* feat(post-application): add schema and shared types for provider ingestion (#136) * test(orchestrator): ensure full localStorage shape in vitest setup * feat(post-application): add provider registry and dispatcher framework (#137) (#146) * Implement Gmail provider credential persistence (#147) * Add unified post-application provider action API (#148) * Implement Gmail ingestion sync with 95/60 relevance policy * Implement Gmail ingestion sync with 95/60 relevance policy (#149) * feat(post-application): add job mapping engine with llm rerank fallback * feat(post-application): add inbox review APIs with transactional approve/deny (#151) * feat(post-application): add tracking inbox UI with provider controls (#152) * oauth implementation * UI changes * see past runs in more detail * occurred at comes from email * state mismatch * better UI representation * comments * comments * comments * comments * documentation * explainer * set things manually * scrolling * any found email can be pending * searchable download * Email-to-Job Matching Decision Tree * email viewer list improvement * simplification initial commit * exclude discovered jobs * show only resady * dropdown * mermaid * syntax * targets is the same as logging that is done manually * event label * duplicate avoidance * clean up html * token saving * print * send idx not uuid * remove logging * formatting * better documentation * documentation * comments * process all * comments
165 lines
4.6 KiB
TypeScript
165 lines
4.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@server/repositories/post-application-integrations", () => ({
|
|
getPostApplicationIntegration: vi.fn().mockResolvedValue({
|
|
id: "integration-1",
|
|
provider: "gmail",
|
|
accountKey: "default",
|
|
displayName: "Gmail",
|
|
status: "connected",
|
|
credentials: {
|
|
refreshToken: "refresh-token",
|
|
accessToken: "access-token",
|
|
expiryDate: Date.now() + 60 * 60 * 1000,
|
|
},
|
|
lastConnectedAt: null,
|
|
lastSyncedAt: null,
|
|
lastError: null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}),
|
|
updatePostApplicationIntegrationSyncState: vi.fn().mockResolvedValue(null),
|
|
upsertConnectedPostApplicationIntegration: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
vi.mock("@server/repositories/post-application-sync-runs", () => ({
|
|
startPostApplicationSyncRun: vi
|
|
.fn()
|
|
.mockResolvedValue({ id: "sync-run-1", startedAt: Date.now() }),
|
|
completePostApplicationSyncRun: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
vi.mock("@server/repositories/jobs", () => ({
|
|
getAllJobs: vi.fn().mockResolvedValue([
|
|
{
|
|
id: "job-1",
|
|
employer: "Example Co",
|
|
title: "Software Engineer",
|
|
status: "applied",
|
|
},
|
|
]),
|
|
}));
|
|
|
|
const upsertPostApplicationMessage = vi.fn();
|
|
vi.mock("@server/repositories/post-application-messages", () => ({
|
|
upsertPostApplicationMessage,
|
|
}));
|
|
|
|
const transitionStage = vi.fn();
|
|
vi.mock("@server/services/applicationTracking", () => ({
|
|
transitionStage,
|
|
}));
|
|
|
|
vi.mock("@server/repositories/settings", () => ({
|
|
getSetting: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
vi.mock("@server/services/llm-service", () => ({
|
|
LlmService: class {
|
|
callJson() {
|
|
return Promise.resolve({
|
|
success: true,
|
|
data: {
|
|
bestMatchIndex: 1,
|
|
confidence: 99,
|
|
stageTarget: "assessment",
|
|
isRelevant: true,
|
|
stageEventPayload: null,
|
|
reason: "matches",
|
|
},
|
|
});
|
|
}
|
|
},
|
|
}));
|
|
|
|
function makeJsonResponse(body: unknown): Response {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => body,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe("gmail sync auto-log idempotency", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: string | URL) => {
|
|
const url = String(input);
|
|
if (url.includes("/gmail/v1/users/me/messages?")) {
|
|
return makeJsonResponse({
|
|
messages: [{ id: "message-1", threadId: "thread-1" }],
|
|
});
|
|
}
|
|
if (url.includes("message-1") && url.includes("format=metadata")) {
|
|
return makeJsonResponse({
|
|
id: "message-1",
|
|
threadId: "thread-1",
|
|
snippet: "snippet",
|
|
payload: {
|
|
headers: [
|
|
{ name: "From", value: "Recruiter <jobs@example.com>" },
|
|
{ name: "Subject", value: "Interview update" },
|
|
{ name: "Date", value: new Date().toUTCString() },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
if (url.includes("message-1") && url.includes("format=full")) {
|
|
return makeJsonResponse({
|
|
id: "message-1",
|
|
threadId: "thread-1",
|
|
snippet: "snippet",
|
|
payload: {
|
|
mimeType: "text/plain",
|
|
body: {
|
|
data: Buffer.from("Hello").toString("base64url"),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
throw new Error(`Unexpected fetch URL in test: ${url}`);
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("creates auto stage event only on first auto_linked transition", async () => {
|
|
const { runGmailIngestionSync } = await import("./gmail-sync");
|
|
|
|
upsertPostApplicationMessage
|
|
.mockResolvedValueOnce({
|
|
message: {
|
|
id: "post-msg-1",
|
|
matchedJobId: "job-1",
|
|
processingStatus: "auto_linked",
|
|
stageTarget: "assessment",
|
|
receivedAt: Date.now(),
|
|
},
|
|
wasCreated: true,
|
|
previousProcessingStatus: null,
|
|
autoLinkTransitioned: true,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
message: {
|
|
id: "post-msg-1",
|
|
matchedJobId: "job-1",
|
|
processingStatus: "auto_linked",
|
|
stageTarget: "assessment",
|
|
receivedAt: Date.now(),
|
|
},
|
|
wasCreated: false,
|
|
previousProcessingStatus: "auto_linked",
|
|
autoLinkTransitioned: false,
|
|
});
|
|
|
|
await runGmailIngestionSync({ accountKey: "default", maxMessages: 1 });
|
|
await runGmailIngestionSync({ accountKey: "default", maxMessages: 1 });
|
|
|
|
expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2);
|
|
expect(transitionStage).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|