* Deduplicate string cleanup helpers and not-found responses * Enforce aliased imports for infra and shared modules * Enforce @client/@server aliases for deep relative imports * Deduplicate visa sponsor and location filter definitions * Use shared city filter export in extractor location checks
304 lines
9.4 KiB
TypeScript
304 lines
9.4 KiB
TypeScript
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");
|
|
});
|
|
});
|