255 lines
20 KiB
TypeScript
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(); }
|
|
});
|