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; function renderDialog() { return render( {}} initialTab={1} /> , ); } 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 ", 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 ", 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 ", }, ]; 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 ", 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 /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 ", 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 ", 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 ", 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', }), })); }); }); });