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

255 lines
20 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 };
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: { 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 bounds canonical last-writer saves and structured-history recovery without claiming stale-write protection', 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: { 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: { 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 = { profile: { ...staleSnapshot.profile, contact: { ...staleSnapshot.profile.contact, headline: 'Stale snapshot accepted as last writer' } }, cvText: currentRaw };
const staleWrite = await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: staleBody });
const staleStatus = staleWrite.status();
const lastWriter = await json<CareerReadback>(staleWrite);
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(200);
expect(staleBody).not.toHaveProperty('expectedVersion');
expect(afterStale).toEqual(lastWriter);
expect(afterStale.profile.contact.headline).toBe('Stale snapshot accepted as last writer');
expect(historyAfterStale.map(row => row.version)).toEqual([3, 2, 1]);
expect(historyMetadata(historyAfterStale.slice(1))).toEqual(historyMetadata(remoteHistory));
const restored = await json<CareerReadback>(await secondSession.post(`${apiUrl}/career/profile/versions/2/restore`, { 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(remote.profile);
expect(recovered).toEqual(restored);
expect(recovered.cvText).toBe(currentRaw);
expect(recovered.cvText).not.toBe(remoteRaw);
expect(restoredHistory.map(row => row.version)).toEqual([4, 3, 2, 1]);
expect(restoredHistory[0]).toMatchObject({ source: 'restore:v2', 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`, { 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`, { headers: otherOwner.headers })).status()).toBe(404);
await json<CareerReadback>(await other.put(`${apiUrl}/career/profile`, { headers: otherOwner.headers,
data: { 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 real authenticated HTTP sessions in disposable SQLite. Canonical stale saves remain accepted last-writer replacements; history recovery appends a structured revision and does not restore raw cvText. No conflict prevention, 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(); }
});