fix(settings): preserve safe job import compatibility and progress
This commit is contained in:
@@ -1,7 +1,33 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type BrowserContext } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const apiUrl = "http://localhost:5302/api";
|
||||
const networkBoundaries = new WeakMap<BrowserContext, { abortedFontStylesheets: string[]; unexpectedExternalRequests: string[] }>();
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const boundary = { abortedFontStylesheets: [] as string[], unexpectedExternalRequests: [] as string[] };
|
||||
networkBoundaries.set(context, boundary);
|
||||
await context.route("**/*", route => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.protocol === "https:" && url.hostname === "fonts.googleapis.com" && url.port === "" && url.pathname === "/css2" && request.method() === "GET" && request.resourceType() === "stylesheet") {
|
||||
boundary.abortedFontStylesheets.push(url.href);
|
||||
return route.abort();
|
||||
}
|
||||
if (["http:", "https:"].includes(url.protocol) && (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || !["3300", "5302"].includes(url.port))) {
|
||||
boundary.unexpectedExternalRequests.push(`${request.method()} ${url.origin}${url.pathname}`);
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ context }, info) => {
|
||||
const boundary = networkBoundaries.get(context);
|
||||
await info.attach("csv-export-network-boundary", { body: JSON.stringify(boundary), contentType: "application/json" });
|
||||
expect(boundary?.unexpectedExternalRequests).toEqual([]);
|
||||
networkBoundaries.delete(context);
|
||||
});
|
||||
|
||||
test("CSV export has safe cells and headers, and browser download preserves its UTF-8 bytes", async ({ page }) => {
|
||||
await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en"));
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type TestInfo } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Language = 'en' | 'nb';
|
||||
type Headers = Record<string, string>;
|
||||
type Owner = { id: string; email: string; headers: Headers };
|
||||
type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number; body?: unknown };
|
||||
type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] };
|
||||
type Job = { id: number; companyId: number; jobTitle: string; company: { id: number; name: string }; [key: string]: unknown };
|
||||
const boundary = 'Actual isolated Program/SQLite/local Identity/CSRF and verified Free owners; actual JSON exporter/download and native browser file chooser; existing company deduplication and additive job creates. The malformed/legacy files are explicitly constructed inputs, not projected backend successes. Only the named partial-result case relays one controlled503 after an actual job POST201 commit, then holds that reply for progress/duplicate-admission evidence. This is a new-job import, not account/history/file/CV restore. No real provider/SMTP/model request, Docker/schema/destructive/deployment/main/Phase8 action. External requests are aborted; exact Google Fonts css2 stylesheet attempts are recorded separately. Emulated viewport/themes/locales and keyboard are finite browser evidence; physical chooser/device/reader/zoom acceptance remains pending.';
|
||||
function copy(key: string, language: Language = 'en', params: Record<string, number | string> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
function browserRequests(session: Session, path: string, method = 'POST') { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && value.method === method); }
|
||||
async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT' | 'PATCH', path: string, headers?: Headers, data?: unknown) {
|
||||
const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data }); const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result;
|
||||
}
|
||||
async function json<T>(response: APIResponse, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
||||
async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string; entitlements: { ai: boolean } }>(await call(records, api, 'GET', '/auth/me'));
|
||||
const csrf = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
async function newAccount(records: RequestRecord[], admin: APIRequestContext, headers: Headers) { const email = `p709-import-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await call(records, admin, 'POST', '/users', headers, { email, password, roles: [] })).status()).toBe(200); return email; }
|
||||
async function free(session: Session, api: APIRequestContext) { expect((await json<{ entitlements: { ai: boolean } }>(await call(session.requests, api, 'GET', '/auth/me'))).entitlements.ai).toBe(false); }
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext();
|
||||
context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search, ...(['POST', 'PUT', 'PATCH'].includes(request.method()) && ['/api/companies', '/api/jobapplications'].includes(url.pathname) ? { body: request.postDataJSON() } : {}) }); });
|
||||
context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); });
|
||||
await context.route('**/*', route => {
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); }
|
||||
const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname); const mailboxRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname);
|
||||
const ai = /^\/api\/(?:ai|cv\/ai)(?:\/|$)/.test(url.pathname); const settingsRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname);
|
||||
if (mailbox && !mailboxRead || ai && !settingsRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup|export\/account|admin\/system)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); }
|
||||
return route.continue();
|
||||
});
|
||||
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); });
|
||||
try { const administrator = await login(records, admin, 'e2e@example.test'); const owner = await login(records, page.request, await newAccount(records, admin, administrator.headers)); const session = { owner, admin, adminHeaders: administrator.headers, requests: records }; await free(session, page.request); await provide(session); expect(forbidden).toEqual([]); }
|
||||
finally { await info.attach('job-import-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
async function keyboard(control: Locator, key = 'Enter') { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press(key); }
|
||||
async function enter(page: Page, session: Session, language: Language = 'en', mode = 'light', width = 1440) {
|
||||
expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', session.owner.headers, { language: language === 'nb' ? 'nb-NO' : 'en' })).status()).toBe(204);
|
||||
await page.addInitScript(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, { language, mode });
|
||||
await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/settings'); const group = page.getByRole('group', { name: copy('importExportTitle', language), exact: true }); await expect(group).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode); return group;
|
||||
}
|
||||
async function chooseSection(page: Page, language: Language, key: string) {
|
||||
const main = page.getByRole('main'); const selector = main.getByRole('combobox', { name: copy('settingsNavigation', language), exact: true }); const tab = main.getByRole('tab', { name: copy(key, language), exact: true });
|
||||
await expect.poll(async () => await selector.isVisible() || await tab.isVisible()).toBe(true);
|
||||
if (await selector.isVisible()) { await keyboard(selector); const option = page.getByRole('option', { name: copy(key, language), exact: true }); await option.focus(); await option.press('Enter'); await expect(selector).toBeFocused(); }
|
||||
else { await keyboard(tab); await expect(tab).toHaveAttribute('aria-selected', 'true'); }
|
||||
}
|
||||
async function upload(page: Page, group: Locator, rows: unknown, language: Language = 'en', key = 'Enter') { const choosing = page.waitForEvent('filechooser'); await keyboard(group.getByRole('button', { name: copy('importJson', language), exact: true }), key); await (await choosing).setFiles({ name: 'explicit-import-fixture.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(rows), 'utf8') }); }
|
||||
async function exportRows(session: Session, api: APIRequestContext) { return json<Array<Record<string, unknown>>>(await call(session.requests, api, 'GET', '/export/jobs?format=json&includeDeleted=false')); }
|
||||
async function readJob(session: Session, api: APIRequestContext, id: number) { return json<Job>(await call(session.requests, api, 'GET', `/jobapplications/${id}`)); }
|
||||
async function createJob(page: Page, session: Session, companyName: string, data: Record<string, unknown>) {
|
||||
await free(session, page.request); const company = await json<{ id: number; name: string }>(await call(session.requests, page.request, 'POST', '/companies', session.owner.headers, { name: companyName, location: 'Oslo', source: 'finn.no' }), 201); const job = await json<Job>(await call(session.requests, page.request, 'POST', '/jobapplications', session.owner.headers, { companyId: company.id, ...data }), 201); return { company, job };
|
||||
}
|
||||
async function noImportedGraphs(session: Session, api: APIRequestContext, jobId: number) {
|
||||
const history = await json<Array<{ type: string }>>(await call(session.requests, api, 'GET', `/jobapplications/${jobId}/history`)); expect(history.map(event => event.type)).toEqual(['Created']);
|
||||
expect(await json(await call(session.requests, api, 'GET', `/attachments/${jobId}`))).toEqual([]); expect(await json(await call(session.requests, api, 'GET', `/correspondence/${jobId}`))).toEqual([]); expect(await json(await call(session.requests, api, 'GET', `/jobapplications/${jobId}/submitted-packages`))).toEqual([]);
|
||||
}
|
||||
async function capture(page: Page, group: Locator, info: TestInfo, name: string) {
|
||||
const geometry = await group.evaluate(element => { const bounds = element.getBoundingClientRect(); return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: Array.from(element.querySelectorAll('p,button,[role="status"],[role="alert"]')).filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden').map(node => ({ text: node.textContent?.slice(0, 100), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) }; });
|
||||
await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
const result = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete, qualification: 'All enabled axe rules; incomplete contexts retained for separate human triage.' }), contentType: 'application/json' }); expect(result.violations).toEqual([]);
|
||||
}
|
||||
|
||||
test('[P7-09 J10] actual PascalCase JSON download imports supported fields through the keyboard native chooser without changing export bytes or existing history', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const sourceData = { jobTitle: 'P709 Første – API developer', status: 'Applied', dateApplied: '2025-01-15T12:00:00Z', location: 'Oslo', salary: 'Avtales: 600 000–800 000 NOK', salaryMin: 600000, salaryMax: 800000, salaryCurrency: 'NOK', salaryPeriod: 'year', nextAction: 'Følg opp', followUpAt: '2030-02-14T09:15:00Z', feedbackRequestedAt: '2025-01-20T00:00:00Z', notes: 'Notater æøå\nLinje to', description: 'Known source description', translatedDescription: 'Kjent oversatt beskrivelse', descriptionLanguage: 'en', tags: '["TypeScript","API"]', deadline: '2030-02-14T00:00:00Z', coverLetterText: 'Bevart søknadstekst æøå', jobUrl: 'https://example.test/opportunity/42?ref=jobjakt' };
|
||||
const { company, job } = await createJob(page, session, `P709 Ærlig Export ${Date.now()}`, sourceData); expect((await call(session.requests, page.request, 'PATCH', `/jobapplications/${job.id}/followup`, session.owner.headers, { followUpAt: sourceData.followUpAt })).status()).toBe(204); const source = await readJob(session, page.request, job.id); const sourceHistory = await json<Array<{ type: string }>>(await call(session.requests, page.request, 'GET', `/jobapplications/${job.id}/history`)); expect(sourceHistory).toHaveLength(2);
|
||||
const group = await enter(page, session); const pending = page.waitForEvent('download'); const exported = page.waitForResponse(value => new URL(value.url()).pathname === '/api/export/jobs' && value.request().method() === 'GET'); await keyboard(group.getByRole('button', { name: copy('exportJson'), exact: true })); const download = await pending; const native = await exported; expect(native.status()).toBe(200); expect(native.headers()['content-type']).toContain('application/json'); const file = await download.path(); expect(file).not.toBeNull(); const bytes = await fs.readFile(file!); expect(bytes).toEqual(await native.body()); expect(download.suggestedFilename()).toMatch(/^job-tracker-export-\d{4}-\d{2}-\d{2}\.json$/); const rows = JSON.parse(bytes.toString('utf8')); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ Id: job.id, OwnerUserId: session.owner.id, JobTitle: sourceData.jobTitle, Company: { Id: company.id, Name: company.name } }); expect(rows[0]).not.toHaveProperty('jobTitle'); await info.attach('actual-job-export-original-bytes', { body: bytes, contentType: 'application/json' });
|
||||
const choosing = page.waitForEvent('filechooser'); await keyboard(group.getByRole('button', { name: copy('importJson'), exact: true })); await (await choosing).setFiles(file!); await expect(group.getByRole('status')).toHaveText(copy('lastImport', 'en', { count: 1 })); const after = await exportRows(session, page.request); expect(after).toHaveLength(2); const importedRaw = after.find(value => value.Id !== job.id)!; expect(importedRaw.OwnerUserId).toBe(session.owner.id); expect(importedRaw.CompanyId).toBe(company.id); const imported = await readJob(session, page.request, Number(importedRaw.Id));
|
||||
for (const field of ['jobTitle', 'status', 'dateApplied', 'location', 'salary', 'salaryMin', 'salaryMax', 'salaryCurrency', 'salaryPeriod', 'nextAction', 'followUpAt', 'feedbackRequestedAt', 'notes', 'description', 'translatedDescription', 'descriptionLanguage', 'tags', 'deadline', 'coverLetterText', 'jobUrl']) expect(imported[field], field).toEqual(source[field]); expect(imported.id).not.toBe(job.id); expect(imported).toMatchObject({ hasResume: false, hasCoverLetter: false, hasPortfolio: false, hasOtherAttachment: false, responseReceived: false, responseDate: null, isDeleted: false, tailoredCvText: null }); await noImportedGraphs(session, page.request, imported.id); expect(await json(await call(session.requests, page.request, 'GET', `/jobapplications/${job.id}/history`))).toEqual(sourceHistory); expect((await json<Array<unknown>>(await call(session.requests, page.request, 'GET', '/companies')))).toHaveLength(1); expect(browserRequests(session, '/companies')).toHaveLength(1); expect(browserRequests(session, '/jobapplications')).toHaveLength(1); await capture(page, group, info, 'actual-json-export-import-en-light-1440');
|
||||
});
|
||||
|
||||
test('[P7-09 J10 E09] legacy import excludes foreign graphs and an actual account switch disposes a held batch before its next queued row', async ({ page, playwright, session }, info) => {
|
||||
test.setTimeout(150_000); const sourceOwnerId = session.owner.id; const sourceEmail = session.owner.email; const source = await createJob(page, session, `P709 Foreign Graph ${Date.now()}`, { jobTitle: 'Owner A original', status: 'Saved', dateApplied: null }); const ownerA = await playwright.request.newContext({ storageState: await page.context().storageState() }); const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const accountB = await login(session.requests, page.request, await newAccount(session.requests, session.admin, session.adminHeaders)); session.owner = accountB; await free(session, page.request); const group = await enter(page, session, 'nb', 'light', 768);
|
||||
const row = { id: 987654321, ownerUserId: sourceOwnerId, jobId: 987654321, companyId: source.company.id, company: { id: source.company.id, ownerUserId: sourceOwnerId, name: source.company.name, location: 'Bergen', source: 'manual', recruiterEmail: 'must-not-import@example.test' }, jobTitle: 'Legacy import – æøå', status: 'Saved', dateApplied: null, salary: 'Etter avtale', salaryMin: 500000, salaryMax: 750000, salaryCurrency: 'NOK', salaryPeriod: 'year', notes: 'Legacy supported prose', savedAt: '1900-01-01T00:00:00Z', responseReceived: true, responseDate: '2020-01-01T00:00:00Z', isDeleted: true, deletedAt: '2020-01-01T00:00:00Z', hasResume: true, hasCoverLetter: true, hasPortfolio: true, hasOtherAttachment: true, tailoredCvText: 'FROZEN TEXT MUST NOT BE ADOPTED', tailoredCvDraft: { id: 999, status: 'submitted' }, messages: [{ id: 999, content: 'DO NOT RESTORE' }], attachments: [{ id: 999, fileName: 'DO-NOT-RESTORE.pdf' }], events: [{ id: 999, type: 'Restored' }], submittedPackages: [{ id: 999, cvHtml: 'FROZEN HTML MUST NOT BE ADOPTED' }] };
|
||||
await upload(page, group, [row], 'nb', 'Space'); await expect(group.getByRole('status')).toHaveText(copy('lastImport', 'nb', { count: 1 })); const bRows = await exportRows(session, page.request); expect(bRows).toHaveLength(1); const raw = bRows[0]; expect(raw.OwnerUserId).toBe(accountB.id); expect(raw.Id).not.toBe(row.id); expect(raw.JobId).not.toBe(row.jobId); expect(raw.CompanyId).not.toBe(source.company.id); const imported = await readJob(session, page.request, Number(raw.Id)); expect(imported).toMatchObject({ jobTitle: row.jobTitle, company: { name: source.company.name, location: 'Bergen', source: 'Manual', recruiterEmail: null }, status: 'Saved', dateApplied: null, responseReceived: false, responseDate: null, isDeleted: false, deletedAt: null, hasResume: false, hasCoverLetter: false, hasPortfolio: false, hasOtherAttachment: false, tailoredCvText: null, salary: row.salary, salaryMin: row.salaryMin, salaryMax: row.salaryMax }); expect(String(raw.SavedAt)).not.toBe(row.savedAt); await noImportedGraphs(session, page.request, imported.id); expect(await json(await call(session.requests, page.request, 'GET', '/cv/variants'))).toEqual([]);
|
||||
expect((await call(session.requests, page.request, 'GET', `/jobapplications/${source.job.id}`)).status()).toBe(404); expect((await call(session.requests, ownerA, 'GET', `/jobapplications/${imported.id}`)).status()).toBe(404); const aRows = await exportRows(session, ownerA); expect(aRows).toHaveLength(1); expect(aRows[0].Id).toBe(source.job.id); expect(await exportRows(session, page.request)).toEqual(bRows); expect((await call(session.requests, anonymous, 'GET', '/export/jobs?format=json')).status()).toBe(401); expect((await call(session.requests, anonymous, 'GET', `/jobapplications/${imported.id}`)).status()).toBe(401);
|
||||
for (const request of browserRequests(session, '/companies')) expect(Object.keys(request.body as object).sort()).toEqual(['location', 'name', 'source']); const posted = browserRequests(session, '/jobapplications')[0].body as Record<string, unknown>; for (const key of ['id', 'ownerUserId', 'jobId', 'savedAt', 'isDeleted', 'deletedAt', 'responseReceived', 'responseDate', 'hasResume', 'hasCoverLetter', 'hasPortfolio', 'hasOtherAttachment', 'tailoredCvText', 'tailoredCvDraft', 'messages', 'attachments', 'events', 'submittedPackages']) expect(posted).not.toHaveProperty(key); await capture(page, group, info, 'legacy-input-owner-graph-safety-nb-light-768'); await info.attach('legacy-file-graph-exclusion-boundary', { body: JSON.stringify({ boundary, controlledInput: 'Explicit manually constructed camelCase file contains foreign identifiers and history/file/frozen graphs. Those are never forwarded or restored.', actualReadback: { jobsA: aRows.length, jobsB: bRows.length, importedOwner: raw.OwnerUserId, sourceA: source.job.id, createdB: imported.id, attachments: 0, correspondence: 0, submittedPackages: 0, cvVariants: 0 }, anonymous: [401, 401], foreign: [404, 404] }), contentType: 'application/json' });
|
||||
const retainedB = await playwright.request.newContext({ storageState: await page.context().storageState() });
|
||||
const gate = hold(); let committedB: number | null = null; let pendingPosts = 0; let cancelledReply: string | null = null;
|
||||
await page.route(`${apiUrl}/jobapplications`, async route => {
|
||||
expect(route.request().method()).toBe('POST'); pendingPosts++; const actual = await route.fetch(); expect(actual.status()).toBe(201); committedB = (await actual.json() as { id: number }).id;
|
||||
await gate.pending; const failure = route.request().failure(); if (failure) { cancelledReply = failure.errorText; return; } return route.fulfill({ response: actual });
|
||||
});
|
||||
try {
|
||||
await upload(page, group, [{ jobTitle: 'Owner B accepted before switch', status: 'Saved', dateApplied: null, company: { name: source.company.name } }, { jobTitle: 'Owner B next queued row must not write', status: 'Saved', dateApplied: null, company: { name: source.company.name } }], 'nb');
|
||||
await expect.poll(() => Number.isInteger(committedB) && Number(committedB) > 0).toBe(true); expect(pendingPosts).toBe(1); await expect(group).toHaveAttribute('aria-busy', 'true');
|
||||
session.owner = await login(session.requests, page.request, sourceEmail); expect(session.owner.id).toBe(sourceOwnerId); await free(session, page.request);
|
||||
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); gate.release(); await expect(group).toHaveCount(0);
|
||||
const afterB = await exportRows(session, retainedB); expect(afterB).toHaveLength(2); expect(afterB.some(value => value.Id === committedB && value.JobTitle === 'Owner B accepted before switch')).toBe(true); expect(afterB.some(value => value.JobTitle === 'Owner B next queued row must not write')).toBe(false);
|
||||
const afterA = await exportRows(session, page.request); expect(afterA).toHaveLength(1); expect(afterA[0].Id).toBe(source.job.id); expect(pendingPosts).toBe(1); expect(browserRequests(session, '/jobapplications')).toHaveLength(2);
|
||||
await info.attach('actual-import-owner-switch-disposal', { body: JSON.stringify({ boundary, controlledTiming: 'An actual owner-B job POST201 acknowledgement was held. Actual login changed the shared cookies back to A, disposing the earlier page; only then was the held reply released.', committedB, cancelledReply, jobsB: afterB.length, jobsA: afterA.length, oldBatchJobPosts: pendingPosts, nextQueuedRowWritten: false, qualification: 'The first B write remains committed. Disposal stops queued writes; it neither rolls back accepted work nor adopts the next account session.' }), contentType: 'application/json' });
|
||||
} finally { gate.release(); await retainedB.dispose(); }
|
||||
} finally { await ownerA.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-09 J10] invalid later rows and conflicting null aliases reject the complete file before the first company or job write', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const group = await enter(page, session, 'en', 'dark', 320); const good = { jobTitle: 'Valid earlier row must never write', status: 'Saved', dateApplied: null, company: { name: 'No company should be created' } };
|
||||
for (const invalid of [{ ...good, company: 42 }, { ...good, dateApplied: '2026-02-31' }, { ...good, notes: null, Notes: 'Conflicting populated alias' }, { ...good, salaryMin: 1e100 }, { ...good, dateApplied: '2026-01-01T00:00:00+15:00' }]) {
|
||||
await upload(page, group, [good, invalid]); await expect(group.locator('input[accept="application/json"]')).toHaveValue(''); await expect(group.getByRole('alert')).toContainText(copy('jobImportInvalidRow', 'en', { row: 2 })); await expect(group).toHaveAttribute('aria-busy', 'false'); expect(browserRequests(session, '/companies')).toHaveLength(0); expect(browserRequests(session, '/jobapplications')).toHaveLength(0); expect(await exportRows(session, page.request)).toEqual([]); expect(await json(await call(session.requests, page.request, 'GET', '/companies'))).toEqual([]);
|
||||
}
|
||||
await expect(group.locator('input[accept="application/json"]')).toHaveValue(''); await capture(page, group, info, 'invalid-later-row-zero-writes-en-dark-320'); await info.attach('preflight-whole-file-validation', { body: JSON.stringify({ boundary, explicitlyConstructedInvalidSecondRows: ['company is a number', 'nonexistent February31 date', 'null camelCase notes conflicts with populated PascalCase Notes', 'finite1e100 exceeds the server decimal envelope', '+15:00 exceeds the server DateTime offset envelope'], earlierValidRowsWritten: 0, companyPosts: 0, jobPosts: 0, actualCompaniesAndJobs: [0, 0], inputResetForDeliberateReselection: true }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-09 J10] controlled503 after an actual second job commit reports only acknowledged rows and warns about uncertainty without replaying or admitting another batch', async ({ page, session }, info) => {
|
||||
test.setTimeout(150_000); const group = await enter(page, session, 'nb', 'dark', 390); const companyName = `P709 Partial ${Date.now()}`; const rows = [1, 2, 3].map(number => ({ JobTitle: `Partial native row ${number}`, Status: 'Saved', DateApplied: null, Company: { Name: companyName, Location: 'Trondheim', Source: 'manual' } })); const gate = hold(); const actualCommits: { id: number; status: number }[] = [];
|
||||
await page.route(`${apiUrl}/jobapplications`, async route => { expect(route.request().method()).toBe('POST'); const actual = await route.fetch(); expect(actual.status()).toBe(201); const created = await actual.json() as { id: number }; actualCommits.push({ id: created.id, status: actual.status() }); if (actualCommits.length === 2) { await gate.pending; return route.fulfill({ status: 503, json: {} }); } return route.fulfill({ response: actual }); });
|
||||
try {
|
||||
await upload(page, group, rows, 'nb'); await expect.poll(() => actualCommits.length).toBe(2); await expect(group).toHaveAttribute('aria-busy', 'true'); await expect(group.getByRole('status')).toContainText(copy('jobImportProgress', 'nb', { count: 1, total: 3 })); const importing = group.getByRole('button', { name: copy('profileUploading', 'nb'), exact: true }); await expect(importing).toBeDisabled(); const chooserEvents: string[] = []; const unexpectedChooser = () => { chooserEvents.push('unexpected second native chooser'); }; page.on('filechooser', unexpectedChooser); await page.keyboard.press('Space'); await group.locator('input[accept="application/json"]').setInputFiles({ name: 'explicit-duplicate-selection.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(rows), 'utf8') }); expect(browserRequests(session, '/jobapplications')).toHaveLength(2); expect(browserRequests(session, '/companies')).toHaveLength(2); expect(chooserEvents).toEqual([]); page.off('filechooser', unexpectedChooser);
|
||||
expect((await exportRows(session, page.request)).map(value => value.JobTitle).sort()).toEqual(['Partial native row 1', 'Partial native row 2']);
|
||||
await capture(page, group, info, 'held-real-commits-progress-nb-dark-390');
|
||||
await chooseSection(page, 'nb', 'settingsTabNotifications'); await expect(group).not.toBeVisible();
|
||||
const relayed = page.waitForResponse(value => new URL(value.url()).pathname === '/api/jobapplications' && value.request().method() === 'POST');
|
||||
gate.release(); expect((await relayed).status()).toBe(503);
|
||||
await expect(page.getByRole('alert').filter({ hasText: copy('jobImportStopped', 'nb', { row: 2, count: 1, total: 3 }) })).toHaveCount(1);
|
||||
await expect(page.getByRole('alert').filter({ hasText: copy('importFailedJson', 'nb') })).toHaveCount(0);
|
||||
await chooseSection(page, 'nb', 'settingsTabGeneral'); await expect(group).toBeVisible(); await expect(group).toHaveAttribute('aria-busy', 'false');
|
||||
await expect(group.getByRole('alert')).toContainText(copy('jobImportStopped', 'nb', { row: 2, count: 1, total: 3 })); await expect(group.getByRole('alert')).toContainText(copy('jobImportInspectBeforeRetry', 'nb')); await expect(group.getByRole('button', { name: copy('retry', 'nb'), exact: true })).toHaveCount(0); await expect(group.getByRole('button', { name: copy('importJson', 'nb'), exact: true })).toBeEnabled(); await expect(group.locator('input[accept="application/json"]')).toHaveValue('');
|
||||
const samples = [
|
||||
{ width: 320, language: 'nb', mode: 'dark' }, { width: 390, language: 'en', mode: 'light' },
|
||||
{ width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' },
|
||||
{ width: 1280, language: 'nb', mode: 'dark' }, { width: 1440, language: 'en', mode: 'light' },
|
||||
{ width: 1920, language: 'en', mode: 'dark' },
|
||||
] as const;
|
||||
for (const sample of samples) {
|
||||
await page.setViewportSize({ width: sample.width, height: sample.width < 768 ? 844 : 900 });
|
||||
const languageButton = page.locator('header').getByRole('button', { name: sample.language === 'nb' ? 'Norsk' : 'English', exact: true });
|
||||
if (await languageButton.getAttribute('aria-pressed') !== 'true') {
|
||||
const savedLanguage = page.waitForResponse(value => new URL(value.url()).pathname === '/api/auth/preferences/language' && value.request().method() === 'PUT');
|
||||
await keyboard(languageButton); expect((await savedLanguage).status()).toBe(204);
|
||||
}
|
||||
const current = page.getByRole('group', { name: copy('importExportTitle', sample.language), exact: true });
|
||||
await expect(current.getByRole('alert')).toContainText(copy('jobImportStopped', sample.language, { row: 2, count: 1, total: 3 }));
|
||||
if (await page.locator('html').getAttribute('data-color-scheme') !== sample.mode) {
|
||||
const theme = page.getByRole('main').getByRole('combobox', { name: copy('settingsTheme', sample.language), exact: true });
|
||||
await keyboard(theme); const option = page.getByRole('option', { name: copy(sample.mode === 'dark' ? 'settingsThemeDark' : 'settingsThemeLight', sample.language), exact: true }); await option.focus(); await option.press('Enter');
|
||||
}
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.mode);
|
||||
await expect(current).toContainText(copy('jobImportScope', sample.language)); await expect(current.getByRole('alert')).toContainText(copy('jobImportInspectBeforeRetry', sample.language));
|
||||
const importButton = current.getByRole('button', { name: copy('importJson', sample.language), exact: true }); await importButton.focus(); await expect(importButton).toBeFocused(); expect(await importButton.evaluate(node => getComputedStyle(node).outlineStyle)).toBe('solid');
|
||||
await capture(page, current, info, `partial-warning-${sample.width}-${sample.language}-${sample.mode}`);
|
||||
}
|
||||
const after = await exportRows(session, page.request); expect(after).toHaveLength(2); expect(after.map(value => value.JobTitle).sort()).toEqual(['Partial native row 1', 'Partial native row 2']); expect(await json<Array<unknown>>(await call(session.requests, page.request, 'GET', '/companies'))).toHaveLength(1); expect(actualCommits).toHaveLength(2); expect(browserRequests(session, '/jobapplications')).toHaveLength(2); expect(browserRequests(session, '/companies')).toHaveLength(2);
|
||||
await info.attach('actual-commit-controlled503-uncertainty', { body: JSON.stringify({ boundary, actualCommits, controlledFault: 'Only the second browser-visible job reply is replaced with503 AFTER actual route.fetch returned201; its job really exists. This is a fault-relay fixture, not a real backend503 or a fabricated successful write.', fileRows: 3, acknowledgedJobs: 1, actualJobs: 2, thirdRowNotAttempted: true, wholeFileOrRowAutoReplay: false, duplicateActivation: 'Disabled natural button plus explicitly controlled second file-input selection while busy; exactly one batch admitted.', internalTabLifetime: 'Accepted import completed while General was hidden by Notifications; the stopped result and its acknowledgement count remain on General return.', samples, layoutQualification: 'Seven paired samples, not Cartesian; same persisted partial result and uncertainty copy survive real language/theme controls without reload or projected result state.', companies: 1 }), contentType: 'application/json' });
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
import { api } from "../api";
|
||||
import { Company, JobApplication } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { checkSessionCookie, getSessionContext, isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "../sessionBoundary";
|
||||
|
||||
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
|
||||
// row has no business supplying one.
|
||||
@@ -14,6 +15,105 @@ type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
|
||||
company: Pick<Company, "name" | "location" | "source">;
|
||||
};
|
||||
|
||||
type ImportFields = Record<string, string | number | null>;
|
||||
type PreparedImport = { company: ImportFields; job: ImportFields };
|
||||
type ImportState =
|
||||
| { kind: "checking" }
|
||||
| { kind: "running"; count: number; total: number }
|
||||
| { kind: "invalid"; row?: number }
|
||||
| { kind: "stopped"; row: number; count: number; total: number };
|
||||
|
||||
class InvalidImport extends Error {
|
||||
constructor(readonly row?: number) { super("Invalid import"); }
|
||||
}
|
||||
|
||||
function importRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new InvalidImport();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function importAlias<T>(record: Record<string, unknown>, name: string, parse: (value: unknown) => T): T | undefined {
|
||||
const pascal = name[0].toUpperCase() + name.slice(1);
|
||||
const camelPresent = Object.prototype.hasOwnProperty.call(record, name);
|
||||
const pascalPresent = Object.prototype.hasOwnProperty.call(record, pascal);
|
||||
const camel = camelPresent ? parse(record[name]) : undefined;
|
||||
const exported = pascalPresent ? parse(record[pascal]) : undefined;
|
||||
if (camelPresent && pascalPresent && JSON.stringify(camel) !== JSON.stringify(exported)) throw new InvalidImport();
|
||||
return camelPresent ? camel : exported;
|
||||
}
|
||||
|
||||
function importText(value: unknown): string | null {
|
||||
if (value !== null && typeof value !== "string") throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importRequiredText(value: unknown): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importDate(value: unknown): string | null {
|
||||
const text = importText(value);
|
||||
if (text === null) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,16}))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/.exec(text);
|
||||
const instant = Date.parse(text);
|
||||
if (!match || !Number.isFinite(instant)) throw new InvalidImport();
|
||||
const [, year, month, day, hour, minute, second, , offset, offsetHour, offsetMinute] = match;
|
||||
if (Number(year) < 1 || Number(month) < 1 || Number(month) > 12 || Number(day) < 1 ||
|
||||
Number(day) > new Date(Date.UTC(Number(year), Number(month), 0)).getUTCDate() ||
|
||||
Number(hour ?? 0) > 23 || Number(minute ?? 0) > 59 || Number(second ?? 0) > 59 ||
|
||||
Number(offsetHour ?? 0) > 14 || Number(offsetMinute ?? 0) > 59 || Number(offsetHour) === 14 && Number(offsetMinute) !== 0) throw new InvalidImport();
|
||||
// DateTime offsets must fit its year range; no-zone values retain unspecified local-time semantics.
|
||||
if (offset && (instant < Date.parse("0001-01-01T00:00:00Z") || instant > Date.parse("9999-12-31T23:59:59.999Z"))) throw new InvalidImport();
|
||||
return text;
|
||||
}
|
||||
|
||||
function importNumber(value: unknown): number | null {
|
||||
// decimal.MaxValue is 2^96 - 1; JavaScript rounds that bound up, so equality must also be rejected.
|
||||
if (value !== null && (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 2 ** 96)) throw new InvalidImport();
|
||||
return value;
|
||||
}
|
||||
|
||||
function importCompany(value: unknown): ImportFields {
|
||||
const source = importRecord(value);
|
||||
const name = importAlias(source, "name", importRequiredText);
|
||||
if (name === undefined) throw new InvalidImport();
|
||||
const company: ImportFields = { name };
|
||||
for (const field of ["location", "source"]) {
|
||||
const text = importAlias(source, field, importText);
|
||||
if (text !== undefined) company[field] = text;
|
||||
}
|
||||
return company;
|
||||
}
|
||||
|
||||
function prepareImport(text: string): PreparedImport[] {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { throw new InvalidImport(); }
|
||||
if (!Array.isArray(parsed)) throw new InvalidImport();
|
||||
return parsed.map((value, index) => {
|
||||
try {
|
||||
const source = importRecord(value);
|
||||
const company = importAlias(source, "company", importCompany);
|
||||
const jobTitle = importAlias(source, "jobTitle", importRequiredText);
|
||||
if (!company || jobTitle === undefined) throw new InvalidImport();
|
||||
const job: ImportFields = { jobTitle };
|
||||
for (const field of ["status", "location", "salary", "salaryCurrency", "salaryPeriod", "nextAction", "notes", "description", "translatedDescription", "descriptionLanguage", "tags", "coverLetterText", "jobUrl", "source", "countryCode"]) {
|
||||
const text = importAlias(source, field, importText);
|
||||
if (text !== undefined) job[field] = text;
|
||||
}
|
||||
for (const field of ["dateApplied", "followUpAt", "feedbackRequestedAt", "deadline"]) {
|
||||
const date = importAlias(source, field, importDate);
|
||||
if (date !== undefined) job[field] = date;
|
||||
}
|
||||
for (const field of ["salaryMin", "salaryMax"]) {
|
||||
const number = importAlias(source, field, importNumber);
|
||||
if (number !== undefined) job[field] = number;
|
||||
}
|
||||
return { company, job };
|
||||
} catch { throw new InvalidImport(index + 1); }
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(filename: string, content: BlobPart, mime: string) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -180,8 +280,25 @@ export default function ImportExportJobs() {
|
||||
const { t } = useI18n();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [lastImportCount, setLastImportCount] = useState<number | null>(null);
|
||||
const [importState, setImportState] = useState<ImportState | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const csvInputRef = useRef<HTMLInputElement>(null);
|
||||
const importBody = useRef<HTMLDivElement>(null);
|
||||
const importSession = useRef({ mounted: true, busy: false, generation: 0 });
|
||||
const currentTranslation = useRef(t);
|
||||
|
||||
useEffect(() => { currentTranslation.current = t; }, [t]);
|
||||
useEffect(() => {
|
||||
const session = importSession.current;
|
||||
session.mounted = true;
|
||||
const invalidate = () => { session.generation += 1; };
|
||||
window.addEventListener(SESSION_INVALIDATED_EVENT, invalidate);
|
||||
return () => {
|
||||
session.mounted = false;
|
||||
invalidate();
|
||||
window.removeEventListener(SESSION_INVALIDATED_EVENT, invalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const exportAll = async (format: "json" | "csv") => {
|
||||
try {
|
||||
@@ -198,40 +315,61 @@ export default function ImportExportJobs() {
|
||||
};
|
||||
|
||||
const onImportFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const session = importSession.current;
|
||||
checkSessionCookie();
|
||||
if (!file || !session.mounted || session.busy || isSessionInvalidated()) 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);
|
||||
setImportState({ kind: "checking" });
|
||||
let total = 0;
|
||||
let created = 0;
|
||||
let row = 1;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as ImportJob[];
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid format");
|
||||
|
||||
let created = 0;
|
||||
for (const j of parsed) {
|
||||
if (!active()) return;
|
||||
const jobs = prepareImport(text);
|
||||
total = jobs.length;
|
||||
setImportState({ kind: "running", count: created, total });
|
||||
for (const j of jobs) {
|
||||
if (!active()) return;
|
||||
row = created + 1;
|
||||
const companyRes = await api.post<Company>("/companies", j.company);
|
||||
if (!active()) return;
|
||||
const company = companyRes.data;
|
||||
await api.post("/jobapplications", {
|
||||
jobTitle: j.jobTitle,
|
||||
companyId: company.id,
|
||||
status: j.status,
|
||||
dateApplied: j.dateApplied,
|
||||
location: j.location,
|
||||
salary: j.salary,
|
||||
nextAction: j.nextAction,
|
||||
followUpAt: j.followUpAt,
|
||||
jobUrl: j.jobUrl,
|
||||
notes: j.notes,
|
||||
coverLetterText: j.coverLetterText,
|
||||
});
|
||||
if (!Number.isInteger(company?.id) || company.id <= 0) throw new Error("Invalid company response");
|
||||
// Only the normalized create allow-list reaches this request; exported identities and history never do.
|
||||
await api.post("/jobapplications", { ...j.job, companyId: company.id });
|
||||
if (!active()) return;
|
||||
created += 1;
|
||||
setImportState({ kind: "running", count: created, total });
|
||||
}
|
||||
|
||||
setLastImportCount(created);
|
||||
toast(t("importedJobs", { count: created }), "success");
|
||||
} catch {
|
||||
toast(t("importFailedJson"), "error");
|
||||
setImportState(null);
|
||||
toast(currentTranslation.current("importedJobs", { count: created }), "success");
|
||||
} catch (error) {
|
||||
if (!active()) return;
|
||||
if (error instanceof InvalidImport) setImportState({ kind: "invalid", row: error.row });
|
||||
else if (total === 0) setImportState({ kind: "invalid" });
|
||||
else {
|
||||
setLastImportCount(created);
|
||||
setImportState({ kind: "stopped", row, count: created, total });
|
||||
}
|
||||
toast(currentTranslation.current(total > 0 ? "jobImportStopped" : "importFailedJson", { row, count: created, total }), "error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (active()) {
|
||||
session.busy = false;
|
||||
setImporting(false);
|
||||
if (importInputRef.current) importInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,7 +390,8 @@ export default function ImportExportJobs() {
|
||||
|
||||
return (
|
||||
<PageSection level="h3" title={t("importExportTitle")} description={helper}>
|
||||
|
||||
<Box ref={importBody} role="group" aria-label={t("importExportTitle")} aria-busy={importing} tabIndex={-1} sx={{ display: "grid", gap: 1.5, minWidth: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">{t("jobImportScope")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Button variant="outlined" onClick={() => void exportAll("json")}>{t("exportJson")}</Button>
|
||||
<Button variant="outlined" onClick={() => void exportAll("csv")}>{t("exportCsv")}</Button>
|
||||
@@ -267,8 +406,19 @@ export default function ImportExportJobs() {
|
||||
</Button>
|
||||
<input ref={csvInputRef} type="file" accept=".csv,text/csv" hidden onChange={(e) => void onConvertCsv(e.target.files?.[0] ?? null)} />
|
||||
|
||||
{lastImportCount !== null && (
|
||||
<Typography sx={{ ml: 1, color: "text.secondary" }}>
|
||||
</Box>
|
||||
{importState?.kind === "checking" || importState?.kind === "running" ? <Typography role="status" variant="body2" color="text.secondary">
|
||||
{importState.kind === "checking" ? t("jobImportChecking") : t("jobImportProgress", { count: importState.count, total: importState.total })}
|
||||
</Typography> : null}
|
||||
{importState?.kind === "invalid" ? <Alert severity="error">
|
||||
{importState.row === undefined ? t("jobImportInvalidFile") : t("jobImportInvalidRow", { row: importState.row })}
|
||||
</Alert> : null}
|
||||
{importState?.kind === "stopped" ? <Alert severity="error">
|
||||
{t("jobImportStopped", { row: importState.row, count: importState.count, total: importState.total })}
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{t("jobImportInspectBeforeRetry")}</Typography>
|
||||
</Alert> : null}
|
||||
{lastImportCount !== null && (
|
||||
<Typography role="status" variant="body2" color="text.secondary">
|
||||
{t("lastImport", { count: lastImportCount })}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -40,9 +40,11 @@ interface Props {
|
||||
onThemeModeChange: (v: ThemeModePref) => void;
|
||||
}
|
||||
|
||||
function TabPanel({ value, index, label, children }: { value: number; index: number; label: string; children: React.ReactNode }) {
|
||||
if (value !== index) return null;
|
||||
return <Box role="tabpanel" id={`settings-panel-${index}`} aria-labelledby={`settings-tab-${index}`} aria-label={label} sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
function TabPanel({ value, index, label, children, keepMounted = false }: { value: number; index: number; label: string; children: React.ReactNode; keepMounted?: boolean }) {
|
||||
const [hasBeenActive, setHasBeenActive] = useState(value === index);
|
||||
useEffect(() => { if (value === index) setHasBeenActive(true); }, [value, index]);
|
||||
if (value !== index && (!keepMounted || !hasBeenActive)) return null;
|
||||
return <Box role="tabpanel" hidden={value !== index} id={`settings-panel-${index}`} aria-labelledby={`settings-tab-${index}`} aria-label={label} sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -247,7 +249,7 @@ export default function SettingsView({
|
||||
|
||||
{rulesDirty ? <Typography role="status" variant="body2" sx={{ mt: 1, color: "text.secondary" }}>{t("assetsUnsavedChanges")}. {t("rulesDraftScope")}</Typography> : null}
|
||||
|
||||
<TabPanel value={tab} index={0} label={t("settingsTabGeneral")}>
|
||||
<TabPanel value={tab} index={0} label={t("settingsTabGeneral")} keepMounted>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: "var(--app-section-gap)" }}>
|
||||
<PageSection surface title={t("settingsAppearance")}>
|
||||
|
||||
@@ -2297,6 +2297,13 @@ export const translations = {
|
||||
errorBoundaryRefresh: "Refresh",
|
||||
importExportTitle: "Import / Export",
|
||||
importExportBody: "Import expects the JSON exported by this app (an array of job objects with embedded company).",
|
||||
jobImportScope: "Imports create new jobs from supported fields. IDs, owners, history, attachments and CV associations are not restored. Keep Settings open until the import finishes. If you leave, the import may be incomplete.",
|
||||
jobImportChecking: "Checking import file…",
|
||||
jobImportProgress: "{count} of {total} jobs confirmed imported.",
|
||||
jobImportInvalidFile: "Choose a JSON array of jobs. No jobs or companies were written.",
|
||||
jobImportInvalidRow: "Row {row} has missing, invalid or conflicting fields. No jobs or companies were written.",
|
||||
jobImportStopped: "Import stopped at row {row}. {count} of {total} jobs are confirmed imported.",
|
||||
jobImportInspectBeforeRetry: "This row may have been partly saved. Check your job list and company list before importing again; repeating the file can create duplicates.",
|
||||
exportJson: "Export JSON",
|
||||
exportCsv: "Export CSV",
|
||||
importJson: "Import JSON",
|
||||
@@ -5060,6 +5067,13 @@ export const translations = {
|
||||
errorBoundaryRefresh: "Oppdater",
|
||||
importExportTitle: "Import / eksport",
|
||||
importExportBody: "Import forventer JSON eksportert av denne appen (en matrise med jobbobjekter med innebygd selskap).",
|
||||
jobImportScope: "Import oppretter nye jobber fra støttede felt. ID-er, eiere, historikk, vedlegg og CV-koblinger gjenopprettes ikke. Hold Innstillinger åpent til importen er ferdig. Hvis du går ut, kan importen bli ufullstendig.",
|
||||
jobImportChecking: "Kontrollerer importfil…",
|
||||
jobImportProgress: "{count} av {total} jobber er bekreftet importert.",
|
||||
jobImportInvalidFile: "Velg en JSON-liste med jobber. Ingen jobber eller selskaper ble lagret.",
|
||||
jobImportInvalidRow: "Rad {row} har manglende, ugyldige eller motstridende felt. Ingen jobber eller selskaper ble lagret.",
|
||||
jobImportStopped: "Importen stoppet på rad {row}. {count} av {total} jobber er bekreftet importert.",
|
||||
jobImportInspectBeforeRetry: "Denne raden kan være delvis lagret. Kontroller jobblisten og selskapslisten før du importerer på nytt; gjentatt import kan opprette duplikater.",
|
||||
exportJson: "Eksporter JSON",
|
||||
exportCsv: "Eksporter CSV",
|
||||
importJson: "Importer JSON",
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import SettingsView from "./components/SettingsView";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), post: jest.fn(), put: jest.fn() },
|
||||
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
jest.mock("./sessionBoundary", () => ({
|
||||
SESSION_INVALIDATED_EVENT: "session-invalidated",
|
||||
checkSessionCookie: jest.fn(),
|
||||
getSessionContext: jest.fn(() => "owner-a-session"),
|
||||
isSessionInvalidated: jest.fn(() => false),
|
||||
}));
|
||||
jest.mock("./components/AiUsageCard", () => () => <div>Usage auxiliary owner</div>);
|
||||
jest.mock("./components/QuickCaptureCard", () => () => <div>Capture auxiliary owner</div>);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const savedRules = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 };
|
||||
const savedPrivacy = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: "local" };
|
||||
const reads = (path: string) => mockedApi.get.mock.calls.filter(([url]) => url === path);
|
||||
const text = (key: "lastImport" | "importedJobs", count: number) => translations.en[key].replace("{count}", String(count));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(yes => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function file(rows: unknown[]) {
|
||||
const contents = JSON.stringify(rows);
|
||||
const selected = new File([contents], "jobs.json", { type: "application/json" });
|
||||
Object.defineProperty(selected, "text", { value: jest.fn().mockResolvedValue(contents) });
|
||||
return selected;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
return render(<MemoryRouter><ToastProvider><I18nProvider><PromptProvider>
|
||||
<SettingsView pageSize={20} onPageSizeChange={jest.fn()}
|
||||
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={jest.fn()}
|
||||
themeMode="dark" onThemeModeChange={jest.fn()} />
|
||||
</PromptProvider></I18nProvider></ToastProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
async function selectFile(rows: unknown[]) {
|
||||
const input = document.querySelector<HTMLInputElement>('input[accept="application/json"]');
|
||||
expect(input).not.toBeNull();
|
||||
await act(async () => { fireEvent.change(input!, { target: { files: [file(rows)] } }); });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.post.mockReset(); mockedApi.put.mockReset();
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockImplementation((path: string) => {
|
||||
if (path === "/ai/settings") return Promise.resolve({ data: savedPrivacy } as any);
|
||||
if (path === "/notification-settings") return Promise.resolve({ data: { emailFollowUpRemindersEnabled: true } } as any);
|
||||
if (path === "/rules") return Promise.resolve({ data: savedRules } as any);
|
||||
if (path === "/account-lifecycle/status") return Promise.resolve({ data: { deletionEnabled: false, deletionStatus: "active", requiredConfirmation: "DELETE owner@example.test", request: null } } as any);
|
||||
return Promise.reject(new Error(`Unexpected GET ${path}`));
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
test("[P7-09 lifetime] an accepted import continues once across Notifications and Rules while a real Settings unmount stops queued writes", async () => {
|
||||
const company = deferred<any>();
|
||||
let companies = 0;
|
||||
mockedApi.post.mockImplementation((path: string) => {
|
||||
if (path === "/companies") {
|
||||
companies += 1;
|
||||
return companies === 1 ? company.promise : Promise.resolve({ data: { id: 32 } } as any);
|
||||
}
|
||||
if (path === "/jobapplications") return Promise.resolve({ data: { id: 100 + companies } } as any);
|
||||
return Promise.reject(new Error(`Unexpected POST ${path}`));
|
||||
});
|
||||
const mounted = renderSettings();
|
||||
await screen.findByRole("switch", { name: translations.en.settingsAiEnabled });
|
||||
await selectFile([
|
||||
{ jobTitle: "First preserved role", company: { name: "Ærlig AS" } },
|
||||
{ jobTitle: "Second preserved role", company: { name: "Neste AS" } },
|
||||
]);
|
||||
expect(mockedApi.post.mock.calls).toEqual([["/companies", { name: "Ærlig AS" }]]);
|
||||
expect(screen.getByRole("group", { name: translations.en.importExportTitle })).toHaveAttribute("aria-busy", "true");
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabNotifications }));
|
||||
expect(screen.getByRole("tabpanel", { name: translations.en.settingsTabNotifications })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: translations.en.importJson })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabFollowUps }));
|
||||
const rule = await screen.findByRole("spinbutton", { name: translations.en.rulesAppliedFollowUpDays });
|
||||
fireEvent.change(rule, { target: { value: "5" } });
|
||||
await act(async () => company.resolve({ data: { id: 31 } }));
|
||||
await waitFor(() => expect(mockedApi.post.mock.calls).toEqual([
|
||||
["/companies", { name: "Ærlig AS" }],
|
||||
["/jobapplications", { jobTitle: "First preserved role", companyId: 31 }],
|
||||
["/companies", { name: "Neste AS" }],
|
||||
["/jobapplications", { jobTitle: "Second preserved role", companyId: 32 }],
|
||||
]));
|
||||
expect(await screen.findByText(text("importedJobs", 2))).toBeVisible();
|
||||
expect(rule).toHaveValue(5);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabGeneral }));
|
||||
const group = screen.getByRole("group", { name: translations.en.importExportTitle });
|
||||
expect(within(group).getByRole("status")).toHaveTextContent(text("lastImport", 2));
|
||||
expect(within(group).getByRole("button", { name: translations.en.importJson })).toBeEnabled();
|
||||
expect(group).toHaveAttribute("aria-busy", "false");
|
||||
expect(reads("/ai/settings")).toHaveLength(1);
|
||||
expect(reads("/account-lifecycle/status")).toHaveLength(0);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
|
||||
const disposedCompany = deferred<any>(); mockedApi.post.mockReturnValueOnce(disposedCompany.promise);
|
||||
await selectFile([{ jobTitle: "Must stop after leaving Settings", company: { name: "Disposed AS" } }]);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(5);
|
||||
mounted.unmount();
|
||||
await act(async () => disposedCompany.resolve({ data: { id: 33 } }));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
test("[P7-09 lifetime] the General privacy owner keeps its draft and read across internal tabs while Backup remains lazy and disposable", async () => {
|
||||
const mounted = renderSettings();
|
||||
const privacy = await screen.findByRole("switch", { name: translations.en.settingsAiEnabled });
|
||||
expect(privacy).toBeChecked(); fireEvent.click(privacy); expect(privacy).not.toBeChecked();
|
||||
expect(reads("/ai/settings")).toHaveLength(1); expect(reads("/account-lifecycle/status")).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.settingsTabNotifications }));
|
||||
expect(privacy).toBeInTheDocument(); expect(privacy).not.toBeVisible();
|
||||
expect(privacy.closest('[role="tabpanel"]')).toHaveAttribute("hidden");
|
||||
expect(screen.queryByRole("switch", { name: translations.en.settingsAiEnabled })).not.toBeInTheDocument();
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabFollowUps }));
|
||||
await screen.findByRole("spinbutton", { name: translations.nb.rulesAppliedFollowUpDays });
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabGeneral }));
|
||||
expect(screen.getByRole("switch", { name: translations.nb.settingsAiEnabled })).not.toBeChecked();
|
||||
expect(screen.getByRole("switch", { name: translations.nb.settingsAiExternalAllowed })).toBeDisabled();
|
||||
expect(reads("/ai/settings")).toHaveLength(1); expect(reads("/account-lifecycle/status")).toHaveLength(0);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabBackup }));
|
||||
await screen.findByText(translations.nb.accountDeleteDisabled);
|
||||
expect(reads("/account-lifecycle/status")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabGeneral }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.settingsTabBackup }));
|
||||
await waitFor(() => expect(reads("/account-lifecycle/status")).toHaveLength(2));
|
||||
expect(reads("/ai/settings")).toHaveLength(1); expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
mounted.unmount(); renderSettings();
|
||||
expect(await screen.findByRole("switch", { name: translations.nb.settingsAiEnabled })).toBeChecked();
|
||||
expect(reads("/ai/settings")).toHaveLength(2);
|
||||
expect(reads("/account-lifecycle/status")).toHaveLength(2);
|
||||
});
|
||||
Reference in New Issue
Block a user