Files
jobtrackingapp/job-tracker-ui/src/settings-import-lifetime.test.tsx
T

154 lines
9.0 KiB
TypeScript

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", () => () => <div>Usage auxiliary owner</div>);
jest.mock("./components/QuickCaptureCard", () => () => <div>Capture auxiliary owner</div>);
const mockedApi = api as jest.Mocked<typeof api>;
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(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(<MemoryRouter><ToastProvider><I18nProvider><PromptProvider>
<SettingsView pageSize={20} onPageSizeChange={jest.fn()}
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={jest.fn()}
themeMode="dark" onThemeModeChange={jest.fn()} />
</PromptProvider></I18nProvider></ToastProvider></MemoryRouter>);
}
async function selectFile(rows: unknown[]) {
const input = document.querySelector<HTMLInputElement>('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<any>();
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<any>(); 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);
});