Files
jobtrackingapp/job-tracker-ui/e2e/phase7-career-source-ids.spec.ts
T

255 lines
21 KiB
TypeScript

import { expect, test as base, type APIRequestContext, type APIResponse } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import type { CvVariant } from '../src/cvBuilder';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; email: string; headers: Record<string, string> };
type SourceItem = { id?: string; [key: string]: unknown };
type CareerProfile = Record<string, unknown> & {
contact: Record<string, unknown>;
jobs: SourceItem[];
education: SourceItem[];
projects: SourceItem[];
certifications: SourceItem[];
};
type CareerReadback = { profile: CareerProfile; completeness: unknown; cvText: string | null; revision: number };
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
type Preview = { themeId: string; html: string; suggestedFileName: string };
async function json<T>(response: APIResponse, status = 200): Promise<T> {
expect(response.status(), `${response.url()} must return ${status}`).toBe(status);
return response.json();
}
async function login(api: APIRequestContext, email: string): Promise<Owner> {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
return { id: me.id, email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
}
async function createOwner(admin: APIRequestContext, headers: Record<string, string>) {
const email = `p7-career-ids-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
return email;
}
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminOwner = await login(admin, 'e2e@example.test');
const owner = await login(page.request, await createOwner(admin, adminOwner.headers));
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: 'en' } })).status()).toBe(204);
await context.route('**/*', route => {
const url = new URL(route.request().url());
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
? route.continue() : route.abort();
});
await context.addInitScript(() => {
localStorage.setItem('uiLanguage', 'en');
localStorage.setItem('jobtracker.themeMode', 'light');
});
await provide(owner);
} finally { await admin.dispose(); }
},
});
function sourceIds(profile: CareerProfile) {
return Object.fromEntries((['jobs', 'education', 'projects', 'certifications'] as const)
.map(key => [key, profile[key].map(item => item.id)]));
}
function historyMetadata(versions: CareerVersion[]) {
return versions.map(({ version, source, createdAtUtc }) => ({ version, source, createdAtUtc }));
}
test('[P7-03 PRES-C01 C03 C06 C07 C10 W05 W06] unrelated canonical UI save preserves four source IDs, existing CV overrides, sibling and frozen package', async ({ page, owner }, info) => {
test.setTimeout(120_000);
const raw = 'Exact owner source — æøå\nReviewed work and qualifications.\n';
const profile = {
version: '1',
metadata: { fields: { 'contact.fullName': { confidence: 0.9, method: 'manual', sourceSnippet: 'Owner-reviewed contact', reviewState: 'reviewed' } } },
contact: { fullName: 'Åse Source-ID', headline: 'Canonical engineer', phone: '+47 900 00 001', location: 'Oslo, Norge',
website: 'https://example.test/portfolio', linkedIn: 'https://www.linkedin.com/in/source-fixture', gitHub: 'https://github.com/source-fixture',
links: [{ label: 'Reviewed work', url: 'https://example.test/work' }] },
summary: ['A source-backed professional summary.'],
jobs: [{ id: 'job-source', title: 'Canonical role', company: 'Nordlys AS', start: 'Jan 2020', end: 'Dec 2022',
bullets: ['Canonical work evidence.'], skills: ['C#'] }],
education: [{ id: 'education-source', qualification: 'Computer science', qualificationLevel: 'Bachelor', institution: 'Fixture University',
location: 'Oslo', start: 'Aug 2016', end: 'Jun 2019', details: ['Reviewed qualification.'] }],
projects: [{ id: 'project-source', name: 'Recorded project', role: 'Developer', location: 'Remote', start: '2023-01', end: '2023-12',
bullets: ['Reviewed project evidence.'], skills: ['React'] }],
certifications: [{ id: 'cert-source', name: 'Recorded certification', issuer: 'Fixture Academy', location: 'Remote', date: 'Jun 2022',
details: ['Reviewed certification detail.'] }],
skills: ['C#', 'Norsk'], languages: [{ name: 'Norwegian Sign Language', level: 'Professional working proficiency', notes: 'Reviewed language note.' }],
interests: ['Documented community work'], awards: ['Reviewed award'], publications: ['Reviewed publication'],
organisations: ['Reviewed organisation'], references: ['Available by owner agreement'],
otherSections: [{ title: 'Custom factual section', items: ['Recorded custom fact.', 'Another reviewed custom fact.'] }],
};
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile, cvText: raw } }));
const before = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
expect(sourceIds(before.profile)).toEqual({ jobs: ['job-source'], education: ['education-source'], projects: ['project-source'], certifications: ['cert-source'] });
const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Source-ID fixture AS' } }), 201);
const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
data: { companyId: company.id, jobTitle: 'Source-ID preservation', status: 'Saved' } }), 201);
const retainedTitle = 'CV-only retained role';
const retainedBullet = 'CV-only retained evidence';
let selected = await json<CvVariant>(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Selected source-ID CV', jobApplicationId: job.id, settings: { themeId: 'minimal',
overrides: { [before.profile.jobs[0].id!]: { title: retainedTitle, bullets: [retainedBullet] } } } } }));
const sibling = await json<CvVariant>(await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Untouched source-ID sibling', settings: { themeId: 'minimal' } } }));
const selectedHistoryBefore = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/versions`));
const siblingHistoryBefore = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/versions`));
const selectedPreviewBefore = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/preview`));
const siblingPreviewBefore = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/preview`));
expect(selectedPreviewBefore.html).toContain(retainedTitle);
expect(selectedPreviewBefore.html).toContain(retainedBullet);
expect(siblingPreviewBefore.html).toContain('Canonical role');
expect((await page.request.put(`${apiUrl}/jobapplications/${job.id}/cv`, { headers: owner.headers, data: { variantId: selected.id } })).status()).toBe(200);
const packageSummary = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications/${job.id}/submitted-packages`, { headers: owner.headers }));
const packageUrl = `${apiUrl}/jobapplications/${job.id}/submitted-packages/${packageSummary.id}`;
const frozenBefore = await json<unknown>(await page.request.get(packageUrl));
// Linking the selected CV updates its timestamp during setup. The preservation
// baseline must include that setup mutation before the unrelated career edit.
selected = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}`));
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/career?section=profile');
const t = translations.en;
const phone = page.getByLabel(t.profileCvContactPhone, { exact: true });
await expect(phone).toHaveValue(String(before.profile.contact.phone));
await phone.fill('+47 900 00 002');
const requestPromise = page.waitForRequest(request => request.url() === `${apiUrl}/career/profile` && request.method() === 'PUT');
const responsePromise = page.waitForResponse(response => response.url() === `${apiUrl}/career/profile` && response.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).focus();
await page.keyboard.press('Enter');
const savedRequest = await requestPromise;
const savedResponse = await responsePromise;
const savedStatus = savedResponse.status();
const requested = savedRequest.postDataJSON() as { profile: CareerProfile; cvText: string };
const after = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const selectedAfter = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}`));
const siblingAfter = await json<CvVariant>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`));
const selectedHistoryAfter = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/versions`));
const siblingHistoryAfter = await json<unknown>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/versions`));
const selectedPreviewAfter = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${selected.id}/preview`));
const siblingPreviewAfter = await json<Preview>(await page.request.get(`${apiUrl}/cv/variants/${sibling.id}/preview`));
const frozenAfter = await json<unknown>(await page.request.get(packageUrl));
// Collect the persisted IDs AND effective override output before the first preservation
// assertion, so the RED attempt retains both consequences of the existing browser save.
await info.attach('native-career-source-id-readbacks', { body: JSON.stringify({ savedStatus, requested,
ids: { before: sourceIds(before.profile), requested: sourceIds(requested.profile), after: sourceIds(after.profile) },
observed: {
requestedIdsEqual: JSON.stringify(sourceIds(requested.profile)) === JSON.stringify(sourceIds(before.profile)),
persistedIdsEqual: JSON.stringify(sourceIds(after.profile)) === JSON.stringify(sourceIds(before.profile)),
overrideTitleBefore: selectedPreviewBefore.html.includes(retainedTitle), overrideTitleAfter: selectedPreviewAfter.html.includes(retainedTitle),
overrideBulletBefore: selectedPreviewBefore.html.includes(retainedBullet), overrideBulletAfter: selectedPreviewAfter.html.includes(retainedBullet),
selectedDtoUnchanged: JSON.stringify(selectedAfter) === JSON.stringify(selected), siblingDtoUnchanged: JSON.stringify(siblingAfter) === JSON.stringify(sibling),
selectedHistoryUnchanged: JSON.stringify(selectedHistoryAfter) === JSON.stringify(selectedHistoryBefore),
siblingHistoryUnchanged: JSON.stringify(siblingHistoryAfter) === JSON.stringify(siblingHistoryBefore),
frozenDetailUnchanged: JSON.stringify(frozenAfter) === JSON.stringify(frozenBefore),
},
canonical: { before, after }, selected: { before: selected, after: selectedAfter, historyBefore: selectedHistoryBefore, historyAfter: selectedHistoryAfter,
previewBefore: selectedPreviewBefore, previewAfter: selectedPreviewAfter },
sibling: { before: sibling, after: siblingAfter, historyBefore: siblingHistoryBefore, historyAfter: siblingHistoryAfter,
previewBefore: siblingPreviewBefore, previewAfter: siblingPreviewAfter }, frozen: { before: frozenBefore, after: frozenAfter },
qualification: 'Actual canonical browser Save and local SQLite HTTP readbacks. No PDF/model/provider call; frozen full-detail/HTML proof only, no new attachment-byte proof or concurrency-protection claim.' }), contentType: 'application/json' });
await info.attach('native-career-source-id-editor', { body: await page.screenshot({ fullPage: true }), contentType: 'image/png' });
expect(savedStatus).toBe(200);
expect(sourceIds(requested.profile)).toEqual(sourceIds(before.profile));
expect(sourceIds(after.profile)).toEqual(sourceIds(before.profile));
const { contact: beforeContact, ...nonContactBefore } = before.profile;
const { contact: afterContact, ...nonContactAfter } = after.profile;
expect(nonContactAfter).toEqual(nonContactBefore);
expect(afterContact).toEqual({ ...beforeContact, phone: '+47 900 00 002' });
expect(requested.cvText).toBe(raw);
expect(after.cvText).toBe(raw);
expect(selectedAfter).toEqual(selected);
expect(siblingAfter).toEqual(sibling);
expect(selectedHistoryAfter).toEqual(selectedHistoryBefore);
expect(siblingHistoryAfter).toEqual(siblingHistoryBefore);
expect(selectedPreviewAfter.html).toContain(retainedTitle);
expect(selectedPreviewAfter.html).toContain(retainedBullet);
expect(siblingPreviewAfter.html).toContain('Canonical role');
expect(siblingPreviewAfter.html).not.toContain(retainedTitle);
expect(frozenAfter).toEqual(frozenBefore);
await page.reload();
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 002');
});
test('[P7-03 PRES-C01 C03 E05 E09] real HTTP rejects a stale canonical snapshot and preserves structured-history recovery', async ({ page, owner, playwright }, info) => {
test.setTimeout(90_000);
const initialProfile = { version: '1', contact: { fullName: 'Canonical recovery owner', headline: 'Initial source' },
jobs: [{ id: 'recovery-source', title: 'Recorded role', company: 'Fixture AS', bullets: ['Recorded evidence.'] }], skills: ['Norsk'] };
const initialRaw = 'Initial raw source — æøå\n';
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: initialProfile, cvText: initialRaw } }));
const staleSnapshot = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const initialHistory = await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
expect(initialHistory.map(row => row.version)).toEqual([1]);
const secondSession = await playwright.request.newContext();
const admin = await playwright.request.newContext();
const other = await playwright.request.newContext();
const anonymous = await playwright.request.newContext();
try {
const secondOwner = await login(secondSession, owner.email);
expect(secondOwner.id).toBe(owner.id);
const secondSnapshot = await json<CareerReadback>(await secondSession.get(`${apiUrl}/career/profile`));
expect(secondSnapshot).toEqual(staleSnapshot);
const remoteRaw = 'Remote current raw source — æøå\n';
const remote = await json<CareerReadback>(await secondSession.put(`${apiUrl}/career/profile`, { headers: secondOwner.headers,
data: { expectedVersion: secondSnapshot.revision, profile: { ...secondSnapshot.profile, contact: { ...secondSnapshot.profile.contact, headline: 'Remote reviewed source' } }, cvText: remoteRaw } }));
const remoteHistory = await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
expect(remoteHistory.map(row => row.version)).toEqual([2, 1]);
const currentRaw = 'Last writer raw source remains current after structured restore — æøå\n';
const staleBody = { expectedVersion: staleSnapshot.revision, profile: { ...staleSnapshot.profile, contact: { ...staleSnapshot.profile.contact, headline: 'Stale snapshot rejected' } }, cvText: currentRaw };
const staleWrite = await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: staleBody });
const staleStatus = staleWrite.status();
expect(await staleWrite.json()).toMatchObject({ code: 'revision_conflict' });
const afterStale = await json<CareerReadback>(await secondSession.get(`${apiUrl}/career/profile`));
const historyAfterStale = await json<CareerVersion[]>(await secondSession.get(`${apiUrl}/career/profile/versions`));
expect(staleStatus).toBe(409);
expect(staleBody.expectedVersion).toBe(staleSnapshot.revision);
expect(afterStale).toEqual(remote);
expect(afterStale.profile.contact.headline).toBe('Remote reviewed source');
expect(historyAfterStale.map(row => row.version)).toEqual([2, 1]);
expect(historyMetadata(historyAfterStale)).toEqual(historyMetadata(remoteHistory));
const restored = await json<CareerReadback>(await secondSession.post(`${apiUrl}/career/profile/versions/1/restore`, { data: { expectedVersion: afterStale.revision }, headers: secondOwner.headers }));
const recovered = await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`));
const restoredHistory = await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`));
expect(restored.profile).toEqual(staleSnapshot.profile);
expect(recovered).toEqual(restored);
expect(recovered.cvText).toBe(remoteRaw);
expect(recovered.cvText).not.toBe(currentRaw);
expect(restoredHistory.map(row => row.version)).toEqual([3, 2, 1]);
expect(restoredHistory[0]).toMatchObject({ source: 'restore:v1', isCurrent: true });
expect(restoredHistory.slice(1).every(row => !row.isCurrent)).toBe(true);
expect(historyMetadata(restoredHistory.slice(1))).toEqual(historyMetadata(historyAfterStale));
expect((await page.request.post(`${apiUrl}/career/profile/versions/99999/restore`, { data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision }, headers: owner.headers })).status()).toBe(404);
const adminOwner = await login(admin, 'e2e@example.test');
const otherOwner = await login(other, await createOwner(admin, adminOwner.headers));
const otherBefore = await json<CareerReadback>(await other.get(`${apiUrl}/career/profile`));
expect(await json<CareerReadback>(await other.get(`${apiUrl}/career/profile?ownerUserId=${encodeURIComponent(owner.id)}`))).toEqual(otherBefore);
expect(await json<CareerVersion[]>(await other.get(`${apiUrl}/career/profile/versions?ownerUserId=${encodeURIComponent(owner.id)}`))).toEqual([]);
expect((await other.post(`${apiUrl}/career/profile/versions/2/restore`, { data: { expectedVersion: (await (await other.get(`${apiUrl}/career/profile`)).json()).revision }, headers: otherOwner.headers })).status()).toBe(404);
await json<CareerReadback>(await other.put(`${apiUrl}/career/profile`, { headers: otherOwner.headers,
data: { expectedVersion: (await (await other.get(`${apiUrl}/career/profile`)).json()).revision, ownerUserId: owner.id, profile: { version: '1', contact: { fullName: 'Other account only' }, skills: ['Other owner fact'] }, cvText: 'Other owner raw source' } }));
expect((await json<CareerReadback>(await other.get(`${apiUrl}/career/profile`))).profile.contact.fullName).toBe('Other account only');
expect((await anonymous.get(`${apiUrl}/career/profile`)).status()).toBe(401);
expect((await anonymous.get(`${apiUrl}/career/profile/versions`)).status()).toBe(401);
expect((await anonymous.put(`${apiUrl}/career/profile`, { data: staleBody })).status()).toBe(401);
expect((await anonymous.post(`${apiUrl}/career/profile/versions/2/restore`)).status()).toBe(401);
expect(await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`))).toEqual(recovered);
expect(await json<CareerVersion[]>(await page.request.get(`${apiUrl}/career/profile/versions`))).toEqual(restoredHistory);
await info.attach('native-canonical-stale-contract-characterization', { body: JSON.stringify({ initial: staleSnapshot, remote,
staleRequest: staleBody, staleStatus, lastWriter: afterStale, historyAfterStale, recovered, restoredHistory,
ownerIsolation: { forgedOwnerQueryCannotReadForeign: true, foreignStructuredVersion404: true, forgedBodyWritesOnlyOwnProfile: true, anonymous401: true },
qualification: 'Two authenticated HTTP sessions with source revisions. Stale replacement is rejected and the winner remains unchanged. Structured restore appends history while preserving current raw cvText. Historical unguarded characterization is retained in the frozen Phase 9 diagnostics. No browser draft-recovery, attachment-byte, model/provider or production acceptance is claimed.' }), contentType: 'application/json' });
} finally { await secondSession.dispose(); await admin.dispose(); await other.dispose(); await anonymous.dispose(); }
});