160 lines
33 KiB
TypeScript
160 lines
33 KiB
TypeScript
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(); }
|
||
});
|