fix(settings): bind job exports and CSV conversion to their session

This commit is contained in:
cesnimda
2026-11-11 15:02:06 +01:00
parent 214eb25fa5
commit 2a02e1023e
3 changed files with 224 additions and 12 deletions
@@ -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();
}
@@ -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");
}
};
@@ -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<typeof api>;
const context = getSessionContext as jest.MockedFunction<typeof getSessionContext>;
const invalidated = isSessionInvalidated as jest.MockedFunction<typeof isSessionInvalidated>;
function deferred() {
let resolve!: (value: any) => void; let reject!: (error: unknown) => void;
const promise = new Promise<any>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
function view() { return render(<ToastProvider><I18nProvider><ImportExportJobs /></I18nProvider></ToastProvider>); }
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();
});