import { randomUUID } from "node:crypto"; import type { Server } from "node:http"; import type { PostApplicationMessage, PostApplicationRouterStageTarget, } from "@shared/types"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { startServer, stopServer } from "./test-utils"; describe.sequential("Post-Application Review Workflow API", () => { let server: Server; let baseUrl: string; let closeDb: () => void; let tempDir: string; beforeEach(async () => { ({ server, baseUrl, closeDb, tempDir } = await startServer()); }); afterEach(async () => { await stopServer({ server, closeDb, tempDir }); }); async function seedPendingMessage(input?: { syncRunId?: string | null; matchedJobId?: string | null; stageTarget?: PostApplicationRouterStageTarget; }): Promise<{ message: PostApplicationMessage; jobId: string; }> { const { createJob } = await import("@server/repositories/jobs"); const { upsertPostApplicationMessage } = await import( "@server/repositories/post-application-messages" ); const job = await createJob({ source: "manual", title: "Front End JavaScript Developer", employer: "Roku Interactive", jobUrl: `https://example.com/jobs/${randomUUID()}`, }); const { message } = await upsertPostApplicationMessage({ provider: "gmail", accountKey: "default", integrationId: null, syncRunId: input?.syncRunId ?? null, externalMessageId: randomUUID(), fromAddress: "roku@smartrecruiters.com", fromDomain: "smartrecruiters.com", senderName: "Roku", subject: "Interview invitation", receivedAt: Date.now(), snippet: "Please schedule an interview.", classificationLabel: "interview", classificationConfidence: 0.97, classificationPayload: { reason: "High confidence", }, relevanceLlmScore: 97, relevanceDecision: "relevant", matchConfidence: 97, stageTarget: input?.stageTarget ?? "technical_interview", messageType: input?.stageTarget === "rejected" || input?.stageTarget === "withdrawn" ? "rejection" : "interview", stageEventPayload: { note: "from test" }, processingStatus: "pending_user", matchedJobId: input?.matchedJobId === undefined ? job.id : input.matchedJobId, }); return { message, jobId: job.id }; } it("lists pending inbox items", async () => { const { message } = await seedPendingMessage(); const res = await fetch( `${baseUrl}/api/post-application/inbox?provider=gmail&accountKey=default`, ); const body = await res.json(); expect(res.status).toBe(200); expect(body.ok).toBe(true); expect(body.data.total).toBe(1); expect(body.data.items[0].message.id).toBe(message.id); expect(body.data.items[0].message.processingStatus).toBe("pending_user"); expect(typeof body.meta.requestId).toBe("string"); }); it("approves an inbox item and writes stage event", async () => { const { message, jobId } = await seedPendingMessage(); const { db, schema } = await import("@server/db"); const res = await fetch( `${baseUrl}/api/post-application/inbox/${message.id}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "gmail", accountKey: "default", jobId, decidedBy: "tester", }), }, ); const body = await res.json(); expect(res.status).toBe(200); expect(body.ok).toBe(true); expect(body.data.message.processingStatus).toBe("manual_linked"); expect(body.data.message.matchedJobId).toBe(jobId); const stageRows = await db.select().from(schema.stageEvents); expect(stageRows.length).toBeGreaterThan(0); }); it("returns conflict on second approve and increments sync-run approval once", async () => { const { startPostApplicationSyncRun, getPostApplicationSyncRunById } = await import("@server/repositories/post-application-sync-runs"); const run = await startPostApplicationSyncRun({ provider: "gmail", accountKey: "default", integrationId: null, }); const { message, jobId } = await seedPendingMessage({ syncRunId: run.id }); const firstRes = await fetch( `${baseUrl}/api/post-application/inbox/${message.id}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "gmail", accountKey: "default", jobId, decidedBy: "tester", }), }, ); expect(firstRes.status).toBe(200); const secondRes = await fetch( `${baseUrl}/api/post-application/inbox/${message.id}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "gmail", accountKey: "default", jobId, decidedBy: "tester", }), }, ); const secondBody = await secondRes.json(); expect(secondRes.status).toBe(409); expect(secondBody.ok).toBe(false); expect(secondBody.error.code).toBe("CONFLICT"); const updatedRun = await getPostApplicationSyncRunById(run.id); expect(updatedRun?.messagesApproved).toBe(1); expect(updatedRun?.messagesDenied).toBe(0); }); it("denies an inbox item as ignored", async () => { const { message } = await seedPendingMessage(); const denyRes = await fetch( `${baseUrl}/api/post-application/inbox/${message.id}/deny`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "gmail", accountKey: "default", decidedBy: "tester", }), }, ); const denyBody = await denyRes.json(); expect(denyRes.status).toBe(200); expect(denyBody.ok).toBe(true); expect(denyBody.data.message.processingStatus).toBe("ignored"); expect(denyBody.data.message.matchedJobId).toBeNull(); }); it("counts no-suggested-match approve items as skipped, not failed", async () => { await seedPendingMessage({ matchedJobId: null }); const res = await fetch(`${baseUrl}/api/post-application/inbox/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "approve", provider: "gmail", accountKey: "default", decidedBy: "tester", }), }); const body = await res.json(); expect(res.status).toBe(200); expect(body.ok).toBe(true); expect(body.data.requested).toBe(1); expect(body.data.succeeded).toBe(0); expect(body.data.skipped).toBe(1); expect(body.data.failed).toBe(0); expect(body.data.results[0].ok).toBe(false); expect(body.data.results[0].error.code).toBe("NO_SUGGESTED_MATCH"); }); it("lists messages for a sync run", async () => { const { startPostApplicationSyncRun } = await import( "@server/repositories/post-application-sync-runs" ); const run = await startPostApplicationSyncRun({ provider: "gmail", accountKey: "default", integrationId: null, }); const { message } = await seedPendingMessage({ syncRunId: run.id }); const res = await fetch( `${baseUrl}/api/post-application/runs/${run.id}/messages?provider=gmail&accountKey=default`, ); const body = await res.json(); expect(res.status).toBe(200); expect(body.ok).toBe(true); expect(body.data.run.id).toBe(run.id); expect(body.data.total).toBe(1); expect(body.data.items[0].message.id).toBe(message.id); }); it("approves rejected target and sets closed stage with rejected outcome", async () => { const { message, jobId } = await seedPendingMessage({ stageTarget: "rejected", }); const { db, schema } = await import("@server/db"); const res = await fetch( `${baseUrl}/api/post-application/inbox/${message.id}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "gmail", accountKey: "default", jobId, }), }, ); expect(res.status).toBe(200); const stageRows = await db.select().from(schema.stageEvents); expect(stageRows.at(-1)?.toStage).toBe("closed"); expect(stageRows.at(-1)?.outcome).toBe("rejected"); const jobRow = (await db.select().from(schema.jobs)).find( (job) => job.id === jobId, ); expect(jobRow?.outcome).toBe("rejected"); }); it("approves withdrawn target and sets closed stage with withdrawn outcome", async () => { const { message, jobId } = await seedPendingMessage({ stageTarget: "withdrawn", }); const { db, schema } = await import("@server/db"); const res = await fetch( `${baseUrl}/api/post-application/inbox/${message.id}/approve`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "gmail", accountKey: "default", jobId, }), }, ); expect(res.status).toBe(200); const stageRows = await db.select().from(schema.stageEvents); expect(stageRows.at(-1)?.toStage).toBe("closed"); expect(stageRows.at(-1)?.outcome).toBe("withdrawn"); const jobRow = (await db.select().from(schema.jobs)).find( (job) => job.id === jobId, ); expect(jobRow?.outcome).toBe("withdrawn"); }); });