import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import SettingsView from "./components/SettingsView"; import { I18nProvider } from "./i18n/I18nProvider"; import { translations } from "./i18n/translations"; import { ToastProvider } from "./toast"; import { PromptProvider } from "./prompt"; import { api } from "./api"; jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), put: jest.fn() }, getApiErrorMessage: (_error: unknown, fallback: string) => fallback, })); jest.mock("./sessionBoundary", () => ({ SESSION_INVALIDATED_EVENT: "session-invalidated", checkSessionCookie: jest.fn(), getSessionContext: jest.fn(() => "owner-a-session"), isSessionInvalidated: jest.fn(() => false), })); jest.mock("./components/AiUsageCard", () => () =>
Usage auxiliary owner
); jest.mock("./components/QuickCaptureCard", () => () =>
Capture auxiliary owner
); const mockedApi = api as jest.Mocked; const savedRules = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 }; const savedPrivacy = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: "local" }; const reads = (path: string) => mockedApi.get.mock.calls.filter(([url]) => url === path); const text = (key: "lastImport" | "importedJobs", count: number) => translations.en[key].replace("{count}", String(count)); function deferred() { let resolve!: (value: T) => void; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; } function file(rows: unknown[]) { const contents = JSON.stringify(rows); const selected = new File([contents], "jobs.json", { type: "application/json" }); Object.defineProperty(selected, "text", { value: jest.fn().mockResolvedValue(contents) }); return selected; } function renderSettings() { return render( ); } async function selectFile(rows: unknown[]) { const input = document.querySelector('input[accept="application/json"]'); expect(input).not.toBeNull(); await act(async () => { fireEvent.change(input!, { target: { files: [file(rows)] } }); }); } beforeEach(() => { jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.post.mockReset(); mockedApi.put.mockReset(); window.localStorage.clear(); mockedApi.get.mockImplementation((path: string) => { if (path === "/ai/settings") return Promise.resolve({ data: savedPrivacy } as any); if (path === "/notification-settings") return Promise.resolve({ data: { emailFollowUpRemindersEnabled: true } } as any); if (path === "/rules") return Promise.resolve({ data: savedRules } as any); if (path === "/account-lifecycle/status") return Promise.resolve({ data: { deletionEnabled: false, deletionStatus: "active", requiredConfirmation: "DELETE owner@example.test", request: null } } as any); return Promise.reject(new Error(`Unexpected GET ${path}`)); }); mockedApi.put.mockResolvedValue({ data: {} } as any); }); test("[P7-09 lifetime] an accepted import continues once across Notifications and Rules while a real Settings unmount stops queued writes", async () => { const company = deferred(); let companies = 0; mockedApi.post.mockImplementation((path: string) => { if (path === "/companies") { companies += 1; return companies === 1 ? company.promise : Promise.resolve({ data: { id: 32 } } as any); } if (path === "/jobapplications") return Promise.resolve({ data: { id: 100 + companies } } as any); return Promise.reject(new Error(`Unexpected POST ${path}`)); }); const mounted = renderSettings(); await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); await selectFile([ { jobTitle: "First preserved role", company: { name: "Ærlig AS" } }, { jobTitle: "Second preserved role", company: { name: "Neste AS" } }, ]); expect(mockedApi.post.mock.calls).toEqual([["/companies", { name: "Ærlig AS" }]]); expect(screen.getByRole("group", { name: translations.en.importExportTitle })).toHaveAttribute("aria-busy", "true"); fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabNotifications })); expect(screen.getByRole("tabpanel", { name: translations.en.settingsTabNotifications })).toBeVisible(); expect(screen.queryByRole("button", { name: translations.en.importJson })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabFollowUps })); const rule = await screen.findByRole("spinbutton", { name: translations.en.rulesAppliedFollowUpDays }); fireEvent.change(rule, { target: { value: "5" } }); await act(async () => company.resolve({ data: { id: 31 } })); await waitFor(() => expect(mockedApi.post.mock.calls).toEqual([ ["/companies", { name: "Ærlig AS" }], ["/jobapplications", { jobTitle: "First preserved role", companyId: 31 }], ["/companies", { name: "Neste AS" }], ["/jobapplications", { jobTitle: "Second preserved role", companyId: 32 }], ])); expect(await screen.findByText(text("importedJobs", 2))).toBeVisible(); expect(rule).toHaveValue(5); fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabGeneral })); const group = screen.getByRole("group", { name: translations.en.importExportTitle }); expect(within(group).getByRole("status")).toHaveTextContent(text("lastImport", 2)); expect(within(group).getByRole("button", { name: translations.en.importJson })).toBeEnabled(); expect(group).toHaveAttribute("aria-busy", "false"); expect(reads("/ai/settings")).toHaveLength(1); expect(reads("/account-lifecycle/status")).toHaveLength(0); expect(mockedApi.put).not.toHaveBeenCalled(); const disposedCompany = deferred(); mockedApi.post.mockReturnValueOnce(disposedCompany.promise); await selectFile([{ jobTitle: "Must stop after leaving Settings", company: { name: "Disposed AS" } }]); expect(mockedApi.post).toHaveBeenCalledTimes(5); mounted.unmount(); await act(async () => disposedCompany.resolve({ data: { id: 33 } })); expect(mockedApi.post).toHaveBeenCalledTimes(5); }); test("[P7-09 lifetime] the General privacy owner keeps its draft and read across internal tabs while Backup remains lazy and disposable", async () => { const mounted = renderSettings(); const privacy = await screen.findByRole("switch", { name: translations.en.settingsAiEnabled }); expect(privacy).toBeChecked(); fireEvent.click(privacy); expect(privacy).not.toBeChecked(); expect(reads("/ai/settings")).toHaveLength(1); expect(reads("/account-lifecycle/status")).toHaveLength(0); fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabNotifications })); expect(privacy).toBeInTheDocument(); expect(privacy).not.toBeVisible(); expect(privacy.closest('[role="tabpanel"]')).toHaveAttribute("hidden"); expect(screen.queryByRole("switch", { name: translations.en.settingsAiEnabled })).not.toBeInTheDocument(); await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); }); fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabFollowUps })); await screen.findByRole("spinbutton", { name: translations.nb.rulesAppliedFollowUpDays }); fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabGeneral })); expect(screen.getByRole("switch", { name: translations.nb.settingsAiEnabled })).not.toBeChecked(); expect(screen.getByRole("switch", { name: translations.nb.settingsAiExternalAllowed })).toBeDisabled(); expect(reads("/ai/settings")).toHaveLength(1); expect(reads("/account-lifecycle/status")).toHaveLength(0); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabBackup })); await screen.findByText(translations.nb.accountDeleteDisabled); expect(reads("/account-lifecycle/status")).toHaveLength(1); fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabGeneral })); fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabBackup })); await waitFor(() => expect(reads("/account-lifecycle/status")).toHaveLength(2)); expect(reads("/ai/settings")).toHaveLength(1); expect(mockedApi.put).not.toHaveBeenCalled(); mounted.unmount(); renderSettings(); expect(await screen.findByRole("switch", { name: translations.nb.settingsAiEnabled })).toBeChecked(); expect(reads("/ai/settings")).toHaveLength(2); expect(reads("/account-lifecycle/status")).toHaveLength(2); });