From 2a02e1023e0869d32af23e31d51d5ac895d3e8cd Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 11 Nov 2026 15:02:06 +0100 Subject: [PATCH] fix(settings): bind job exports and CSV conversion to their session --- .../scripts/verify-job-export-lifetime.mjs | 105 ++++++++++++++++++ .../src/components/ImportExportJobs.tsx | 34 ++++-- .../src/job-export-lifetime.test.tsx | 97 ++++++++++++++++ 3 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 job-tracker-ui/scripts/verify-job-export-lifetime.mjs create mode 100644 job-tracker-ui/src/job-export-lifetime.test.tsx diff --git a/job-tracker-ui/scripts/verify-job-export-lifetime.mjs b/job-tracker-ui/scripts/verify-job-export-lifetime.mjs new file mode 100644 index 00000000..8bd83839 --- /dev/null +++ b/job-tracker-ui/scripts/verify-job-export-lifetime.mjs @@ -0,0 +1,105 @@ +// Real UI/blob downloads; all API traffic and input files are synthetic. +import { chromium, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; +import ts from 'typescript'; +const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; +const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); +const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/job-export-lifetime'); +fs.mkdirSync(out, { recursive: true }); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'jobjakt-export-')); +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const json = '[{"id":42,"jobTitle":"Synthetic lærer","company":{"name":"Synthetic skole"},"dateApplied":null}]'; +const csv = '\ufeffJob Title,Company\r\nSynthetic lærer,Synthetic skole\r\n'; +const conversion = 'Job Title,Company\nSynthetic lærer,Synthetic skole'; +const report = { journeys: [], downloads: [], captures: [], errors: [], writes: [], unavailableReads: [], blockedExternal: [] }; +const configurations = process.env.A3_PILOT ? [[390, 'nb', 'dark']] : [390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(theme => [width, language, theme]))); +const browser = await chromium.launch(); +try { + for (const [width, language, theme] of configurations) { + const name = `${width}-${language}-${theme}`, copy = translations[language]; + console.log(`Checking ${name}`); + const context = await browser.newContext({ viewport: { width, height: 1000 }, hasTouch: width === 390, isMobile: width === 390, acceptDownloads: true, reducedMotion: 'reduce' }); + const page = await context.newPage(); let downloads = 0; + page.on('download', () => { downloads += 1; }); + page.on('pageerror', error => report.errors.push({ name, message: error.message })); + await page.addInitScript(({ language, theme, conversion }) => { + localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); + const original = File.prototype.text; + File.prototype.text = function () { + if (this.name !== 'held.csv') return original.call(this); + return new Promise(resolve => { window.__releaseSyntheticCsv = () => resolve(conversion); }); + }; + }, { language, theme, conversion }); + let holdExport = false, releaseExport; + const heldExport = new Promise(resolve => { releaseExport = resolve; }); + await page.route('**/*', async route => { + const request = route.request(), url = new URL(request.url()), p = url.pathname; + if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && p.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); } + if (!p.startsWith('/api/')) return route.continue(); + if (request.method() !== 'GET') { report.writes.push({ name, p, method: request.method() }); return route.abort(); } + if (p === '/api/export/jobs') { + assert.equal(url.searchParams.get('includeDeleted'), 'false'); + if (holdExport) await heldExport; + const format = url.searchParams.get('format'); assert.ok(format === 'json' || format === 'csv'); + return route.fulfill({ contentType: format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json', body: format === 'csv' ? csv : json }); + } + const fixtures = { + '/api/auth/config': { requireAuth: true }, + '/api/auth/me': { id: 'a3-export-owner', sessionContext: 'a3-export-session', firstName: 'Alex', displayName: 'Alex Sample', roles: [], plan: 'pro', entitlements: { ai: false }, uiLanguage: language === 'nb' ? 'nb-NO' : 'en' }, + '/api/companies': [], '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 }, + '/api/notification-settings': { emailFollowUpRemindersEnabled: false }, + '/api/ai/settings': { enabled: false, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local' }, + '/api/ai/usage': { currentMonth: { calls: 0, estimatedTokens: 0 }, plan: 'pro', monthlyCallLimit: 100, monthlyTokenLimit: 10000, storageUsedBytes: 0, storageLimitBytes: 250000000 }, + '/api/billing/status': { enabled: false, canCheckout: false, canManage: false }, + }; + if (Object.hasOwn(fixtures, p)) return route.fulfill({ json: fixtures[p] }); + report.unavailableReads.push(p); return route.fulfill({ status: 404, json: {} }); + }); + const group = page.getByRole('group', { name: copy.importExportTitle, exact: true }); + const saveDownload = async (kind, trigger) => { + const event = page.waitForEvent('download'); await trigger(); const download = await event; + assert.match(download.suggestedFilename(), kind === 'converted' ? /^job-tracker-import-.*\.json$/ : new RegExp(`^job-tracker-export-.*\.${kind}$`)); + const target = path.join(temporary, `${name}-${kind}`); await download.saveAs(target); + const bytes = fs.readFileSync(target); + if (kind === 'json') assert.deepEqual(bytes, Buffer.from(json)); + else if (kind === 'csv') assert.deepEqual(bytes, Buffer.from(csv)); + else { const rows = JSON.parse(bytes.toString('utf8')); assert.equal(rows.length, 1); assert.equal(rows[0].jobTitle, 'Synthetic lærer'); assert.equal(rows[0].company.name, 'Synthetic skole'); } + const compressed = gzipSync(bytes), filename = `${name}-${kind}.gz`; fs.writeFileSync(path.join(out, filename), compressed); + report.downloads.push({ name, kind, filename, bytes: bytes.length, rawSha256: sha(bytes), gzipSha256: sha(compressed) }); + }; + const spaNavigate = async destination => { + await page.evaluate(value => { history.pushState({}, '', value); window.dispatchEvent(new PopStateEvent('popstate')); }, destination); + }; + await page.goto('http://localhost:4338/settings'); await expect(group).toBeVisible(); + await saveDownload('json', () => group.getByRole('button', { name: copy.exportJson, exact: true }).click()); + await saveDownload('csv', () => group.getByRole('button', { name: copy.exportCsv, exact: true }).click()); + await saveDownload('converted', () => page.locator('input[accept=".csv,text/csv"]').setInputFiles({ name: 'normal.csv', mimeType: 'text/csv', buffer: Buffer.from(conversion) })); + await group.scrollIntoViewIfNeeded(); + const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze(); + report.captures.push({ name, violations: scan.violations }); assert.deepEqual(scan.violations, []); + await page.screenshot({ path: path.join(out, `${name}-exports.png`) }); + holdExport = true; await group.getByRole('button', { name: copy.exportJson, exact: true }).click(); + await spaNavigate('/export-lifetime-fixture-unmount'); await expect(group).toHaveCount(0); + const response = page.waitForResponse(value => new URL(value.url()).pathname === '/api/export/jobs'); releaseExport(); await (await response).finished(); + await page.waitForTimeout(250); assert.equal(downloads, 3, 'No stale export download after SPA unmount'); + await spaNavigate('/settings'); await expect(group).toBeVisible(); + await page.locator('input[accept=".csv,text/csv"]').setInputFiles({ name: 'held.csv', mimeType: 'text/csv', buffer: Buffer.from(conversion) }); + await expect.poll(() => page.evaluate(() => typeof window.__releaseSyntheticCsv)).toBe('function'); + await spaNavigate('/export-lifetime-fixture-unmount'); await expect(group).toHaveCount(0); + await page.evaluate(() => window.__releaseSyntheticCsv()); await page.waitForTimeout(250); + assert.equal(downloads, 3, 'No stale conversion download after SPA unmount'); + report.journeys.push({ name, downloads, jsonAndCsvBytes: 'PASS', convertedUnicode: 'PASS', unmountProbes: 2, quietWindowMs: 250 }); + await context.close(); + } + assert.equal(report.journeys.length, configurations.length); assert.deepEqual(report.errors, []); assert.deepEqual(report.writes, []); assert.deepEqual(report.unavailableReads, []); + console.log(`PASS ${report.journeys.length} configurations, ${report.downloads.length} native downloads, ${report.captures.length} accessibility scans; no API writes`); +} finally { + fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify(report, null, 2)); + await browser.close(); +} diff --git a/job-tracker-ui/src/components/ImportExportJobs.tsx b/job-tracker-ui/src/components/ImportExportJobs.tsx index b088aadc..0beff18a 100644 --- a/job-tracker-ui/src/components/ImportExportJobs.tsx +++ b/job-tracker-ui/src/components/ImportExportJobs.tsx @@ -300,31 +300,39 @@ export default function ImportExportJobs() { }; }, []); + const captureSession = () => { + checkSessionCookie(); + const session = importSession.current; + if (!session.mounted || isSessionInvalidated()) return null; + const context = getSessionContext(); + const generation = session.generation; + return () => { + checkSessionCookie(); + return session.mounted && generation === session.generation && !isSessionInvalidated() && getSessionContext() === context; + }; + }; + const exportAll = async (format: "json" | "csv") => { + const active = captureSession(); if (!active) return; try { const stamp = new Date().toISOString().slice(0, 10); const url = `/export/jobs?format=${format}&includeDeleted=false`; const res = await api.get(url, { responseType: "blob" }); + if (!active()) return; // Keep the CSV's UTF-8 BOM: decoding through Blob.text() would discard it. downloadFile(`job-tracker-export-${stamp}.${format}`, res.data, format === "json" ? "application/json" : "text/csv;charset=utf-8"); - toast(t("exportedJobs", { format: format.toUpperCase() }), "success"); + toast(currentTranslation.current("exportedJobs", { format: format.toUpperCase() }), "success"); } catch { - toast(t("exportFailed"), "error"); + if (active()) toast(currentTranslation.current("exportFailed"), "error"); } }; const onImportFile = async (file: File | null) => { const session = importSession.current; - checkSessionCookie(); - if (!file || !session.mounted || session.busy || isSessionInvalidated()) return; + const active = captureSession(); + if (!file || !active || session.busy) 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); @@ -375,14 +383,16 @@ export default function ImportExportJobs() { const onConvertCsv = async (file: File | null) => { if (!file) return; + const active = captureSession(); if (!active) return; try { const text = await file.text(); + if (!active()) return; const jobs = csvToImportJobs(text); const stamp = new Date().toISOString().slice(0, 10); downloadFile(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json"); - toast(t("convertedRows", { count: jobs.length }), "success"); + toast(currentTranslation.current("convertedRows", { count: jobs.length }), "success"); } catch { - toast(t("csvConversionFailed"), "error"); + if (active()) toast(currentTranslation.current("csvConversionFailed"), "error"); } }; diff --git a/job-tracker-ui/src/job-export-lifetime.test.tsx b/job-tracker-ui/src/job-export-lifetime.test.tsx new file mode 100644 index 00000000..8e7b8b8b --- /dev/null +++ b/job-tracker-ui/src/job-export-lifetime.test.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import ImportExportJobs from "./components/ImportExportJobs"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { ToastProvider } from "./toast"; +import { translations } from "./i18n/translations"; +import { api } from "./api"; +import { getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBoundary"; + +jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn() } })); +jest.mock("./sessionBoundary", () => ({ SESSION_INVALIDATED_EVENT: "session-invalidated", checkSessionCookie: jest.fn(), getSessionContext: jest.fn(), isSessionInvalidated: jest.fn() })); +const client = api as jest.Mocked; +const context = getSessionContext as jest.MockedFunction; +const invalidated = isSessionInvalidated as jest.MockedFunction; +function deferred() { + let resolve!: (value: any) => void; let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +} +function view() { return render(); } +let download: jest.SpyInstance; +beforeEach(() => { + jest.resetAllMocks(); localStorage.clear(); context.mockReturnValue("owner-a-session"); invalidated.mockReturnValue(false); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: jest.fn(() => "blob:synthetic-download") }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: jest.fn() }); + download = jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); +}); +afterEach(() => jest.restoreAllMocks()); + +test.each(["json", "csv", "convert"] as const)("%s completes and downloads only for the initiating session", async action => { + const pending = deferred(); client.get.mockReturnValue(pending.promise); + view(); + if (action === "convert") { + const file = new File([], "jobs.csv", { type: "text/csv" }); Object.defineProperty(file, "text", { value: () => pending.promise }); + fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [file] } }); + await act(async () => pending.resolve("Job Title,Company\nTeacher,Synthetic school")); + } else { + fireEvent.click(screen.getByRole("button", { name: action === "json" ? translations.en.exportJson : translations.en.exportCsv })); + const blob = new Blob([action === "csv" ? "\ufeffJob Title,Company\r\nTeacher,Synthetic school" : "[]"]); + await act(async () => pending.resolve({ data: blob })); + expect(client.get).toHaveBeenCalledWith(`/export/jobs?format=${action}&includeDeleted=false`, { responseType: "blob" }); + expect(URL.createObjectURL).toHaveBeenCalledWith(blob); + } + expect(download).toHaveBeenCalledTimes(1); expect(client.post).not.toHaveBeenCalled(); +}); + +describe.each(["json", "convert"] as const)("%s lifetime", action => { + test.each(["unmount", "context", "event"] as const)("a pending completion after %s cannot download or announce success", async boundary => { + const pending = deferred(); client.get.mockReturnValue(pending.promise); + const mounted = view(); + if (action === "json") fireEvent.click(screen.getByRole("button", { name: translations.en.exportJson })); + else { + const file = new File([], "jobs.csv", { type: "text/csv" }); Object.defineProperty(file, "text", { value: () => pending.promise }); + fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [file] } }); + } + if (boundary === "unmount") mounted.unmount(); + else if (boundary === "context") context.mockReturnValue("owner-b-session"); + else act(() => window.dispatchEvent(new Event(SESSION_INVALIDATED_EVENT))); + await act(async () => pending.resolve(action === "json" ? { data: new Blob(["[]"]) } : "Job Title,Company\nTeacher,Synthetic school")); + expect(download).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled(); + expect(screen.queryByText(action === "json" ? translations.en.exportedJobs.replace("{format}", "JSON") : translations.en.convertedRows.replace("{count}", "1"))).not.toBeInTheDocument(); + expect(client.post).not.toHaveBeenCalled(); + }); + test("a stale rejection cannot display feedback in the new session", async () => { + const pending = deferred(); client.get.mockReturnValue(pending.promise); view(); + if (action === "json") fireEvent.click(screen.getByRole("button", { name: translations.en.exportJson })); + else { + const file = new File([], "jobs.csv", { type: "text/csv" }); Object.defineProperty(file, "text", { value: () => pending.promise }); + fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [file] } }); + } + context.mockReturnValue("owner-b-session"); + await act(async () => pending.reject(new Error("offline"))); + expect(screen.queryByText(action === "json" ? translations.en.exportFailed : translations.en.csvConversionFailed)).not.toBeInTheDocument(); + expect(download).not.toHaveBeenCalled(); + }); +}); + +test("an already invalidated session starts neither an export nor a file read", () => { + invalidated.mockReturnValue(true); view(); + fireEvent.click(screen.getByRole("button", { name: translations.en.exportJson })); + const read = jest.fn(); const file = new File([], "jobs.csv"); Object.defineProperty(file, "text", { value: read }); + fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [file] } }); + expect(client.get).not.toHaveBeenCalled(); expect(read).not.toHaveBeenCalled(); +}); + +test.each(["json", "convert"] as const)("%s delayed rejection uses the current UI language", async action => { + const pending = deferred(); client.get.mockReturnValue(pending.promise); view(); + if (action === "json") fireEvent.click(screen.getByRole("button", { name: translations.en.exportJson })); + else { + const file = new File([], "jobs.csv", { type: "text/csv" }); Object.defineProperty(file, "text", { value: () => pending.promise }); + fireEvent.change(document.querySelector('input[accept=".csv,text/csv"]')!, { target: { files: [file] } }); + } + await act(async () => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" }))); + await act(async () => pending.reject(new Error("offline"))); + expect(await screen.findByText(action === "json" ? translations.nb.exportFailed : translations.nb.csvConversionFailed)).toBeVisible(); +});