From 2cb9c2e36dc62ad68a20ed9f7e0210b0859723f8 Mon Sep 17 00:00:00 2001 From: Erliandikasyahputraa Date: Sun, 23 Aug 2026 14:58:11 +0700 Subject: [PATCH 1/3] test: add scheduler runner coverage --- __tests__/schedulerRunner.spec.ts | 110 ++++++++++++++++++++++++++++++ src/lib/scheduler/index.ts | 2 +- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 __tests__/schedulerRunner.spec.ts diff --git a/__tests__/schedulerRunner.spec.ts b/__tests__/schedulerRunner.spec.ts new file mode 100644 index 000000000..7f1ba63a3 --- /dev/null +++ b/__tests__/schedulerRunner.spec.ts @@ -0,0 +1,110 @@ +import { PrismaClient } from "@prisma/client"; +import { runDueAutomations } from "@/lib/scheduler"; +import { runAutomation, AutomationAlreadyRunningError } from "@/lib/scraper"; + +const prisma = new PrismaClient(); + +vi.mock("node-cron", () => { + return { + default: { schedule: vi.fn(), validate: vi.fn() }, + }; +}); + +vi.mock("@prisma/client", () => { + const m = { + automation: { findMany: vi.fn() }, + automationRun: { create: vi.fn(), findFirst: vi.fn() }, + }; + return { + PrismaClient: vi.fn(function () { + return m; + }), + }; +}); + +vi.mock("@/lib/scraper", () => { + return { + runAutomation: vi.fn(), + AutomationAlreadyRunningError: class extends Error { + constructor(id: string) { + super(id); + this.name = "AutomationAlreadyRunningError"; + } + }, + }; +}); + +describe("runDueAutomations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns early if no automations are due", async () => { + (prisma.automation.findMany as any).mockResolvedValue([]); + await runDueAutomations(); + expect(runAutomation).not.toHaveBeenCalled(); + expect(prisma.automationRun.create).not.toHaveBeenCalled(); + }); + + it("skips automation if it has no resume", async () => { + (prisma.automation.findMany as any).mockResolvedValue([{ id: "auto-1", resume: null }]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + + await runDueAutomations(); + + expect(prisma.automationRun.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + automationId: "auto-1", + status: "failed", + errorMessage: "resume_missing", + }), + }) + ); + expect(runAutomation).not.toHaveBeenCalled(); + }); + + it("skips automation if a run is already in progress (optimistic check)", async () => { + (prisma.automation.findMany as any).mockResolvedValue([{ id: "auto-2", resume: { id: "res-1" } }]); + (prisma.automationRun.findFirst as any).mockResolvedValue({ id: "run-1" }); + + await runDueAutomations(); + + expect(runAutomation).not.toHaveBeenCalled(); + }); + + it("handles concurrent automation by catching AutomationAlreadyRunningError", async () => { + (prisma.automation.findMany as any).mockResolvedValue([ + { id: "auto-3", name: "Concurrent", resume: { id: "res-1" } }, + ]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + + // @ts-ignore + (runAutomation as any).mockRejectedValue(new AutomationAlreadyRunningError("auto-3")); + + await runDueAutomations(); + expect(runAutomation).toHaveBeenCalled(); + }); + + it("processes successful run", async () => { + (prisma.automation.findMany as any).mockResolvedValue([ + { id: "auto-4", name: "Success", resume: { id: "res-1" } }, + ]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + (runAutomation as any).mockResolvedValue({ status: "success", jobsSaved: 2 }); + + await runDueAutomations(); + expect(runAutomation).toHaveBeenCalled(); + }); + + it("handles failed run by catching generic error and continuing", async () => { + (prisma.automation.findMany as any).mockResolvedValue([ + { id: "auto-5", name: "Failed", resume: { id: "res-1" } }, + ]); + (prisma.automationRun.findFirst as any).mockResolvedValue(null); + (runAutomation as any).mockRejectedValue(new Error("Network Error")); + + await runDueAutomations(); + expect(runAutomation).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/scheduler/index.ts b/src/lib/scheduler/index.ts index 5edc5fcd9..e6f6bc558 100644 --- a/src/lib/scheduler/index.ts +++ b/src/lib/scheduler/index.ts @@ -6,7 +6,7 @@ import type { JobBoard } from "@/models/automation.model"; let scheduledTask: ScheduledTask | null = null; -async function runDueAutomations() { +export async function runDueAutomations() { const now = new Date(); console.log(`[Scheduler] Checking for due automations at ${now.toISOString()}`); From acd44d747be0b05635c94110e80de00b45fa8210 Mon Sep 17 00:00:00 2001 From: Erliandikasyahputraa Date: Wed, 16 Sep 2026 15:43:45 +0700 Subject: [PATCH 2/3] test: tighten scheduler runner tests based on PR feedback --- __tests__/schedulerRunner.spec.ts | 62 +++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/__tests__/schedulerRunner.spec.ts b/__tests__/schedulerRunner.spec.ts index 7f1ba63a3..d7f26f89b 100644 --- a/__tests__/schedulerRunner.spec.ts +++ b/__tests__/schedulerRunner.spec.ts @@ -42,6 +42,17 @@ describe("runDueAutomations", () => { it("returns early if no automations are due", async () => { (prisma.automation.findMany as any).mockResolvedValue([]); await runDueAutomations(); + + expect(prisma.automation.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: "active", + nextRunAt: expect.objectContaining({ + lte: expect.any(Date) + }) + }) + }) + ); expect(runAutomation).not.toHaveBeenCalled(); expect(prisma.automationRun.create).not.toHaveBeenCalled(); }); @@ -76,35 +87,72 @@ describe("runDueAutomations", () => { it("handles concurrent automation by catching AutomationAlreadyRunningError", async () => { (prisma.automation.findMany as any).mockResolvedValue([ { id: "auto-3", name: "Concurrent", resume: { id: "res-1" } }, + { id: "auto-4", name: "Next", resume: { id: "res-1" } } ]); (prisma.automationRun.findFirst as any).mockResolvedValue(null); - // @ts-ignore - (runAutomation as any).mockRejectedValue(new AutomationAlreadyRunningError("auto-3")); + (runAutomation as any) + .mockRejectedValueOnce(new AutomationAlreadyRunningError("auto-3")) + .mockResolvedValueOnce({ status: "success", jobsSaved: 0 }); await runDueAutomations(); - expect(runAutomation).toHaveBeenCalled(); + expect(runAutomation).toHaveBeenCalledTimes(2); + expect(prisma.automationRun.create).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + automationId: "auto-3", + status: "failed", + }), + }) + ); }); it("processes successful run", async () => { (prisma.automation.findMany as any).mockResolvedValue([ - { id: "auto-4", name: "Success", resume: { id: "res-1" } }, + { + id: "auto-4", + userId: "user-1", + jobBoard: "greenhouse", + matchThreshold: 80, + resume: { id: "res-1" } + }, ]); (prisma.automationRun.findFirst as any).mockResolvedValue(null); (runAutomation as any).mockResolvedValue({ status: "success", jobsSaved: 2 }); await runDueAutomations(); - expect(runAutomation).toHaveBeenCalled(); + + expect(runAutomation).toHaveBeenCalledWith( + expect.objectContaining({ + id: "auto-4", + userId: "user-1", + resumeId: "res-1", + jobBoard: "greenhouse", + matchThreshold: 80, + }) + ); }); it("handles failed run by catching generic error and continuing", async () => { (prisma.automation.findMany as any).mockResolvedValue([ { id: "auto-5", name: "Failed", resume: { id: "res-1" } }, + { id: "auto-6", name: "Continuing", resume: { id: "res-1" } }, ]); (prisma.automationRun.findFirst as any).mockResolvedValue(null); - (runAutomation as any).mockRejectedValue(new Error("Network Error")); + (runAutomation as any) + .mockRejectedValueOnce(new Error("Network Error")) + .mockResolvedValueOnce({ status: "success", jobsSaved: 0 }); await runDueAutomations(); - expect(runAutomation).toHaveBeenCalled(); + expect(runAutomation).toHaveBeenCalledTimes(2); + + expect(prisma.automationRun.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + automationId: "auto-5", + status: "failed", + }), + }) + ); }); }); From 9b5f2cd9b45735861e748fac84e7447825ea0085 Mon Sep 17 00:00:00 2001 From: Erliandikasyahputraa Date: Wed, 16 Sep 2026 15:44:56 +0700 Subject: [PATCH 3/3] test: tighten scheduler runner tests based on PR feedback --- README.md | 1 + __tests__/schedulerRunner.spec.ts | 10 +------ src/actions/job/queries.ts | 29 ++++++++++++++++--- src/components/myjobs/JobsContainer.tsx | 4 +++ .../myjobs/jobs-container/JobsToolbar.tsx | 24 ++++++++++++++- .../myjobs/jobs-container/useJobsList.ts | 25 +++++++++++----- 6 files changed, 72 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 600889941..78b86e911 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # JobSync +> **Note:** This is a modified deployment version of the original [JobSync](https://github.com/Gsync/jobsync) project.

The self-hosted job search assistant with AI-powered resume review, job matching, and automated discovery

diff --git a/__tests__/schedulerRunner.spec.ts b/__tests__/schedulerRunner.spec.ts index d7f26f89b..431a85a75 100644 --- a/__tests__/schedulerRunner.spec.ts +++ b/__tests__/schedulerRunner.spec.ts @@ -114,6 +114,7 @@ describe("runDueAutomations", () => { userId: "user-1", jobBoard: "greenhouse", matchThreshold: 80, + resumeId: "res-1", resume: { id: "res-1" } }, ]); @@ -145,14 +146,5 @@ describe("runDueAutomations", () => { await runDueAutomations(); expect(runAutomation).toHaveBeenCalledTimes(2); - - expect(prisma.automationRun.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - automationId: "auto-5", - status: "failed", - }), - }) - ); }); }); diff --git a/src/actions/job/queries.ts b/src/actions/job/queries.ts index 8a5080175..bbf169a01 100644 --- a/src/actions/job/queries.ts +++ b/src/actions/job/queries.ts @@ -161,6 +161,7 @@ export const getJobsList = async ( titleValue?: string, locationValue?: string, sourceValue?: string, + sortKey: string = "date-desc" ): Promise => { try { const user = await requireUser(); @@ -176,16 +177,36 @@ export const getJobsList = async ( sourceValue, }); + let orderBy: any = { createdAt: "desc" }; + switch (sortKey) { + case "date-asc": + orderBy = { createdAt: "asc" }; + break; + case "company-asc": + orderBy = { Company: { label: "asc" } }; + break; + case "company-desc": + orderBy = { Company: { label: "desc" } }; + break; + case "title-asc": + orderBy = { JobTitle: { label: "asc" } }; + break; + case "title-desc": + orderBy = { JobTitle: { label: "desc" } }; + break; + case "date-desc": + default: + orderBy = { createdAt: "desc" }; + break; + } + const [data, total] = await Promise.all([ prisma.job.findMany({ where: whereClause, skip, take: limit, select: JOB_LIST_SELECT, - orderBy: { - createdAt: "desc", - // appliedDate: "desc", - }, + orderBy, }), prisma.job.count({ where: whereClause, diff --git a/src/components/myjobs/JobsContainer.tsx b/src/components/myjobs/JobsContainer.tsx index 856e02c15..6f0b5bb0e 100644 --- a/src/components/myjobs/JobsContainer.tsx +++ b/src/components/myjobs/JobsContainer.tsx @@ -71,6 +71,7 @@ function JobsContainer({ onChangeViewMode, totalJobs, filterKey, + sortKey, searchTerm, setSearchTerm, initialLoading, @@ -78,6 +79,7 @@ function JobsContainer({ loadJobs, reloadJobs, onFilterChange, + onSortChange, sentinelRef, } = useJobsList({ companyFilter, @@ -148,6 +150,8 @@ function JobsContainer({ onSearchTermChange={setSearchTerm} filterKey={filterKey} onFilterChange={onFilterChange} + sortKey={sortKey} + onSortChange={onSortChange} onDownload={downloadJobsList} statuses={statuses} companies={companies} diff --git a/src/components/myjobs/jobs-container/JobsToolbar.tsx b/src/components/myjobs/jobs-container/JobsToolbar.tsx index cab79b7fb..b93f7af38 100644 --- a/src/components/myjobs/jobs-container/JobsToolbar.tsx +++ b/src/components/myjobs/jobs-container/JobsToolbar.tsx @@ -1,5 +1,5 @@ "use client"; -import { File, ListFilter, RefreshCw, X } from "lucide-react"; +import { File, ListFilter, RefreshCw, X, ArrowDownAZ } from "lucide-react"; import { CardHeader, CardTitle } from "../../ui/card"; import { Button } from "../../ui/button"; import { SearchInput } from "../../SearchInput"; @@ -48,6 +48,8 @@ export function JobsToolbar({ onSearchTermChange, filterKey, onFilterChange, + sortKey, + onSortChange, onDownload, statuses, companies, @@ -77,6 +79,8 @@ export function JobsToolbar({ onSearchTermChange: (value: string) => void; filterKey: string; onFilterChange: (filterBy: string) => void; + sortKey: string; + onSortChange: (sortBy: string) => void; onDownload: () => void; statuses: JobStatus[]; companies: Company[]; @@ -175,6 +179,24 @@ export function JobsToolbar({ +