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; const currentContext = getSessionContext as jest.MockedFunction; const invalidated = isSessionInvalidated as jest.MockedFunction; const tr = (key: string, language: "en" | "nb" = "en", values: Record = {}) => Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), (translations[language] as Record)[key]); const row = (jobTitle = "Supported role") => ({ jobTitle, company: { name: "Ærlig AS", location: "Oslo", source: "Manual" } }); function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function selectedFile(value: unknown, text?: () => Promise) { 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 <>{shown ? : null}; } return render(); } function input() { return document.querySelector('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(); const saved = deferred(); 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(); 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(); 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(() => "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((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(); });