32d16089f1
The routed job workspace owns analysis, correspondence, timeline, and interview flows. Remove the unreachable duplicate dialog and keep coverage on the live components and route contracts.
418 lines
16 KiB
TypeScript
418 lines
16 KiB
TypeScript
import React from "react";
|
|
import "@testing-library/jest-dom";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { ConfirmProvider } from "./confirm";
|
|
import { PromptProvider } from "./prompt";
|
|
import { ToastProvider } from "./toast";
|
|
import { I18nProvider } from "./i18n/I18nProvider";
|
|
import Correspondence from "./components/Correspondence";
|
|
import { api } from "./api";
|
|
|
|
jest.setTimeout(15000);
|
|
|
|
jest.mock("./api", () => ({
|
|
api: {
|
|
get: jest.fn(),
|
|
post: jest.fn(),
|
|
put: jest.fn(),
|
|
patch: jest.fn(),
|
|
delete: jest.fn(),
|
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
|
},
|
|
}));
|
|
|
|
const mockedApi = api as jest.Mocked<typeof api>;
|
|
|
|
function renderDialog() {
|
|
return render(
|
|
<ToastProvider>
|
|
<I18nProvider>
|
|
<ConfirmProvider>
|
|
<PromptProvider>
|
|
<Correspondence
|
|
jobId={42}
|
|
jobContext={{ jobTitle: "Backend Developer", companyName: "Acme", recruiterEmail: "maria@acme.test" }}
|
|
/>
|
|
</PromptProvider>
|
|
</ConfirmProvider>
|
|
</I18nProvider>
|
|
</ToastProvider>,
|
|
);
|
|
}
|
|
|
|
describe("correspondence Gmail import", () => {
|
|
let correspondenceMessages: any[];
|
|
|
|
beforeEach(() => {
|
|
Object.assign(navigator, {
|
|
clipboard: {
|
|
writeText: jest.fn().mockResolvedValue(undefined),
|
|
},
|
|
});
|
|
window.open = jest.fn();
|
|
|
|
correspondenceMessages = [];
|
|
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
|
if (url === "/jobapplications/choices") {
|
|
return Promise.resolve({
|
|
data: [
|
|
{
|
|
id: 42,
|
|
jobTitle: "Backend Developer",
|
|
companyName: "Acme",
|
|
},
|
|
{
|
|
id: 77,
|
|
jobTitle: "Platform Engineer",
|
|
companyName: "Beta",
|
|
},
|
|
],
|
|
} as any);
|
|
}
|
|
if (url === "/jobapplications/42") {
|
|
return Promise.resolve({
|
|
data: {
|
|
id: 42,
|
|
jobTitle: "Backend Developer",
|
|
status: "Applied",
|
|
dateApplied: new Date().toISOString(),
|
|
daysSince: 3,
|
|
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
|
|
tailoredCvText: "",
|
|
shortSummary: "summary",
|
|
},
|
|
} as any);
|
|
}
|
|
if (url === "/auth/me") {
|
|
return Promise.resolve({ data: { roles: [], profileCvText: "Master CV text" } } as any);
|
|
}
|
|
if (url === "/jobapplications/42/history") {
|
|
return Promise.resolve({ data: [] } as any);
|
|
}
|
|
if (url === "/attachments/42") {
|
|
return Promise.resolve({ data: [] } as any);
|
|
}
|
|
if (url === "/correspondence/42") {
|
|
return Promise.resolve({ data: correspondenceMessages } as any);
|
|
}
|
|
if (url === "/gmail/status") {
|
|
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString(), lastSyncAttemptedAt: new Date().toISOString(), lastSyncMode: "list-messages", lastSyncSource: "custom-query", lastSyncStatus: "error", lastSyncError: "Token refresh failed" } } as any);
|
|
}
|
|
if (url === "/gmail/job-candidates") {
|
|
return Promise.resolve({
|
|
data: {
|
|
jobApplicationId: 42,
|
|
jobTitle: "Backend Developer",
|
|
companyName: "Acme",
|
|
recruiterName: "Maria Recruiter",
|
|
recruiterEmail: "maria@acme.test",
|
|
queries: [config?.params?.queryOverride || '"Acme" "Backend Developer" newer_than:365d'],
|
|
threads: [
|
|
{
|
|
threadId: "thread-1",
|
|
subject: "Backend Developer interview",
|
|
score: 42,
|
|
confidence: "high",
|
|
hasImportedMessages: false,
|
|
messageCount: 2,
|
|
latestDate: new Date().toISOString(),
|
|
matchReasons: [
|
|
{ label: "company", value: "Acme" },
|
|
{ label: "recruiterEmail", value: "maria@acme.test" },
|
|
],
|
|
messages: [
|
|
{
|
|
id: "msg-1",
|
|
threadId: "thread-1",
|
|
subject: "Backend Developer interview",
|
|
from: "Maria Recruiter <maria@acme.test>",
|
|
to: "user@example.test",
|
|
date: new Date().toISOString(),
|
|
snippet: "Acme wants to schedule a call.",
|
|
score: 42,
|
|
confidence: "high",
|
|
alreadyImported: false,
|
|
matchReasons: [
|
|
{ label: "company", value: "Acme" },
|
|
{ label: "jobTitle", value: "Developer" },
|
|
],
|
|
},
|
|
{
|
|
id: "msg-2",
|
|
threadId: "thread-1",
|
|
subject: "Backend Developer follow-up",
|
|
from: "user@example.test",
|
|
to: "Maria Recruiter <maria@acme.test>",
|
|
date: new Date().toISOString(),
|
|
snippet: "Following up on the role.",
|
|
score: 24,
|
|
confidence: "medium",
|
|
alreadyImported: false,
|
|
matchReasons: [{ label: "recency", value: "45d" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
} as any);
|
|
}
|
|
return Promise.resolve({ data: [] } as any);
|
|
});
|
|
|
|
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
|
if (url === "/gmail/relink-thread") {
|
|
correspondenceMessages = [];
|
|
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, imported: 1, skipped: 0, unlinkedMessages: 1 } } as any);
|
|
}
|
|
if (url === "/gmail/unlink-thread") {
|
|
const removed = correspondenceMessages.filter((message) => message.externalThreadId === body.threadId).length;
|
|
correspondenceMessages = correspondenceMessages.filter((message) => message.externalThreadId !== body.threadId);
|
|
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, removedMessages: removed, decision: body.nextDecision || 'review' } } as any);
|
|
}
|
|
if (url === "/gmail/refresh-linked-threads") {
|
|
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === "msg-2");
|
|
if (!hasReply && correspondenceMessages.some((message) => message.externalThreadId === "thread-1")) {
|
|
correspondenceMessages = [
|
|
...correspondenceMessages,
|
|
{
|
|
id: 701,
|
|
jobApplicationId: 42,
|
|
from: "Me",
|
|
content: "Following up on the role.",
|
|
subject: "Backend Developer follow-up",
|
|
channel: "Email",
|
|
date: new Date().toISOString(),
|
|
externalMessageId: "msg-2",
|
|
externalThreadId: "thread-1",
|
|
externalFrom: "user@example.test",
|
|
externalTo: "Maria Recruiter <maria@acme.test>",
|
|
},
|
|
];
|
|
return Promise.resolve({
|
|
data: {
|
|
jobApplicationId: body.jobApplicationId,
|
|
threadsChecked: 1,
|
|
imported: 1,
|
|
skipped: 1,
|
|
hasLinkedThreads: true,
|
|
refreshedAt: new Date().toISOString(),
|
|
threads: [
|
|
{
|
|
threadId: "thread-1",
|
|
imported: 1,
|
|
skipped: 1,
|
|
totalMessages: 2,
|
|
status: "imported-new-messages",
|
|
latestMessageDate: new Date().toISOString(),
|
|
},
|
|
],
|
|
},
|
|
} as any);
|
|
}
|
|
|
|
return Promise.resolve({
|
|
data: {
|
|
jobApplicationId: body.jobApplicationId,
|
|
threadsChecked: correspondenceMessages.some((message) => message.externalThreadId === "thread-1") ? 1 : 0,
|
|
imported: 0,
|
|
skipped: correspondenceMessages.some((message) => message.externalThreadId === "thread-1") ? correspondenceMessages.filter((message) => message.externalThreadId === "thread-1").length : 0,
|
|
hasLinkedThreads: correspondenceMessages.some((message) => message.externalThreadId === "thread-1"),
|
|
refreshedAt: new Date().toISOString(),
|
|
threads: correspondenceMessages.some((message) => message.externalThreadId === "thread-1")
|
|
? [{ threadId: "thread-1", imported: 0, skipped: correspondenceMessages.filter((message) => message.externalThreadId === "thread-1").length, totalMessages: correspondenceMessages.filter((message) => message.externalThreadId === "thread-1").length, status: "already-current", latestMessageDate: new Date().toISOString() }]
|
|
: [],
|
|
},
|
|
} as any);
|
|
}
|
|
if (url === "/gmail/import") {
|
|
correspondenceMessages = [
|
|
{
|
|
id: 700,
|
|
jobApplicationId: 42,
|
|
from: "Company",
|
|
content: "Acme wants to schedule a call.",
|
|
subject: "Backend Developer interview",
|
|
channel: "Email",
|
|
date: new Date().toISOString(),
|
|
externalMessageId: body.messageId,
|
|
externalThreadId: "thread-1",
|
|
externalFrom: "Maria Recruiter <maria@acme.test>",
|
|
externalTo: "user@example.test",
|
|
},
|
|
];
|
|
return Promise.resolve({
|
|
data: {
|
|
imported: 1,
|
|
skipped: 0,
|
|
messageId: body.messageId,
|
|
threadId: "thread-1",
|
|
message: correspondenceMessages[0],
|
|
},
|
|
} as any);
|
|
}
|
|
if (url === "/gmail/import-thread") {
|
|
return Promise.resolve({ data: { imported: 2, skipped: 0, threadId: "thread-1" } } as any);
|
|
}
|
|
if (url === "/correspondence") {
|
|
return Promise.resolve({ data: {} } as any);
|
|
}
|
|
return Promise.resolve({ data: {} } as any);
|
|
});
|
|
|
|
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
|
mockedApi.delete.mockResolvedValue({ data: {} } as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test("shows ranked Gmail suggestions with reasons and refreshes correspondence after message import", async () => {
|
|
renderDialog();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
|
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
|
|
|
expect((await screen.findAllByText(/backend developer interview/i)).length).toBeGreaterThan(0);
|
|
expect((await screen.findAllByText(/high confidence · score 42/i)).length).toBeGreaterThan(0);
|
|
expect((await screen.findAllByText(/company: acme/i)).length).toBeGreaterThan(0);
|
|
expect(await screen.findByText(/recruiter email: maria@acme\.test/i)).toBeInTheDocument();
|
|
|
|
const importButtons = await screen.findAllByRole("button", { name: /^import email$/i });
|
|
fireEvent.click(importButtons[importButtons.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/import", { jobApplicationId: 42, messageId: "msg-2" });
|
|
});
|
|
|
|
expect(await screen.findByText(/thread thread-1/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/from maria recruiter <maria@acme\.test>/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/to user@example\.test/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("automatically refreshes already-linked Gmail threads without manual re-import", async () => {
|
|
correspondenceMessages = [
|
|
{
|
|
id: 700,
|
|
jobApplicationId: 42,
|
|
from: "Company",
|
|
content: "Acme wants to schedule a call.",
|
|
subject: "Backend Developer interview",
|
|
channel: "Email",
|
|
date: new Date().toISOString(),
|
|
externalMessageId: "msg-1",
|
|
externalThreadId: "thread-1",
|
|
externalFrom: "Maria Recruiter <maria@acme.test>",
|
|
externalTo: "user@example.test",
|
|
},
|
|
];
|
|
|
|
renderDialog();
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/refresh-linked-threads", { jobApplicationId: 42 });
|
|
});
|
|
|
|
expect(await screen.findByText(/backend developer follow-up/i)).toBeInTheDocument();
|
|
expect(await screen.findByText(/following up on the role\./i)).toBeInTheDocument();
|
|
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("lets the user unlink a linked Gmail thread", async () => {
|
|
correspondenceMessages = [
|
|
{
|
|
id: 700,
|
|
jobApplicationId: 42,
|
|
from: "Company",
|
|
content: "Acme wants to schedule a call.",
|
|
subject: "Backend Developer interview",
|
|
channel: "Email",
|
|
date: new Date().toISOString(),
|
|
externalMessageId: "msg-1",
|
|
externalThreadId: "thread-1",
|
|
externalFrom: "Maria Recruiter <maria@acme.test>",
|
|
externalTo: "user@example.test",
|
|
},
|
|
];
|
|
|
|
renderDialog();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
|
fireEvent.click(await screen.findByRole("button", { name: /unlink from this job/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/unlink-thread", expect.objectContaining({
|
|
jobApplicationId: 42,
|
|
threadId: "thread-1",
|
|
nextDecision: "review",
|
|
}));
|
|
});
|
|
|
|
expect(await screen.findByText(/no messages yet/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("lets the user move a linked Gmail thread to another job", async () => {
|
|
correspondenceMessages = [
|
|
{
|
|
id: 702,
|
|
jobApplicationId: 42,
|
|
from: "Company",
|
|
content: "Second import.",
|
|
subject: "Backend Developer interview",
|
|
channel: "Email",
|
|
date: new Date().toISOString(),
|
|
externalMessageId: "msg-1",
|
|
externalThreadId: "thread-1",
|
|
externalFrom: "Maria Recruiter <maria@acme.test>",
|
|
externalTo: "user@example.test",
|
|
},
|
|
];
|
|
|
|
renderDialog();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
|
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /move to job/i }));
|
|
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
|
|
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/relink-thread", expect.objectContaining({
|
|
jobApplicationId: 77,
|
|
threadId: "thread-1",
|
|
removeFromOtherJobs: true,
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("shows Gmail sync state diagnostics alongside linked thread continuity", async () => {
|
|
renderDialog();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
|
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
|
|
|
expect(await screen.findByText(/sync checked/i)).toBeInTheDocument();
|
|
expect((await screen.findAllByText(/token refresh failed/i)).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
|
|
renderDialog();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
|
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
|
|
|
const search = await screen.findByLabelText(/search gmail/i);
|
|
fireEvent.change(search, { target: { value: 'subject:"Acme" newer_than:30d' } });
|
|
fireEvent.click(screen.getByRole("button", { name: /^search$/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({
|
|
params: expect.objectContaining({
|
|
jobApplicationId: 42,
|
|
queryOverride: 'subject:"Acme" newer_than:30d',
|
|
}),
|
|
}));
|
|
});
|
|
});
|
|
});
|