fix(settings): preserve safe job import compatibility and progress
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
import { api } from "../api";
|
||||
import { Company, JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { checkSessionCookie, getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "../sessionBoundary";
|
||||
|
||||
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
|
||||
// row has no business supplying one.
|
||||
@@ -14,6 +15,105 @@ type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
|
||||
company: Pick<Company, "name" | "location" | "source">;
|
||||
};
|
||||
|
||||
type ImportFields = Record<string, string | number | null>;
|
||||
type PreparedImport = { company: ImportFields; job: ImportFields };
|
||||
type ImportState =
|
||||
| { kind: "checking" }
|
||||
| { kind: "running"; count: number; total: number }
|
||||
| { kind: "invalid"; row?: number }
|
||||
| { kind: "stopped"; row: number; count: number; total: number };
|
||||
|
||||
class InvalidImport extends Error {
|
||||
constructor(readonly row?: number) { super("Invalid import"); }
|
||||
}
|
||||
|
||||
function importRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new InvalidImport();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function importAlias<T>(record: Record<string, unknown>, name: string, parse: (value: unknown) => T): T | undefined {
|
||||
const pascal = name[0].toUpperCase() + name.slice(1);
|
||||
const camelPresent = Object.prototype.hasOwnProperty.call(record, name);
|
||||
const pascalPresent = Object.prototype.hasOwnProperty.call(record, pascal);
|
||||
const camel = camelPresent ? parse(record[name]) : undefined;
|
||||
const exported = pascalPresent ? parse(record[pascal]) : undefined;
|
||||
if (camelPresent && pascalPresent && JSON.stringify(camel) !== JSON.stringify(exported)) throw new InvalidImport();
|
||||
return camelPresent ? camel : exported;
|
||||
}
|
||||
|
||||
function importText(value: unknown): string | null {
|
||||
if (value !== null && typeof value !== "string") throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importRequiredText(value: unknown): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importDate(value: unknown): string | null {
|
||||
const text = importText(value);
|
||||
if (text === null) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,16}))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/.exec(text);
|
||||
const instant = Date.parse(text);
|
||||
if (!match || !Number.isFinite(instant)) throw new InvalidImport();
|
||||
const [, year, month, day, hour, minute, second, , offset, offsetHour, offsetMinute] = match;
|
||||
if (Number(year) < 1 || Number(month) < 1 || Number(month) > 12 || Number(day) < 1 ||
|
||||
Number(day) > new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate() ||
|
||||
Number(hour ?? 0) > 23 || Number(minute ?? 0) > 59 || Number(second ?? 0) > 59 ||
|
||||
Number(offsetHour ?? 0) > 14 || Number(offsetMinute ?? 0) > 59 || Number(offsetHour) === 14 && Number(offsetMinute) !== 0) throw new InvalidImport();
|
||||
// DateTime offsets must fit its year range; no-zone values retain unspecified local-time semantics.
|
||||
if (offset && (instant < Date.parse("0001-01-01T00:00:00Z") || instant > Date.parse("9999-12-31T23:59:59.999Z"))) throw new InvalidImport();
|
||||
return text;
|
||||
}
|
||||
|
||||
function importNumber(value: unknown): number | null {
|
||||
// decimal.MaxValue is 2^96 - 1; JavaScript rounds that bound up, so equality must also be rejected.
|
||||
if (value !== null && (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 2 ** 96)) throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importCompany(value: unknown): ImportFields {
|
||||
const source = importRecord(value);
|
||||
const name = importAlias(source, "name", importRequiredText);
|
||||
if (name === undefined) throw new InvalidImport();
|
||||
const company: ImportFields = { name };
|
||||
for (const field of ["location", "source"]) {
|
||||
const text = importAlias(source, field, importText);
|
||||
if (text !== undefined) company[field] = text;
|
||||
}
|
||||
return company;
|
||||
}
|
||||
|
||||
function prepareImport(text: string): PreparedImport[] {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { throw new InvalidImport(); }
|
||||
if (!Array.isArray(parsed)) throw new InvalidImport();
|
||||
return parsed.map((value, index) => {
|
||||
try {
|
||||
const source = importRecord(value);
|
||||
const company = importAlias(source, "company", importCompany);
|
||||
const jobTitle = importAlias(source, "jobTitle", importRequiredText);
|
||||
if (!company || jobTitle === undefined) throw new InvalidImport();
|
||||
const job: ImportFields = { jobTitle };
|
||||
for (const field of ["status", "location", "salary", "salaryCurrency", "salaryPeriod", "nextAction", "notes", "description", "translatedDescription", "descriptionLanguage", "tags", "coverLetterText", "jobUrl", "source", "countryCode"]) {
|
||||
const text = importAlias(source, field, importText);
|
||||
if (text !== undefined) job[field] = text;
|
||||
}
|
||||
for (const field of ["dateApplied", "followUpAt", "feedbackRequestedAt", "deadline"]) {
|
||||
const date = importAlias(source, field, importDate);
|
||||
if (date !== undefined) job[field] = date;
|
||||
}
|
||||
for (const field of ["salaryMin", "salaryMax"]) {
|
||||
const number = importAlias(source, field, importNumber);
|
||||
if (number !== undefined) job[field] = number;
|
||||
}
|
||||
return { company, job };
|
||||
} catch { throw new InvalidImport(index + 1); }
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(filename: string, content: BlobPart, mime: string) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -180,8 +280,25 @@ export default function ImportExportJobs() {
|
||||
const { t } = useI18n();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [lastImportCount, setLastImportCount] = useState<number | null>(null);
|
||||
const [importState, setImportState] = useState<ImportState | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const csvInputRef = useRef<HTMLInputElement>(null);
|
||||
const importBody = useRef<HTMLDivElement>(null);
|
||||
const importSession = useRef({ mounted: true, busy: false, generation: 0 });
|
||||
const currentTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { currentTranslation.current = t; }, [t]);
|
||||
useEffect(() => {
|
||||
const session = importSession.current;
|
||||
session.mounted = true;
|
||||
const invalidate = () => { session.generation += 1; };
|
||||
window.addEventListener(SESSION_INVALIDATED_EVENT, invalidate);
|
||||
return () => {
|
||||
session.mounted = false;
|
||||
invalidate();
|
||||
window.removeEventListener(SESSION_INVALIDATED_EVENT, invalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const exportAll = async (format: "json" | "csv") => {
|
||||
try {
|
||||
@@ -198,40 +315,61 @@ export default function ImportExportJobs() {
|
||||
};
|
||||
|
||||
const onImportFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const session = importSession.current;
|
||||
checkSessionCookie();
|
||||
if (!file || !session.mounted || session.busy || isSessionInvalidated()) return;
|
||||
session.busy = true;
|
||||
const context = getSessionContext();
|
||||
const generation = session.generation;
|
||||
const active = () => {
|
||||
checkSessionCookie();
|
||||
return session.mounted && generation === session.generation && !isSessionInvalidated() && getSessionContext() === context;
|
||||
};
|
||||
if (importBody.current?.contains(document.activeElement)) importBody.current.focus();
|
||||
setImporting(true);
|
||||
setLastImportCount(null);
|
||||
setImportState({ kind: "checking" });
|
||||
let total = 0;
|
||||
let created = 0;
|
||||
let row = 1;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as ImportJob[];
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid format");
|
||||
|
||||
let created = 0;
|
||||
for (const j of parsed) {
|
||||
if (!active()) return;
|
||||
const jobs = prepareImport(text);
|
||||
total = jobs.length;
|
||||
setImportState({ kind: "running", count: created, total });
|
||||
for (const j of jobs) {
|
||||
if (!active()) return;
|
||||
row = created + 1;
|
||||
const companyRes = await api.post<Company>("/companies", j.company);
|
||||
if (!active()) return;
|
||||
const company = companyRes.data;
|
||||
await api.post("/jobapplications", {
|
||||
jobTitle: j.jobTitle,
|
||||
companyId: company.id,
|
||||
status: j.status,
|
||||
dateApplied: j.dateApplied,
|
||||
location: j.location,
|
||||
salary: j.salary,
|
||||
nextAction: j.nextAction,
|
||||
followUpAt: j.followUpAt,
|
||||
jobUrl: j.jobUrl,
|
||||
notes: j.notes,
|
||||
coverLetterText: j.coverLetterText,
|
||||
});
|
||||
if (!Number.isInteger(company?.id) || company.id <= 0) throw new Error("Invalid company response");
|
||||
// Only the normalized create allow-list reaches this request; exported identities and history never do.
|
||||
await api.post("/jobapplications", { ...j.job, companyId: company.id });
|
||||
if (!active()) return;
|
||||
created += 1;
|
||||
setImportState({ kind: "running", count: created, total });
|
||||
}
|
||||
|
||||
setLastImportCount(created);
|
||||
toast(t("importedJobs", { count: created }), "success");
|
||||
} catch {
|
||||
toast(t("importFailedJson"), "error");
|
||||
setImportState(null);
|
||||
toast(currentTranslation.current("importedJobs", { count: created }), "success");
|
||||
} catch (error) {
|
||||
if (!active()) return;
|
||||
if (error instanceof InvalidImport) setImportState({ kind: "invalid", row: error.row });
|
||||
else if (total === 0) setImportState({ kind: "invalid" });
|
||||
else {
|
||||
setLastImportCount(created);
|
||||
setImportState({ kind: "stopped", row, count: created, total });
|
||||
}
|
||||
toast(currentTranslation.current(total > 0 ? "jobImportStopped" : "importFailedJson", { row, count: created, total }), "error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (active()) {
|
||||
session.busy = false;
|
||||
setImporting(false);
|
||||
if (importInputRef.current) importInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,7 +390,8 @@ export default function ImportExportJobs() {
|
||||
|
||||
return (
|
||||
<PageSection level="h3" title={t("importExportTitle")} description={helper}>
|
||||
|
||||
<Box ref={importBody} role="group" aria-label={t("importExportTitle")} aria-busy={importing} tabIndex={-1} sx={{ display: "grid", gap: 1.5, minWidth: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">{t("jobImportScope")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
|
||||
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
|
||||
@@ -267,8 +406,19 @@ export default function ImportExportJobs() {
|
||||
</Button>
|
||||
<input ref={csvInputRef} type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
|
||||
|
||||
{lastImportCount !== null && (
|
||||
<Typography sx={{ ml: 1, color: "text.secondary" }}>
|
||||
</Box>
|
||||
{importState?.kind === "checking" || importState?.kind === "running" ? <Typography role="status" variant="body2" color="text.secondary">
|
||||
{importState.kind === "checking" ? t("jobImportChecking") : t("jobImportProgress", { count: importState.count, total: importState.total })}
|
||||
</Typography> : null}
|
||||
{importState?.kind === "invalid" ? <Alert severity="error">
|
||||
{importState.row === undefined ? t("jobImportInvalidFile") : t("jobImportInvalidRow", { row: importState.row })}
|
||||
</Alert> : null}
|
||||
{importState?.kind === "stopped" ? <Alert severity="error">
|
||||
{t("jobImportStopped", { row: importState.row, count: importState.count, total: importState.total })}
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{t("jobImportInspectBeforeRetry")}</Typography>
|
||||
</Alert> : null}
|
||||
{lastImportCount !== null && (
|
||||
<Typography role="status" variant="body2" color="text.secondary">
|
||||
{t("lastImport", { count: lastImportCount })}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -40,9 +40,11 @@ interface Props {
|
||||
onThemeModeChange: (v: ThemeModePref) => void;
|
||||
}
|
||||
|
||||
function TabPanel({ value, index, label, children }: { value: number; index: number; label: string; children: React.ReactNode }) {
|
||||
if (value !== index) return null;
|
||||
return <Box role="tabpanel" id={`settings-panel-${index}`} aria-labelledby={`settings-tab-${index}`} aria-label={label} sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
function TabPanel({ value, index, label, children, keepMounted = false }: { value: number; index: number; label: string; children: React.ReactNode; keepMounted?: boolean }) {
|
||||
const [hasBeenActive, setHasBeenActive] = useState(value === index);
|
||||
useEffect(() => { if (value === index) setHasBeenActive(true); }, [value, index]);
|
||||
if (value !== index && (!keepMounted || !hasBeenActive)) return null;
|
||||
return <Box role="tabpanel" hidden={value !== index} id={`settings-panel-${index}`} aria-labelledby={`settings-tab-${index}`} aria-label={label} sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -247,7 +249,7 @@ export default function SettingsView({
|
||||
|
||||
{rulesDirty ? <Typography role="status" variant="body2" sx={{ mt: 1, color: "text.secondary" }}>{t("assetsUnsavedChanges")}. {t("rulesDraftScope")}</Typography> : null}
|
||||
|
||||
<TabPanel value={tab} index={0} label={t("settingsTabGeneral")}>
|
||||
<TabPanel value={tab} index={0} label={t("settingsTabGeneral")} keepMounted>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: "var(--app-section-gap)" }}>
|
||||
<PageSection surface title={t("settingsAppearance")}>
|
||||
|
||||
@@ -2297,6 +2297,13 @@ export const translations = {
|
||||
errorBoundaryRefresh: "Refresh",
|
||||
importExportTitle: "Import / Export",
|
||||
importExportBody: "Import expects the JSON exported by this app (an array of job objects with embedded company).",
|
||||
jobImportScope: "Imports create new jobs from supported fields. IDs, owners, history, attachments and CV associations are not restored. Keep Settings open until the import finishes. If you leave, the import may be incomplete.",
|
||||
jobImportChecking: "Checking import file…",
|
||||
jobImportProgress: "{count} of {total} jobs confirmed imported.",
|
||||
jobImportInvalidFile: "Choose a JSON array of jobs. No jobs or companies were written.",
|
||||
jobImportInvalidRow: "Row {row} has missing, invalid or conflicting fields. No jobs or companies were written.",
|
||||
jobImportStopped: "Import stopped at row {row}. {count} of {total} jobs are confirmed imported.",
|
||||
jobImportInspectBeforeRetry: "This row may have been partly saved. Check your job list and company list before importing again; repeating the file can create duplicates.",
|
||||
exportJson: "Export JSON",
|
||||
exportCsv: "Export CSV",
|
||||
importJson: "Import JSON",
|
||||
@@ -5060,6 +5067,13 @@ export const translations = {
|
||||
errorBoundaryRefresh: "Oppdater",
|
||||
importExportTitle: "Import / eksport",
|
||||
importExportBody: "Import forventer JSON eksportert av denne appen (en matrise med jobbobjekter med innebygd selskap).",
|
||||
jobImportScope: "Import oppretter nye jobber fra støttede felt. ID-er, eiere, historikk, vedlegg og CV-koblinger gjenopprettes ikke. Hold Innstillinger åpent til importen er ferdig. Hvis du går ut, kan importen bli ufullstendig.",
|
||||
jobImportChecking: "Kontrollerer importfil…",
|
||||
jobImportProgress: "{count} av {total} jobber er bekreftet importert.",
|
||||
jobImportInvalidFile: "Velg en JSON-liste med jobber. Ingen jobber eller selskaper ble lagret.",
|
||||
jobImportInvalidRow: "Rad {row} har manglende, ugyldige eller motstridende felt. Ingen jobber eller selskaper ble lagret.",
|
||||
jobImportStopped: "Importen stoppet på rad {row}. {count} av {total} jobber er bekreftet importert.",
|
||||
jobImportInspectBeforeRetry: "Denne raden kan være delvis lagret. Kontroller jobblisten og selskapslisten før du importerer på nytt; gjentatt import kan opprette duplikater.",
|
||||
exportJson: "Eksporter JSON",
|
||||
exportCsv: "Eksporter CSV",
|
||||
importJson: "Importer JSON",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState } from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import ImportExportJobs from "./components/ImportExportJobs";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { api } from "./api";
|
||||
import { getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBoundary";
|
||||
|
||||
jest.mock("./sessionBoundary", () => ({
|
||||
SESSION_INVALIDATED_EVENT: "session-invalidated",
|
||||
checkSessionCookie: jest.fn(),
|
||||
getSessionContext: jest.fn(() => "owner-a-session"),
|
||||
isSessionInvalidated: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const currentContext = getSessionContext as jest.MockedFunction<typeof getSessionContext>;
|
||||
const invalidated = isSessionInvalidated as jest.MockedFunction<typeof isSessionInvalidated>;
|
||||
const tr = (key: string, language: "en" | "nb" = "en", values: Record<string, string | number> = {}) =>
|
||||
Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), (translations[language] as Record<string, string>)[key]);
|
||||
const row = (jobTitle = "Supported role") => ({ jobTitle, company: { name: "Ærlig AS", location: "Oslo", source: "Manual" } });
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function selectedFile(value: unknown, text?: () => Promise<string>) {
|
||||
const file = new File([JSON.stringify(value)], "jobs.json", { type: "application/json" });
|
||||
Object.defineProperty(file, "text", { value: text ?? jest.fn().mockResolvedValue(JSON.stringify(value)) });
|
||||
return file;
|
||||
}
|
||||
|
||||
function view() {
|
||||
function Harness() {
|
||||
const [shown, setShown] = useState(true);
|
||||
return <><button onClick={() => setShown(value => !value)}>Toggle import panel</button>{shown ? <ImportExportJobs /> : null}</>;
|
||||
}
|
||||
return render(<ToastProvider><I18nProvider><Harness /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
function input() { return document.querySelector<HTMLInputElement>('input[accept="application/json"]')!; }
|
||||
async function select(file: File) { await act(async () => { fireEvent.change(input(), { target: { files: [file] } }); }); }
|
||||
function importGroup(language: "en" | "nb" = "en") { return screen.getByRole("group", { name: tr("importExportTitle", language) }); }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); mockedApi.post.mockReset(); mockedApi.get.mockReset();
|
||||
window.localStorage.clear(); currentContext.mockReturnValue("owner-a-session"); invalidated.mockReturnValue(false);
|
||||
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ status: url === "/companies" ? 200 : 201, data: { id: url === "/companies" ? 31 : 44 } }));
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it("[P7-09 import] app PascalCase export recreates every supported create field without owner, history or attachment state", async () => {
|
||||
view();
|
||||
await select(selectedFile([{
|
||||
Id: 99, OwnerUserId: "other-owner", CompanyId: 80, SavedAt: "2020-01-01T00:00:00Z",
|
||||
Company: { Id: 80, OwnerUserId: "other-owner", Name: "Ærlig AS", Location: "Oslo", Source: "Manual", RecruiterEmail: "private@example.test", Jobs: [] },
|
||||
JobTitle: "Exported role", Status: "Custom recorded stage", Location: "Remote", Salary: " negotiable ",
|
||||
SalaryMin: 0, SalaryMax: 850000.5, SalaryCurrency: " nok ", SalaryPeriod: "legacy-period",
|
||||
NextAction: "Contact recruiter", FollowUpAt: "2026-10-20T09:15:00.1234567Z", Notes: "Preserve\nnotes", Description: "Original description",
|
||||
TranslatedDescription: "Oversatt tekst", DescriptionLanguage: "nb", Tags: '["TypeScript"]', Deadline: "2026-11-01",
|
||||
CoverLetterText: "Existing cover letter", JobUrl: "https://example.test/jobs/4", DateApplied: "2026-10-01T12:30:00+02:00",
|
||||
FeedbackRequestedAt: "2026-10-02T09:00:00", Source: "manual", CountryCode: "NO",
|
||||
ResponseReceived: true, ResponseDate: "2026-10-03", HasResume: true, HasCoverLetter: true, IsDeleted: true, DeletedAt: "2026-10-04",
|
||||
Messages: [{ Id: 1 }], Events: [{ Type: "Submitted" }], Attachments: [{ Id: 2 }], TailoredCvDraft: { Id: 3 }, Job: { Id: 4 },
|
||||
}]));
|
||||
expect(mockedApi.post.mock.calls).toEqual([
|
||||
["/companies", { name: "Ærlig AS", location: "Oslo", source: "Manual" }],
|
||||
["/jobapplications", {
|
||||
jobTitle: "Exported role", companyId: 31, status: "Custom recorded stage", location: "Remote", salary: " negotiable ",
|
||||
salaryMin: 0, salaryMax: 850000.5, salaryCurrency: " nok ", salaryPeriod: "legacy-period", nextAction: "Contact recruiter",
|
||||
followUpAt: "2026-10-20T09:15:00.1234567Z", notes: "Preserve\nnotes", description: "Original description", translatedDescription: "Oversatt tekst",
|
||||
descriptionLanguage: "nb", tags: '["TypeScript"]', deadline: "2026-11-01", coverLetterText: "Existing cover letter",
|
||||
jobUrl: "https://example.test/jobs/4", dateApplied: "2026-10-01T12:30:00+02:00", feedbackRequestedAt: "2026-10-02T09:00:00", source: "manual", countryCode: "NO",
|
||||
}],
|
||||
]);
|
||||
expect(await within(importGroup()).findByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
expect(within(importGroup()).getByText(tr("jobImportScope"))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] camelCase nulls and equivalent known aliases remain explicit while server normalization retains authority", async () => {
|
||||
view();
|
||||
await select(selectedFile([{
|
||||
...row("Camel role"), JobTitle: "Camel role", Company: { Name: "Ærlig AS", Location: "Oslo", Source: "Manual", Id: 777 },
|
||||
status: "unknown-custom-stage", dateApplied: null, DateApplied: null, salary: null, salaryMin: -1, salaryMax: 0,
|
||||
salaryCurrency: null, salaryPeriod: "fortnight", notes: "", coverLetterText: null, followUpAt: null,
|
||||
}]));
|
||||
expect(mockedApi.post.mock.calls).toEqual([
|
||||
["/companies", { name: "Ærlig AS", location: "Oslo", source: "Manual" }],
|
||||
["/jobapplications", { jobTitle: "Camel role", companyId: 31, status: "unknown-custom-stage", dateApplied: null, salary: null,
|
||||
salaryMin: -1, salaryMax: 0, salaryCurrency: null, salaryPeriod: "fortnight", notes: "", coverLetterText: null, followUpAt: null }],
|
||||
]);
|
||||
expect(await within(importGroup()).findByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] validates the complete array before writing any earlier valid row, including conflicting null aliases", async () => {
|
||||
view();
|
||||
const invalidRows = [null, [], { ...row(), jobTitle: " " }, { ...row(), company: "Company name" }, { ...row(), company: { name: null } },
|
||||
{ ...row(), salaryMin: "800000" }, { ...row(), notes: { text: "invalid" } }, { ...row(), dateApplied: "2026-02-30" },
|
||||
{ ...row(), followUpAt: "not-a-date" }, { ...row(), dateApplied: null, DateApplied: "2026-10-01" },
|
||||
{ ...row(), Company: { Name: "Different company" } }];
|
||||
for (const bad of invalidRows) {
|
||||
await select(selectedFile([row("Earlier valid row"), bad]));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidRow", "en", { row: 2 }));
|
||||
expect(screen.getByRole("button", { name: tr("importJson") })).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("[P7-09 import] partial failure reports only confirmed jobs and the stopped row without automatic replay, including after locale changes", async () => {
|
||||
let jobWrites = 0;
|
||||
mockedApi.post.mockImplementation((url: string) => url === "/companies" ? Promise.resolve({ status: 200, data: { id: 31 } })
|
||||
: ++jobWrites === 2 ? Promise.reject({ response: { status: 503, data: {} } }) : Promise.resolve({ status: 201, data: { id: 44 } }));
|
||||
view(); await select(selectedFile([row("First role"), row("Unconfirmed second role"), row("Not started role")]));
|
||||
expect(mockedApi.post.mock.calls.map(([url]) => url)).toEqual(["/companies", "/jobapplications", "/companies", "/jobapplications"]);
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportStopped", "en", { row: 2, count: 1, total: 3 }));
|
||||
expect(screen.queryByText(tr("importFailedJson"))).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("alert").filter(alert => alert.textContent?.includes(tr("jobImportStopped", "en", { row: 2, count: 1, total: 3 })))).toHaveLength(2);
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInspectBeforeRetry"));
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
expect(within(importGroup()).queryByRole("button", { name: tr("retry") })).not.toBeInTheDocument();
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
expect(within(importGroup("nb")).getByRole("alert")).toHaveTextContent(tr("jobImportStopped", "nb", { row: 2, count: 1, total: 3 }));
|
||||
expect(within(importGroup("nb")).getByRole("alert")).toHaveTextContent(tr("jobImportInspectBeforeRetry", "nb"));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(4); expect(mockedApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("[P7-09 import] synchronously reserves one batch during file parsing and reports current-locale confirmed progress", async () => {
|
||||
const text = deferred<string>(); const saved = deferred<any>(); const firstRead = jest.fn(() => text.promise); const secondRead = jest.fn().mockResolvedValue(JSON.stringify([row("Duplicate role")]));
|
||||
mockedApi.post.mockImplementation((url: string) => url === "/companies" ? Promise.resolve({ data: { id: 31 } }) : saved.promise);
|
||||
view();
|
||||
const first = selectedFile([], firstRead); const second = selectedFile([], secondRead);
|
||||
act(() => { fireEvent.change(input(), { target: { files: [first] } }); fireEvent.change(input(), { target: { files: [second] } }); });
|
||||
expect(firstRead).toHaveBeenCalledTimes(1); expect(secondRead).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: tr("profileUploading") })).toBeDisabled();
|
||||
expect(importGroup()).toHaveAttribute("aria-busy", "true");
|
||||
await act(async () => text.resolve(JSON.stringify([row("Held role")])));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(within(importGroup()).getByRole("status")).toHaveTextContent(tr("jobImportProgress", "en", { count: 0, total: 1 }));
|
||||
await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
expect(within(importGroup("nb")).getByRole("status")).toHaveTextContent(tr("jobImportProgress", "nb", { count: 0, total: 1 }));
|
||||
await act(async () => saved.resolve({ status: 201, data: { id: 44 } }));
|
||||
expect(within(importGroup("nb")).getByText(tr("lastImport", "nb", { count: 1 }))).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: tr("importJson", "nb") })).toBeEnabled();
|
||||
expect(importGroup("nb")).toHaveAttribute("aria-busy", "false");
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("[P7-09 import] unmount during file reading stops the batch and a fresh panel owns its own completion", async () => {
|
||||
const held = deferred<string>(); view(); await select(selectedFile([], () => held.promise));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle import panel" }));
|
||||
await act(async () => held.resolve(JSON.stringify([row("Disposed role")])));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(screen.queryByText(tr("importedJobs", "en", { count: 1 }))).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle import panel" }));
|
||||
await select(selectedFile([row("Current role")]));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.post.mock.calls[1][1]).toEqual({ jobTitle: "Current role", companyId: 31 });
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] an account boundary after a held company request cannot create a job using the next account session", async () => {
|
||||
const company = deferred<any>(); mockedApi.post.mockReturnValue(company.promise);
|
||||
view(); await select(selectedFile([row("Old account role"), row("Never started role")]));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
currentContext.mockReturnValue("owner-b-session"); invalidated.mockReturnValue(true);
|
||||
act(() => window.dispatchEvent(new Event(SESSION_INVALIDATED_EVENT)));
|
||||
await act(async () => company.resolve({ status: 201, data: { id: 31 } }));
|
||||
expect(mockedApi.post.mock.calls.map(([url]) => url)).toEqual(["/companies"]);
|
||||
expect(screen.queryByText(tr("importedJobs", "en", { count: 2 }))).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(tr("lastImport", "en", { count: 2 }))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("[P7-09 import] malformed or non-array JSON fails before writes and leaves file selection available", async () => {
|
||||
view();
|
||||
for (const content of ["{broken", '{"jobs":[]}', "null"]) {
|
||||
await select(selectedFile([], () => Promise.resolve(content)));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidFile"));
|
||||
expect(screen.getByRole("button", { name: tr("importJson") })).toBeEnabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("[P7-09 import] salaries outside the decimal create contract reject the whole array without rejecting supported numeric boundaries", async () => {
|
||||
view();
|
||||
// These serialized Number values were rejected by the installed System.Text.Json9 decimal converter.
|
||||
for (const salary of [1e100, -1e100, 2 ** 96, -(2 ** 96)]) {
|
||||
await select(selectedFile([row("Earlier valid role"), { ...row("Overflowing salary"), salaryMin: salary }]));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidRow", "en", { row: 2 }));
|
||||
}
|
||||
await select(selectedFile([{ ...row("Supported decimal bounds"), salaryMin: -1, salaryMax: 7.922816251426433e28 }]));
|
||||
expect(mockedApi.post.mock.calls[1][1]).toEqual({ jobTitle: "Supported decimal bounds", companyId: 31, salaryMin: -1, salaryMax: 7.922816251426433e28 });
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 1 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] dates rejected by the DateTime converter cannot leave an earlier row imported and supported dates stay unchanged", async () => {
|
||||
view();
|
||||
const rejected = ["2026-10-17T09:15:00+15:00", "2026-10-17T09:15:00-14:01", "2026-10-17T09:15:00.12345678901234567Z",
|
||||
"0001-01-01T00:00:00+14:00", "9999-12-31T23:59:59-14:00"];
|
||||
for (const date of rejected) {
|
||||
await select(selectedFile([row("Earlier valid role"), { ...row("Unsupported date"), dateApplied: date }]));
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(within(importGroup()).getByRole("alert")).toHaveTextContent(tr("jobImportInvalidRow", "en", { row: 2 }));
|
||||
}
|
||||
await select(selectedFile([{ ...row("Supported DateTime boundaries"), dateApplied: "0001-01-01T00:00:00Z", followUpAt: "9999-12-31T23:59:59Z",
|
||||
deadline: "2026-10-17T09:15+14:00", feedbackRequestedAt: "2026-10-17T09:15:00.1234567890123456Z" },
|
||||
{ ...row("Unspecified DateTime boundaries"), dateApplied: "0001-01-01T00:00", followUpAt: "9999-12-31T23:59" }]));
|
||||
expect(mockedApi.post.mock.calls[1][1]).toEqual({ jobTitle: "Supported DateTime boundaries", companyId: 31, dateApplied: "0001-01-01T00:00:00Z",
|
||||
followUpAt: "9999-12-31T23:59:59Z", deadline: "2026-10-17T09:15+14:00", feedbackRequestedAt: "2026-10-17T09:15:00.1234567890123456Z" });
|
||||
expect(mockedApi.post.mock.calls[3][1]).toEqual({ jobTitle: "Unspecified DateTime boundaries", companyId: 31,
|
||||
dateApplied: "0001-01-01T00:00", followUpAt: "9999-12-31T23:59" });
|
||||
expect(within(importGroup()).getByText(tr("lastImport", "en", { count: 2 }))).toBeVisible();
|
||||
});
|
||||
|
||||
it("[P7-09 import] CSV conversion keeps quoted cells, legacy mapping and generated JSON without server writes", async () => {
|
||||
const created = jest.fn<string, [Blob]>(() => "blob:converted-jobs");
|
||||
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: created });
|
||||
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: jest.fn() });
|
||||
let filename = ""; jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { filename = this.download; });
|
||||
view();
|
||||
const csv = new File([], "jobs.csv", { type: "text/csv" });
|
||||
Object.defineProperty(csv, "text", { value: () => Promise.resolve('Job Title,Company,Location,Source,Date Applied,Status,Notes\n"Senior, engineer",Ærlig AS,Oslo,Manual,3/2/2025,Follow-up Needed,"Line one\nLine two"') });
|
||||
await act(async () => fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [csv] } }));
|
||||
await waitFor(() => expect(created).toHaveBeenCalledTimes(1));
|
||||
const text = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsText(created.mock.calls[0][0]); });
|
||||
expect(JSON.parse(text)).toEqual([{ jobTitle: "Senior, engineer", status: "Waiting", dateApplied: "2025-02-03", responseReceived: false,
|
||||
notes: "Line one\nLine two", daysSince: 0, company: { name: "Ærlig AS", location: "Oslo", source: "Manual" }, location: "Oslo", nextAction: "Follow up", isDeleted: false }]);
|
||||
expect(filename).toMatch(/^job-tracker-import-\d{4}-\d{2}-\d{2}\.json$/);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user