fix(settings): bind job exports and CSV conversion to their session
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user