154 lines
9.0 KiB
TypeScript
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);
|
|
});
|