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

235 lines
17 KiB
TypeScript

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();
});