Files
jobtrackingapp/job-tracker-ui/e2e/phase7-job-import.spec.ts
T

160 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(); }
});