Files
jobtrackingapp/job-tracker-ui/src/correspondence-gmail-import.test.tsx
T

423 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 JobDetailsDialog from "./components/JobDetailsDialog";
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>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={1} />
</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") {
return Promise.resolve({
data: {
items: [
{
id: 42,
jobTitle: "Backend Developer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 3,
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
},
{
id: 77,
jobTitle: "Platform Engineer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 1,
company: { name: "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.findAllByRole("combobox")).slice(-1)[0]);
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',
}),
}));
});
});
});