292 lines
21 KiB
TypeScript
292 lines
21 KiB
TypeScript
import { test as base, expect, type APIRequestContext, type APIResponse, type TestInfo } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
|
|
import { execFileSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { translations } from '../../../../../job-tracker-ui/src/i18n/translations';
|
|
import type { StructuredCvProfile } from '../../../../../job-tracker-ui/src/profileCv';
|
|
|
|
const api = 'http://localhost:5302/api';
|
|
const password = 'E2ePassword123!';
|
|
const t = translations.en;
|
|
type Career = { profile: StructuredCvProfile; revision: number; cvText: string | null };
|
|
type Job = { id: number; version: number; jobTitle: string; notes: string | null; [key: string]: unknown };
|
|
type Owner = { id: string; headers: Record<string, string> };
|
|
type DbRow = Record<string, unknown>;
|
|
type DbProof = { identity: DbRow; career: DbRow | null; careerHistory: DbRow[]; children: Record<string, DbRow[]>;
|
|
job: DbRow | null; opportunity: DbRow | null; events: DbRow[] };
|
|
|
|
async function json<T>(response: Pick<APIResponse, 'status' | 'url' | 'json'>, status = 200): Promise<T> {
|
|
expect(response.status(), `${response.url()} status`).toBe(status);
|
|
return response.json();
|
|
}
|
|
async function login(request: APIRequestContext, email: string): Promise<Owner> {
|
|
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
|
const me = await json<{ id: string; sessionContext: string }>(await request.get(`${api}/auth/me`));
|
|
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
|
|
expect(csrf).toBeDefined();
|
|
return { id: me.id, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
|
}
|
|
const test = base.extend<{ owner: Owner; second: { request: APIRequestContext; owner: Owner }; email: string }>({
|
|
email: async ({ playwright }, use, info) => {
|
|
const admin = await playwright.request.newContext();
|
|
try {
|
|
const adminOwner = await login(admin, 'e2e@example.test');
|
|
const email = `rd21-proof-${info.testId}-${Date.now()}@example.test`;
|
|
expect((await admin.post(`${api}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
|
await use(email);
|
|
} finally { await admin.dispose(); }
|
|
},
|
|
owner: async ({ page, context, email }, use) => {
|
|
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 page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
|
|
await use(await login(page.request, email));
|
|
},
|
|
second: async ({ playwright, email }, use) => {
|
|
const request = await playwright.request.newContext();
|
|
try { await use({ request, owner: await login(request, email) }); }
|
|
finally { await request.dispose(); }
|
|
},
|
|
});
|
|
|
|
function db(info: TestInfo, owner: Owner, jobId?: number): DbProof {
|
|
const dataRoot = info.config.metadata.isolatedDataRoot;
|
|
expect(typeof dataRoot, 'Only the isolated test database may be inspected').toBe('string');
|
|
const args = ['proxy', 'python', path.join(__dirname, 'readonly-db-proof.py'), path.join(dataRoot, 'jobtracker.db'), owner.id];
|
|
if (jobId !== undefined) args.push(String(jobId));
|
|
return JSON.parse(execFileSync('rtk', args, { encoding: 'utf8' }));
|
|
}
|
|
async function attach(info: TestInfo, name: string, body: unknown) {
|
|
await info.attach(name, { body: JSON.stringify(body, null, 2), contentType: 'application/json' });
|
|
}
|
|
async function seedCareer(request: APIRequestContext, owner: Owner) {
|
|
const absent = await json<Career>(await request.get(`${api}/career/profile`));
|
|
expect(absent.revision).toBe(0);
|
|
return json<Career>(await request.put(`${api}/career/profile`, { headers: owner.headers, data: { expectedVersion: 0,
|
|
profile: { version: '1', contact: { fullName: 'Synthetic concurrent owner', phone: '+47 900 00 001' }, summary: ['Initial summary'],
|
|
jobs: [{ id: 'retained-experience', title: 'Recorded role', company: 'Synthetic employer', bullets: ['Retain this reviewed fact.'] }],
|
|
skills: ['C#'] }, cvText: 'Original synthetic source retained through edits.' } }));
|
|
}
|
|
function assertCareer(proof: DbProof, expected: Career) {
|
|
expect(proof.career?.Version).toBe(expected.revision);
|
|
const stored = JSON.parse(proof.career!.ProfileJson as string);
|
|
expect(stored.summary).toEqual(expected.profile.summary);
|
|
expect(stored.contact).toMatchObject({ fullName: expected.profile.contact.fullName, phone: expected.profile.contact.phone });
|
|
expect(stored.jobs).toHaveLength(expected.profile.jobs.length);
|
|
expect(stored.jobs[0]).toMatchObject({ id: 'retained-experience', title: 'Recorded role', company: 'Synthetic employer', bullets: ['Retain this reviewed fact.'] });
|
|
expect(JSON.parse(proof.identity.ProfileCvStructureJson as string)).toEqual(stored);
|
|
expect(proof.identity.ProfileCvText).toBe(expected.cvText);
|
|
expect(proof.careerHistory).toHaveLength(expected.revision);
|
|
expect(proof.careerHistory.at(-1)?.Version).toBe(expected.revision);
|
|
expect(JSON.parse(proof.careerHistory.at(-1)!.ProfileJson as string)).toEqual(stored);
|
|
expect(proof.children.CareerExperiences).toEqual([expect.objectContaining({ ItemKey: 'retained-experience', Title: 'Recorded role', OwnerUserId: proof.identity.Id })]);
|
|
}
|
|
async function createJob(request: APIRequestContext, owner: Owner) {
|
|
const company = await json<{ id: number }>(await request.post(`${api}/companies`, { headers: owner.headers, data: { name: 'Synthetic stale job fixture' } }), 201);
|
|
return json<Job>(await request.post(`${api}/jobapplications`, { headers: owner.headers,
|
|
data: { companyId: company.id, jobTitle: 'Original job title', status: 'Saved', notes: 'Retain original notes', location: 'Trondheim' } }), 201);
|
|
}
|
|
|
|
test('RD21 cross-origin browser exposes committed revisions and native Answers saves advance consecutively', async ({ page, owner }, info) => {
|
|
const job = await createJob(page.request, owner);
|
|
await page.goto(`/jobs/${job.id}?section=application&sub=answers`);
|
|
const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true });
|
|
await expect(answer).toBeEditable();
|
|
const browserCommit = await page.evaluate(async ({ api, job, headers }) => {
|
|
const response = await fetch(`${api}/jobapplications/${job.id}/application-drafts`, { method: 'PUT', credentials: 'include',
|
|
headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ applicationAnswerDraft: 'Browser-visible commit', recruiterMessageDraft: '', expectedVersion: job.version }) });
|
|
return { status: response.status, revision: response.headers.get('X-Job-Version') };
|
|
}, { api, job, headers: owner.headers });
|
|
expect(browserCommit.status).toBe(204);
|
|
expect(db(info, owner, job.id).job).toMatchObject({ Version: job.version + 1, Notes: expect.stringContaining('<<<APPLICATION_ANSWER_DRAFT>>>\nBrowser-visible commit\n<<<END_APPLICATION_ANSWER_DRAFT>>>') });
|
|
expect(browserCommit.revision).toBe(String(job.version + 1));
|
|
await page.reload();
|
|
await expect(answer).toHaveValue('Browser-visible commit');
|
|
for (const [index, text] of ['First native answer', 'Second native answer'].entries()) {
|
|
await answer.fill(text);
|
|
const responsePromise = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/application-drafts`) && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click();
|
|
const response = await responsePromise;
|
|
expect(response.status()).toBe(204);
|
|
expect(Number(response.headers()['x-job-version'])).toBe(job.version + index + 2);
|
|
await expect(page.getByRole('button', { name: t.assetsSaveDrafts, exact: true })).toBeDisabled();
|
|
await expect(page.getByText('Missing committed job revision.', { exact: true })).toHaveCount(0);
|
|
expect(db(info, owner, job.id).job).toMatchObject({ Version: job.version + index + 2, Notes: expect.stringContaining(`<<<APPLICATION_ANSWER_DRAFT>>>\n${text}\n<<<END_APPLICATION_ANSWER_DRAFT>>>`) });
|
|
}
|
|
await attach(info, 'rd21-cross-origin-native-answers', { browserCommit, database: db(info, owner, job.id) });
|
|
});
|
|
|
|
test('RD21 cover-letter save then Answers entry waits for fresh coherent source before a native save', async ({ page, owner }, info) => {
|
|
const job = await createJob(page.request, owner);
|
|
await page.goto(`/jobs/${job.id}?section=application&sub=letter`);
|
|
const letter = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
|
|
await expect(letter).toBeEditable();
|
|
await letter.fill('Acknowledged cover letter retained');
|
|
const saved = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/cover-letter`) && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.save, exact: true }).click();
|
|
const letterResponse = await saved;
|
|
expect(letterResponse.status()).toBe(200);
|
|
const letterRevision = (await letterResponse.json()).revision;
|
|
await expect(page.getByRole('button', { name: t.save, exact: true })).toBeDisabled();
|
|
expect(db(info, owner, job.id).job).toMatchObject({ Version: letterRevision, CoverLetterText: 'Acknowledged cover letter retained' });
|
|
let release!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
let started!: () => void;
|
|
const requested = new Promise<void>(resolve => { started = resolve; });
|
|
await page.route(`**/api/jobapplications/${job.id}/workspace`, async route => { started(); await gate; await route.continue(); });
|
|
await page.getByRole('tab', { name: t.workspaceAnswers, exact: true }).click();
|
|
await expect(page.getByRole('tab', { name: t.workspaceAnswers, exact: true })).toHaveAttribute('aria-selected', 'true');
|
|
const answer = page.getByRole('textbox', { name: t.assetsApplicationAnswer, exact: true });
|
|
try {
|
|
await expect(answer).toHaveCount(0);
|
|
await requested;
|
|
} finally { release(); }
|
|
await expect(answer).toBeEditable();
|
|
await answer.fill('Answer after cover letter');
|
|
const answerSaved = page.waitForResponse(response => response.url().endsWith(`/jobapplications/${job.id}/application-drafts`) && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.assetsSaveDrafts, exact: true }).click();
|
|
const response = await answerSaved;
|
|
expect(response.request().postDataJSON().expectedVersion).toBe(letterRevision);
|
|
expect(response.status()).toBe(204);
|
|
await expect(page.getByRole('button', { name: t.assetsSaveDrafts, exact: true })).toBeDisabled();
|
|
expect(db(info, owner, job.id).job).toMatchObject({ Version: letterRevision + 1, CoverLetterText: 'Acknowledged cover letter retained', Notes: expect.stringContaining('<<<APPLICATION_ANSWER_DRAFT>>>\nAnswer after cover letter\n<<<END_APPLICATION_ANSWER_DRAFT>>>') });
|
|
await attach(info, 'rd21-cover-letter-answers-entry', { letterRevision, committedRevision: response.headers()['x-job-version'], database: db(info, owner, job.id) });
|
|
});
|
|
|
|
test('RD21 original two-session ordering rejects stale snapshots and preserves each acknowledged SQLite commit across navigation and a fresh session', async ({ page, owner, second, playwright, email }, info) => {
|
|
await seedCareer(page.request, owner);
|
|
const careerA = await json<Career>(await page.request.get(`${api}/career/profile`));
|
|
const careerB = await json<Career>(await second.request.get(`${api}/career/profile`));
|
|
careerA.profile.summary = ['First editor committed summary'];
|
|
careerB.profile.contact.headline = 'Second editor headline';
|
|
const acknowledged = await json<Career>(await page.request.put(`${api}/career/profile`, { headers: owner.headers,
|
|
data: { profile: careerA.profile, expectedVersion: careerA.revision } }));
|
|
const immediateCareer = db(info, owner);
|
|
assertCareer(immediateCareer, acknowledged);
|
|
const staleCareer = await second.request.put(`${api}/career/profile`, { headers: second.owner.headers,
|
|
data: { profile: careerB.profile, expectedVersion: careerB.revision } });
|
|
expect(staleCareer.status()).toBe(409);
|
|
expect(await staleCareer.json()).toMatchObject({ code: 'revision_conflict' });
|
|
expect(db(info, owner)).toEqual(immediateCareer);
|
|
expect((await json<Career>(await page.request.get(`${api}/career/profile`))).profile).toEqual(acknowledged.profile);
|
|
|
|
const created = await createJob(page.request, owner);
|
|
const jobA = await json<Job>(await page.request.get(`${api}/jobapplications/${created.id}`));
|
|
const jobB = await json<Job>(await second.request.get(`${api}/jobapplications/${created.id}`));
|
|
jobA.jobTitle = 'First editor committed title'; jobB.notes = 'Second editor notes';
|
|
const committed = await page.request.put(`${api}/jobapplications/${created.id}`, { headers: owner.headers, data: { ...jobA, expectedVersion: jobA.version } });
|
|
expect(committed.status()).toBe(204);
|
|
const committedRevision = Number(committed.headers()['x-job-version']);
|
|
expect(committedRevision).toBe(jobA.version + 1);
|
|
const immediateJob = db(info, owner, created.id);
|
|
assertCareer(immediateJob, acknowledged);
|
|
expect(immediateJob.job).toMatchObject({ Id: created.id, OwnerUserId: owner.id, Version: committedRevision,
|
|
JobTitle: jobA.jobTitle, Notes: 'Retain original notes', Location: 'Trondheim', IsDeleted: 0 });
|
|
expect(immediateJob.opportunity).toMatchObject({ Id: immediateJob.job!.JobId, OwnerUserId: owner.id,
|
|
JobTitle: jobA.jobTitle, Location: 'Trondheim', CompanyId: immediateJob.job!.CompanyId });
|
|
expect(immediateJob.events.length).toBeGreaterThan(0);
|
|
const staleJob = await second.request.put(`${api}/jobapplications/${created.id}`, { headers: second.owner.headers,
|
|
data: { ...jobB, expectedVersion: jobB.version } });
|
|
expect(staleJob.status()).toBe(409);
|
|
expect(await staleJob.json()).toMatchObject({ code: 'revision_conflict' });
|
|
expect(db(info, owner, created.id)).toEqual(immediateJob);
|
|
|
|
await page.goto(`/jobs/${created.id}?section=overview`);
|
|
await expect(page.getByRole('main')).toContainText(jobA.jobTitle);
|
|
expect(db(info, owner, created.id)).toEqual(immediateJob);
|
|
await page.reload();
|
|
await expect(page.getByRole('main')).toContainText(jobA.jobTitle);
|
|
expect(db(info, owner, created.id)).toEqual(immediateJob);
|
|
await page.goto('/career?section=profile');
|
|
await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(acknowledged.profile.summary[0]);
|
|
expect(db(info, owner, created.id)).toEqual(immediateJob);
|
|
await page.reload();
|
|
await expect(page.getByLabel(t.profileCvStructuredSummary, { exact: true })).toHaveValue(acknowledged.profile.summary[0]);
|
|
expect(db(info, owner, created.id)).toEqual(immediateJob);
|
|
const fresh = await playwright.request.newContext();
|
|
try {
|
|
await login(fresh, email);
|
|
expect((await json<Career>(await fresh.get(`${api}/career/profile`))).profile).toEqual(acknowledged.profile);
|
|
expect(await json<Job>(await fresh.get(`${api}/jobapplications/${created.id}`))).toMatchObject({ jobTitle: jobA.jobTitle, version: committedRevision, notes: 'Retain original notes' });
|
|
expect(db(info, owner, created.id)).toEqual(immediateJob);
|
|
} finally { await fresh.dispose(); }
|
|
await attach(info, 'rd21-original-ordering-committed-database', { careerAcknowledgement: acknowledged, careerStaleStatus: staleCareer.status(),
|
|
immediateCareer, jobAcknowledgedStatus: committed.status(), committedRevision, jobStaleStatus: staleJob.status(), finalDatabase: immediateJob,
|
|
checkpoints: ['immediate Career acknowledgement', 'Career stale rejection', 'immediate job acknowledgement', 'job stale rejection', 'navigation', 'job refresh', 'Career navigation', 'Career refresh', 'new authenticated session'] });
|
|
});
|
|
|
|
test('RD21 missing revisions fail closed without changing canonical rows, Identity projection, job opportunity or history', async ({ page, owner }, info) => {
|
|
const career = await seedCareer(page.request, owner);
|
|
const job = await createJob(page.request, owner);
|
|
const before = db(info, owner, job.id);
|
|
const profile = structuredClone(career.profile); profile.summary = ['Unversioned replacement must not persist'];
|
|
const missingCareer = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { profile } });
|
|
expect(missingCareer.status()).toBe(400);
|
|
expect(db(info, owner, job.id)).toEqual(before);
|
|
const negativeCareer = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { profile, expectedVersion: -1 } });
|
|
expect(negativeCareer.status()).toBe(400);
|
|
expect(db(info, owner, job.id)).toEqual(before);
|
|
const missingJob = await page.request.put(`${api}/jobapplications/${job.id}`, { headers: owner.headers,
|
|
data: { ...job, jobTitle: 'Unversioned job replacement must not persist' } });
|
|
expect(missingJob.status()).toBe(409);
|
|
expect(db(info, owner, job.id)).toEqual(before);
|
|
await attach(info, 'rd21-missing-preconditions-database', { statuses: { career: missingCareer.status(), negativeCareer: negativeCareer.status(), job: missingJob.status() }, before, after: db(info, owner, job.id) });
|
|
});
|
|
|
|
test('RD21 native editor retains rejected and network-failed drafts until deliberate review and successful retry', async ({ page, owner, second }, info) => {
|
|
await seedCareer(page.request, owner);
|
|
await page.goto('/career?section=profile');
|
|
const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true });
|
|
await expect(summary).toHaveValue('Initial summary');
|
|
const localDraft = 'Retained draft reviewed after conflict';
|
|
await summary.fill(localDraft);
|
|
const remote = await json<Career>(await second.request.get(`${api}/career/profile`));
|
|
remote.profile.contact.phone = '+47 900 00 077';
|
|
const acknowledged = await json<Career>(await second.request.put(`${api}/career/profile`, { headers: second.owner.headers,
|
|
data: { profile: remote.profile, expectedVersion: remote.revision } }));
|
|
const beforeConflict = db(info, owner); assertCareer(beforeConflict, acknowledged);
|
|
const rejected = page.waitForResponse(response => response.url() === `${api}/career/profile` && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
|
|
expect((await rejected).status()).toBe(409);
|
|
await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.revisionConflict })).toBeVisible();
|
|
await expect(summary).toHaveValue(localDraft);
|
|
expect(db(info, owner)).toEqual(beforeConflict);
|
|
|
|
// The explicit reload discards this editor snapshot; the retained draft is then reviewed against the new contact.
|
|
await page.reload();
|
|
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 077');
|
|
await expect(summary).toHaveValue('Initial summary');
|
|
await summary.fill(localDraft);
|
|
const failedBodies: unknown[] = [];
|
|
await page.route('**/api/career/profile', async route => {
|
|
if (route.request().method() !== 'PUT') return route.continue();
|
|
failedBodies.push(route.request().postDataJSON());
|
|
return route.abort('failed');
|
|
});
|
|
const networkFailed = page.waitForEvent('requestfailed', request => request.url() === `${api}/career/profile` && request.method() === 'PUT');
|
|
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
|
|
await networkFailed;
|
|
await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.profileUpdateFailed })).toBeVisible();
|
|
await expect(summary).toHaveValue(localDraft);
|
|
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
|
|
expect(failedBodies).toHaveLength(1);
|
|
expect(failedBodies[0]).toMatchObject({ expectedVersion: acknowledged.revision, profile: { summary: [localDraft], contact: { phone: '+47 900 00 077' } } });
|
|
expect(db(info, owner)).toEqual(beforeConflict);
|
|
await page.unroute('**/api/career/profile');
|
|
const retried = page.waitForResponse(response => response.url() === `${api}/career/profile` && response.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
|
|
const retryResponse = await retried;
|
|
const recovered = await json<Career>(retryResponse);
|
|
const immediateRecovery = db(info, owner); assertCareer(immediateRecovery, recovered);
|
|
expect(recovered.revision).toBe(acknowledged.revision + 1);
|
|
expect(recovered.profile.summary).toEqual([localDraft]);
|
|
expect(recovered.profile.contact.phone).toBe('+47 900 00 077');
|
|
await page.reload();
|
|
await expect(summary).toHaveValue(localDraft);
|
|
expect(db(info, owner)).toEqual(immediateRecovery);
|
|
await attach(info, 'rd21-recoverable-native-editor', { rejectedStatus: 409, beforeConflict, failedBodies, recovered, immediateRecovery,
|
|
qualification: 'Real stale HTTP conflict and real successful SQLite commits; network failure is a controlled browser request abort.' });
|
|
});
|