import React from "react"; import "@testing-library/jest-dom"; import { act, 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"; import { translations } from "./i18n/translations"; 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() } }, }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback, })); const mockedApi = api as jest.Mocked; const originalWindowOpen = window.open; const gmailCallbackOrigin = "http://localhost:3000"; const gmailAuthorizationUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=test-client&redirect_uri=${encodeURIComponent(`${gmailCallbackOrigin}/api/gmail/oauth/callback`)}&response_type=code&state=opaque-test-state`; function dialog(jobId = 42) { return ( ); } function renderDialog() { return render(dialog()); } async function oauthMessage(data: unknown, source: Window | null, origin = gmailCallbackOrigin) { await act(async () => { window.dispatchEvent(new MessageEvent("message", { data, source, origin })); }); } describe("correspondence Gmail import", () => { let correspondenceMessages: any[]; let gmailConnected: boolean; beforeEach(() => { window.localStorage.clear(); Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue(undefined), }, }); window.open = jest.fn(); correspondenceMessages = []; gmailConnected = true; 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: gmailConnected, 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/connect-url") { return Promise.resolve({ data: { url: gmailAuthorizationUrl } } 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(() => { window.open = originalWindowOpen; window.localStorage.clear(); 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.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', }), })); }); }); test("ignores an unsolicited Gmail success without status, candidate reads or feedback", async () => { gmailConnected = false; renderDialog(); await screen.findByText(/no messages yet/i); const initialReads = [...mockedApi.get.mock.calls]; await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Forged Gmail success" }, window); expect(mockedApi.get.mock.calls).toEqual(initialReads); expect(screen.queryByRole("alert", { hidden: true })).not.toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); expect(window.open).not.toHaveBeenCalled(); }); test("accepts only the pending Gmail popup and callback origin, including a just-closed popup, once", async () => { gmailConnected = false; const popup = { closed: false, focus: jest.fn() } as unknown as Window; (window.open as jest.Mock).mockReturnValue(popup); renderDialog(); fireEvent.click(await screen.findByRole("button", { name: /import email/i })); fireEvent.click(await screen.findByRole("tab", { name: /^google$/i })); fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i })); await waitFor(() => expect(window.open).toHaveBeenCalledWith(gmailAuthorizationUrl, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes")); const pendingReads = [...mockedApi.get.mock.calls]; const connected = { source: "jobtracker-gmail-oauth", status: "connected" }; await oauthMessage(connected, popup, "https://forged.example.test"); await oauthMessage(connected, window); await oauthMessage(connected, null); await oauthMessage({ source: "jobtracker-microsoft-oauth", status: "connected" }, popup); await oauthMessage({ source: "jobtracker-gmail-oauth", status: "pending" }, popup); await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: 42 }, popup); await oauthMessage(null, popup); expect(mockedApi.get.mock.calls).toEqual(pendingReads); expect(screen.queryByRole("alert", { hidden: true })).not.toBeInTheDocument(); gmailConnected = true; Object.assign(popup, { closed: true }); await oauthMessage(connected, popup); expect(await screen.findByText("Google account linked.")).toBeInTheDocument(); await screen.findByLabelText(/search gmail/i); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/status")).toHaveLength(2); expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({ params: expect.objectContaining({ jobApplicationId: 42 }) })); const acceptedReads = [...mockedApi.get.mock.calls]; await oauthMessage({ ...connected, message: "Duplicate terminal callback" }, popup); expect(mockedApi.get.mock.calls).toEqual(acceptedReads); expect(screen.queryByText("Duplicate terminal callback")).not.toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("keeps a pending Gmail popup across locale changes and reports terminal error in the current locale once", async () => { gmailConnected = false; const popup = { closed: false, focus: jest.fn() } as unknown as Window; (window.open as jest.Mock).mockReturnValue(popup); renderDialog(); fireEvent.click(await screen.findByRole("button", { name: /import email/i })); fireEvent.click(await screen.findByRole("tab", { name: /^google$/i })); fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i })); await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1)); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); await screen.findByRole("button", { name: /^koble til gmail$/i }); const pendingReads = [...mockedApi.get.mock.calls]; await oauthMessage({ source: "jobtracker-gmail-oauth", status: "error" }, popup); expect(await screen.findByText("Google-autentisering mislyktes.")).toBeInTheDocument(); expect(screen.queryByText("Google authentication failed.")).not.toBeInTheDocument(); expect(mockedApi.get.mock.calls).toEqual(pendingReads); await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Error callback already consumed" }, popup); expect(mockedApi.get.mock.calls).toEqual(pendingReads); expect(screen.queryByText("Error callback already consumed")).not.toBeInTheDocument(); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("does not open a Gmail popup when its connect URL resolves after unmount", async () => { gmailConnected = false; let resolveConnect!: (value: { data: { url: string } }) => void; const connect = new Promise((resolve) => { resolveConnect = resolve; }); const get = mockedApi.get.getMockImplementation()!; mockedApi.get.mockImplementation((url: string, config?: any) => url === "/gmail/connect-url" ? connect : get(url, config)); const view = renderDialog(); fireEvent.click(await screen.findByRole("button", { name: /import email/i })); fireEvent.click(await screen.findByRole("tab", { name: /^google$/i })); fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i })); expect(mockedApi.get).toHaveBeenCalledWith("/gmail/connect-url"); view.unmount(); const disposedReads = [...mockedApi.get.mock.calls]; await act(async () => { resolveConnect({ data: { url: gmailAuthorizationUrl } }); }); expect(window.open).not.toHaveBeenCalled(); expect(mockedApi.get.mock.calls).toEqual(disposedReads); expect(mockedApi.post).not.toHaveBeenCalled(); }); test("invalidates the pending Gmail callback when the correspondence job changes", async () => { gmailConnected = false; const popup = { closed: false, focus: jest.fn() } as unknown as Window; (window.open as jest.Mock).mockReturnValue(popup); const view = renderDialog(); fireEvent.click(await screen.findByRole("button", { name: /import email/i })); fireEvent.click(await screen.findByRole("tab", { name: /^google$/i })); fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i })); await waitFor(() => expect(window.open).toHaveBeenCalledTimes(1)); view.rerender(dialog(43)); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/correspondence/43")); const currentJobReads = [...mockedApi.get.mock.calls]; await oauthMessage({ source: "jobtracker-gmail-oauth", status: "connected", message: "Previous job Gmail callback" }, popup); expect(mockedApi.get.mock.calls).toEqual(currentJobReads); expect(screen.queryByText("Previous job Gmail callback")).not.toBeInTheDocument(); expect(mockedApi.get.mock.calls.filter(([url]) => url === "/gmail/job-candidates")).toHaveLength(0); expect(mockedApi.post).not.toHaveBeenCalled(); }); test.each(["blocked", "rejected"] as const)("delayed %s Gmail startup feedback follows a locale change while the connect URL is pending", async outcome => { gmailConnected = false; let resolveConnect!: (value: any) => void; let rejectConnect!: (cause: unknown) => void; const pending = new Promise((resolve, reject) => { resolveConnect = resolve; rejectConnect = reject; }); const get = mockedApi.get.getMockImplementation()!; mockedApi.get.mockImplementation((url: string, config?: any) => url === "/gmail/connect-url" ? pending : get(url, config)); (window.open as jest.Mock).mockReturnValue(null); renderDialog(); fireEvent.click(await screen.findByRole("button", { name: /import email/i })); fireEvent.click(await screen.findByRole("tab", { name: /^google$/i })); fireEvent.click(await screen.findByRole("button", { name: /^connect gmail$/i })); expect(mockedApi.get).toHaveBeenCalledWith("/gmail/connect-url"); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); await screen.findByRole("button", { name: /^koble til gmail$/i }); const reads = [...mockedApi.get.mock.calls]; await act(async () => { if (outcome === "blocked") resolveConnect({ data: { url: gmailAuthorizationUrl } }); else rejectConnect(new Error("offline")); }); const key = outcome === "blocked" ? "correspondenceBlockedPopup" : "correspondenceStartGmailFailed"; expect(await screen.findByText(translations.nb[key])).toBeVisible(); expect(screen.queryByText(translations.en[key])).not.toBeInTheDocument(); expect(mockedApi.get.mock.calls).toEqual(reads); expect(window.open).toHaveBeenCalledTimes(outcome === "blocked" ? 1 : 0); expect(mockedApi.post).not.toHaveBeenCalled(); }); });