fix(rd21): retain draft revisions and qualify conflict recovery

This commit is contained in:
cesnimda
2026-10-23 12:34:34 +02:00
parent 12f342c09c
commit 05a738693e
53 changed files with 813 additions and 192 deletions
@@ -0,0 +1,225 @@
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 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.' });
});
@@ -0,0 +1,9 @@
import { defineConfig } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
import base from '../../../../../job-tracker-ui/playwright.config';
import path from 'node:path';
if (!base.metadata?.isolatedDataRoot) throw new Error('RD21 persistence proof requires fresh isolated servers; unset PLAYWRIGHT_REUSE_SERVERS.');
const cwd = path.resolve(__dirname, '../../../../../job-tracker-ui');
const servers = Array.isArray(base.webServer) ? base.webServer : [base.webServer!];
export default defineConfig({ ...base, testDir: __dirname, testMatch: '*.spec.ts', retries: 0,
webServer: servers.map(server => ({ ...server, cwd })) });
@@ -0,0 +1,106 @@
"""Read only the explicitly named synthetic owner's committed SQLite rows."""
import json
from contextlib import closing
from pathlib import Path
import sqlite3
import sys
import tempfile
CHILDREN = ("CareerExperiences", "CareerEducations", "CareerSkills", "CareerProjects",
"CareerCertifications", "CareerLanguages")
def connect_readonly(filename):
path = Path(filename).resolve(strict=True)
connection = sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
return connection
def snapshot(connection, owner, job_id=None):
def rows(sql, parameters):
return [dict(row) for row in connection.execute(sql, parameters)]
identities = rows('SELECT "Id", "ProfileCvText", "ProfileCvStructureJson", '
'"CurrentCvProfileVersion" FROM "AspNetUsers" WHERE "Id" = ?', (owner,))
if len(identities) != 1:
raise ValueError("Expected exactly one explicitly owned synthetic Identity row")
careers = rows('SELECT * FROM "CareerProfiles" WHERE "OwnerUserId" = ?', (owner,))
if len(careers) > 1:
raise ValueError("More than one canonical Career Profile for owner")
result = {
"identity": identities[0],
"career": careers[0] if careers else None,
"careerHistory": rows('SELECT * FROM "CareerProfileVersions" WHERE "OwnerUserId" = ? ORDER BY "Version", "Id"', (owner,)),
"children": {table: rows(f'SELECT * FROM "{table}" WHERE "OwnerUserId" = ? ORDER BY "SortOrder", "Id"', (owner,)) for table in CHILDREN},
"job": None, "opportunity": None, "events": [],
}
if job_id is not None:
jobs = rows('SELECT * FROM "JobApplications" WHERE "OwnerUserId" = ? AND "Id" = ?', (owner, job_id))
if len(jobs) != 1:
raise ValueError("Expected exactly one explicitly owned synthetic job")
result["job"] = jobs[0]
opportunities = rows('SELECT * FROM "Jobs" WHERE "OwnerUserId" = ? AND "Id" = ?', (owner, jobs[0]["JobId"]))
if len(opportunities) != 1:
raise ValueError("Expected the job's explicitly owned opportunity")
result["opportunity"] = opportunities[0]
result["events"] = rows('SELECT e.* FROM "JobEvents" e JOIN "JobApplications" j ON e."JobApplicationId" = j."Id" '
'WHERE j."OwnerUserId" = ? AND j."Id" = ? ORDER BY e."Id"', (owner, job_id))
return result
def self_test():
with tempfile.TemporaryDirectory(prefix="rd21-readonly-proof-") as directory:
filename = Path(directory) / "jobtracker.db"
with closing(sqlite3.connect(filename)) as writer, writer:
writer.executescript('''
CREATE TABLE AspNetUsers (Id TEXT, ProfileCvText TEXT, ProfileCvStructureJson TEXT, CurrentCvProfileVersion INTEGER);
INSERT INTO AspNetUsers VALUES ('owner', 'raw', '{}', 1), ('other', 'private', '{}', 1);
CREATE TABLE CareerProfiles (Id INTEGER, OwnerUserId TEXT, Version INTEGER, ProfileJson TEXT);
INSERT INTO CareerProfiles VALUES (1, 'owner', 1, '{}'), (2, 'other', 1, '{}');
CREATE TABLE CareerProfileVersions (Id INTEGER, OwnerUserId TEXT, Version INTEGER);
INSERT INTO CareerProfileVersions VALUES (1, 'owner', 1), (2, 'other', 1);
CREATE TABLE JobApplications (Id INTEGER, OwnerUserId TEXT, JobId INTEGER, Version INTEGER);
INSERT INTO JobApplications VALUES (1, 'owner', 10, 2), (2, 'other', 20, 3);
CREATE TABLE Jobs (Id INTEGER, OwnerUserId TEXT);
INSERT INTO Jobs VALUES (10, 'owner'), (20, 'other');
CREATE TABLE JobEvents (Id INTEGER, JobApplicationId INTEGER);
INSERT INTO JobEvents VALUES (1, 1), (2, 2);
''')
for table in CHILDREN:
writer.execute(f'CREATE TABLE "{table}" (Id INTEGER, OwnerUserId TEXT, SortOrder INTEGER)')
writer.executemany(f'INSERT INTO "{table}" VALUES (?, ?, ?)', [(1, "owner", 0), (2, "other", 0)])
with closing(connect_readonly(filename)) as reader:
before = snapshot(reader, "owner", 1)
assert before["career"]["Version"] == 1
assert before["job"]["Version"] == 2
assert before["opportunity"]["Id"] == 10
assert before["events"] == [{"Id": 1, "JobApplicationId": 1}]
assert all(len(rows) == 1 for rows in before["children"].values())
assert "private" not in json.dumps(before)
try:
reader.execute('UPDATE CareerProfiles SET Version = 99')
except sqlite3.OperationalError as error:
assert "readonly" in str(error).lower()
else:
raise AssertionError("Read-only URI allowed mutation")
assert snapshot(reader, "owner", 1) == before
try:
snapshot(reader, "owner", 2)
except ValueError:
pass
else:
raise AssertionError("Cross-owner job was returned")
print("readonly-db-proof self-test passed")
if __name__ == "__main__":
if sys.argv[1:] == ["--self-test"]:
self_test()
else:
if len(sys.argv) not in (3, 4):
raise SystemExit("Usage: readonly-db-proof.py DATABASE OWNER_ID [JOB_ID] | --self-test")
with closing(connect_readonly(sys.argv[1])) as connection:
connection.execute("BEGIN")
print(json.dumps(snapshot(connection, sys.argv[2], int(sys.argv[3]) if len(sys.argv) == 4 else None)))
@@ -0,0 +1,99 @@
import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
import os from 'node:os';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(request: APIRequestContext, email: string) {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
}
test('P9 bounded sustained reads with 250 jobs and long CV while using native workspaces', async ({ page, context, playwright }, info) => {
test.setTimeout(240000);
const admin = await playwright.request.newContext();
const latency: { worker: number; endpoint: string; ms: number; bytes: number; status: number }[] = [];
const navigation: object[] = [];
const cdp = await context.newCDPSession(page);
await cdp.send('Performance.enable');
try {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p9-sustained-${Date.now()}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
const headers = await login(page.request, email);
const company = await page.request.post(`${api}/companies`, { headers, data: { name: 'P9 bounded workload' } });
expect(company.status()).toBe(201); const companyId = (await company.json()).id;
for (let index = 0; index < 250; index++) expect((await page.request.post(`${api}/jobapplications`, { headers,
data: { companyId, jobTitle: `P9 bounded role ${index}`, status: 'Applied', dateApplied: new Date().toISOString() } })).status()).toBe(201);
const profile = { version: '1', contact: { fullName: 'Åse Synthetic', email: 'synthetic@example.test' }, summary: ['Documented accessible workflows.'],
jobs: Array.from({ length: 8 }, (_, index) => ({ id: `p9-role-${index}`, title: `Platform engineer ${index}`, company: 'Nordlys synthetic',
start: '2017-01', end: '2025-12', bullets: Array.from({ length: 6 }, (_, bullet) => `Evidence ${index}.${bullet}: ${'Documented reliable workflows. Dokumenterte pålitelige arbeidsflyter. '.repeat(3).trim()}`) })) };
expect((await page.request.put(`${api}/career/profile`, { headers, data: { profile, expectedVersion: (await (await page.request.get(api + "/career/profile")).json()).revision } })).status()).toBe(200);
const savedProfile = (await (await page.request.get(`${api}/career/profile`)).json()).profile;
expect(savedProfile.jobs).toHaveLength(8);
expect(savedProfile.jobs.map((job: { start: string; end: string; bullets: string[] }) => ({ start: job.start, end: job.end, bullets: job.bullets })))
.toEqual(profile.jobs.map(job => ({ start: job.start, end: job.end, bullets: job.bullets })));
const cv = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'P9 long workload CV', settings: { themeId: 'minimal', language: 'nb' } } });
expect(cv.status()).toBe(200); const cvId = (await cv.json()).id;
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();
});
const endpoints = ['/jobapplications/stats', '/jobapplications/board', '/career/profile', '/documents'];
const started = Date.now();
const readWorker = async (worker: number) => {
for (let index = 0; index < 120; index++) {
const endpoint = endpoints[index % endpoints.length]; const begin = Date.now();
const response = await page.request.get(`${api}${endpoint}`);
latency.push({ worker, endpoint, ms: Date.now() - begin, bytes: (await response.body()).length, status: response.status() });
expect(response.status(), endpoint).toBe(200);
if (endpoint.endsWith('/board')) expect(await response.json()).toHaveLength(250);
await new Promise(resolve => setTimeout(resolve, 500));
}
};
const interact = async () => {
for (let cycle = 0; cycle < 5; cycle++) for (const route of ['/dashboard', '/jobs?view=table', '/jobs?view=board', `/career/builder/${cvId}`]) {
const begin = Date.now(); await page.goto(route);
if (route === '/dashboard') await expect(page.getByRole('link', { name: 'Tracked jobs: 250', exact: true })).toBeVisible();
else if (route.endsWith('board')) await expect(page.locator('[data-kanban-card-id]')).toHaveCount(250);
else if (route.endsWith('table')) await expect(page.locator('[data-job-row-id]').first()).toBeVisible();
else {
await expect(page.getByRole('region', { name: 'Live preview', exact: true })).toHaveAttribute('aria-busy', 'false');
const measured = await page.getByTitle('CV preview', { exact: true }).evaluate(element => {
const doc = (element as HTMLIFrameElement).contentDocument!;
const pages = Number(doc.documentElement.dataset.cvPreviewPages);
const width = Number(doc.documentElement.dataset.cvPreviewWidth);
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
let node; let finalMarkerPage = 0;
while ((node = walker.nextNode())) {
const index = node.textContent?.indexOf('Evidence 7.5:') ?? -1;
if (index < 0) continue;
const range = doc.createRange(); range.setStart(node, index); range.setEnd(node, index + 'Evidence 7.5:'.length);
finalMarkerPage = Math.floor(range.getClientRects()[0].x / width) + 1;
}
return { pages, finalMarkerPage, ready: doc.documentElement.dataset.cvPreviewReady };
});
expect(measured.ready).toBe('true'); expect(measured.pages).toBeGreaterThan(1);
expect(measured.finalMarkerPage).toBe(measured.pages);
await expect(page.locator('[data-cv-page]')).toHaveCount(measured.pages);
await expect(page.getByTitle('CV preview page 1', { exact: true })).toBeVisible();
}
navigation.push({ cycle, route, readyMs: Date.now() - begin,
metrics: (await cdp.send('Performance.getMetrics')).metrics,
domElements: await page.locator('*').count() });
}
};
await Promise.all([readWorker(0), readWorker(1), interact()]);
const durationMs = Date.now() - started;
expect(durationMs).toBeGreaterThanOrEqual(60000);
expect(latency).toHaveLength(240);
expect((await (await page.request.get(`${api}/jobapplications/board`)).json())).toHaveLength(250);
expect((await (await page.request.get(`${api}/career/profile`)).json()).profile).toEqual(savedProfile);
const sorted = latency.map(row => row.ms).sort((a, b) => a - b);
await info.attach('phase9-sustained-performance', { body: JSON.stringify({ durationMs, fixtureJobs: 250, fixtureCareerRoles: 8,
readConcurrency: 2, reads: latency.length, p50Ms: sorted[Math.floor(sorted.length * .5)], p95Ms: sorted[Math.floor(sorted.length * .95)], maxMs: sorted.at(-1),
environment: { platform: os.platform(), cpus: os.cpus().length, totalMemoryBytes: os.totalmem(), browser: context.browser()?.version(), database: 'isolated SQLite', frontend: 'production static export' },
latency, navigation, limits: 'Finite paced two-reader workload plus 20 native navigations; no SLA/capacity, physical mobile, deployed Linux, model inference or server CPU attribution.' }, null, 2), contentType: 'application/json' });
} finally { await cdp.detach(); await admin.dispose(); }
});
@@ -20,7 +20,10 @@ test("bound CV proposals use real owned saves and reject fabricated or stale out
const headers = await authenticate(request, "cv-ai-readiness@example.test");
expect((await request.post(`${providerUrl}/fixture/reset`)).status()).toBe(200);
const profile = { version: "1", contact: { fullName: "Native AI Fixture", headline: source }, summary: [source] };
expect((await request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
const canonicalRevision = (await (await request.get(`${apiUrl}/career/profile`)).json()).revision;
const canonicalSave = await request.put(`${apiUrl}/career/profile`, { headers, data: { profile, expectedVersion: canonicalRevision } });
expect(canonicalSave.status()).toBe(200);
const savedCanonicalRevision = (await canonicalSave.json()).revision;
const create = async (name: string) => {
const response = await request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings: { themeId: "minimal", language: "en", sections: [{ key: "summary" }] } } });
@@ -114,7 +117,7 @@ test("bound CV proposals use real owned saves and reject fabricated or stale out
const profileProposalResponse = await generate(staleProfileCv, "Fixture case: stale canonical profile.");
expect(profileProposalResponse.status()).toBe(200);
const profileProposal = await profileProposalResponse.json();
expect((await request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { ...profile, summary: ["Newer canonical user content."] } } })).status()).toBe(200);
expect((await request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { ...profile, summary: ["Newer canonical user content."] }, expectedVersion: savedCanonicalRevision } })).status()).toBe(200);
const newerProfile = await snapshot(staleProfileCv.id);
expect(newerProfile.variant.version).toBe(1);
const profileConflict = await apply(staleProfileCv.id, profileProposal.proposalToken);
@@ -147,7 +150,8 @@ test("Production withholds CV review before provider calls or content writes whi
expect((await request.post(`${providerUrl}/fixture/reset`)).status()).toBe(200);
const profile = { version: "1", contact: { fullName: "Production Gate Fixture", headline: source }, summary: [source] };
expect((await api.put(`${productionUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
const canonicalRevision = (await (await api.get(`${productionUrl}/career/profile`, { headers })).json()).revision;
expect((await api.put(`${productionUrl}/career/profile`, { headers, data: { profile, expectedVersion: canonicalRevision } })).status()).toBe(200);
const created = await api.post(`${productionUrl}/cv/variants`, { headers, data: { name: "Withheld AI review", settings: { themeId: "minimal", language: "en", sections: [{ key: "summary" }] } } });
expect(created.status()).toBe(200);
const cv = await created.json();
+4 -4
View File
@@ -23,7 +23,7 @@ const test = base.extend<{ accounts: { a: Account; b: Account } }>({
expect((await admin.api.post(`${apiUrl}/users`, { headers: admin.headers, data: { email, password, roles: [] } })).status()).toBe(200);
const account = await signIn(playwright, email);
accounts.push(account);
expect((await account.api.put(`${apiUrl}/career/profile`, { headers: account.headers, data: profile(`Private ${name.toUpperCase()}`) })).status()).toBe(200);
expect((await account.api.put(`${apiUrl}/career/profile`, { headers: account.headers, data: { ...profile(`Private ${name.toUpperCase()}`), expectedVersion: (await (await account.api.get(`${apiUrl}/career/profile`)).json()).revision } })).status()).toBe(200);
}
await provide({ a: accounts[0], b: accounts[1] });
} finally {
@@ -52,7 +52,7 @@ test("retained profile request from A cannot write using B cookies and fresh B C
expect((await browserSession.api.post(`${apiUrl}/auth/login`, { data: { email: b.email, password } })).status()).toBe(200);
const csrfB = (await browserSession.api.storageState()).cookies.find(cookie => cookie.name === "XSRF-TOKEN")!.value;
const response = await browserSession.api.put(`${apiUrl}/career/profile`, {
headers: { "X-CSRF-TOKEN": csrfB, "X-Session-Context": staleContext }, data: retained,
headers: { "X-CSRF-TOKEN": csrfB, "X-Session-Context": staleContext }, data: { ...retained, expectedVersion: retained.revision },
});
const persisted = { status: response.status(), a: await storedName(a), b: await storedName(b) };
await info.attach("retained-request-persistence", { body: JSON.stringify(persisted, null, 2), contentType: "application/json" });
@@ -66,7 +66,7 @@ test("missing and forged session contexts fail closed while same-account profile
for (const sessionContext of [undefined, a.sessionContext, a.id, "crafted-session"]) {
const response = await b.api.put(`${apiUrl}/career/profile`, {
headers: { "X-CSRF-TOKEN": b.headers["X-CSRF-TOKEN"], ...(sessionContext ? { "X-Session-Context": sessionContext } : {}) },
data: { ...profile("forged"), userId: a.id, ownerUserId: a.id, accountId: a.id },
data: { expectedVersion: (await (await b.api.get(`${apiUrl}/career/profile`)).json()).revision, ...profile("forged"), userId: a.id, ownerUserId: a.id, accountId: a.id },
});
expect(response.status()).toBe(sessionContext ? 409 : 428);
}
@@ -76,7 +76,7 @@ test("missing and forged session contexts fail closed while same-account profile
const singletonRead = await b.api.get(`${apiUrl}/career/profile?ownerUserId=${a.id}`);
expect((await singletonRead.json()).profile.contact.fullName).toBe("Private B");
const saved = await b.api.put(`${apiUrl}/career/profile`, {
headers: b.headers, data: { ...profile("Legitimate B"), userId: a.id, ownerUserId: a.id, roles: ["Admin"], plan: "pro" },
headers: b.headers, data: { expectedVersion: (await (await b.api.get(`${apiUrl}/career/profile`)).json()).revision, ...profile("Legitimate B"), userId: a.id, ownerUserId: a.id, roles: ["Admin"], plan: "pro" },
});
expect(saved.status()).toBe(200); // Over-posted ownership/security fields are not DTO properties.
expect(await storedName(a)).toBe("Private A");
@@ -34,7 +34,7 @@ test('[PRES-S01 S02 E07 E08 E09 J05 J09] Analytics native reports, direct entry,
const data = { companyId, jobTitle: `Utvikler og faglig rådgiver med lang stillingstittel ${index}`, status: ['Applied', 'Interview', 'Offer', 'Faglig vurdering', 'Saved', 'Rejected'][index], dateApplied: index === 4 ? null : date, tags: index === 4 ? null : 'Azure, TypeScript, Drift, Test, Design, Data, Plattform, Sikkerhet, Analyse, Integrasjon, Norsk, Samarbeid', ...(index % 2 ? {} : { salaryMin: 600000, salaryMax: 800000, salaryCurrency: 'NOK', salaryPeriod: 'year' }) };
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data });
expect(created.status()).toBe(201); ids.push((await created.json()).id);
if (index === 1) expect((await page.request.put(`${apiUrl}/jobapplications/${ids[index]}`, { headers, data: { ...data, responseReceived: true, responseDate: date } })).status()).toBe(204);
if (index === 1) expect((await page.request.put(`${apiUrl}/jobapplications/${ids[index]}`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${ids[index]}`)).json()).version, ...data, responseReceived: true, responseDate: date } })).status()).toBe(204);
}
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/analytics?months=6');
@@ -109,7 +109,7 @@ for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
const headers = await login(page.request, email);
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
const profile = { version: '1', contact: { fullName: 'Åse Zoom Fixture', headline: 'Documented application workflows', location: 'Oslo' }, summary: ['Supported source summary.'], skills: ['C#', 'React'], jobs: [{ id: 'zoom-role', title: 'Engineer', company: 'Synthetic company', bullets: ['Built and maintained internal web applications.'] }] };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Representative Norwegian company — æøå and long title' } });
expect(company.status()).toBe(201);
@@ -23,7 +23,7 @@ const test = base.extend<{ owner: { headers: Record<string, string>; shortId: nu
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();
});
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: {
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: {
version: "1", contact: { fullName: "Åse Eksempel", headline: "Canonical engineer" }, skills: ["Norsk", "C#"],
} } })).status()).toBe(200);
const ids: number[] = [];
+1 -1
View File
@@ -41,7 +41,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb']
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-06' }],
projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: ['Documented source-grounded reviews.'] }], skills: ['C#', 'React', 'Norsk'],
};
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys fixture AS' } });
expect(company.status()).toBe(201);
@@ -48,7 +48,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb']] as const) {
? 'Utviklet pålitelige arbeidsflyter med tilgjengelig navigasjon, dokumentert kildegrunnlag og tydelige vurderinger for norske arbeidssøkere.'
: 'Developed reliable workflows with accessible navigation, documented source evidence and explicit review for people managing their careers.';
const shortProfile = { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical engineer — æøå', location: 'Oslo, Norge' }, summary: ['Documented career evidence.'], skills: ['C#', 'React', 'Norsk'] };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: shortProfile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: shortProfile } })).status()).toBe(200);
const create = async (name: string, themeId: string, customItems: string[] = []): Promise<CvVariant> => {
const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings: {
themeId, language, sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'skills' }, ...(customItems.length ? [{ key: 'custom:preview-evidence' }] : [])],
@@ -167,7 +167,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb']] as const) {
const longProfile = { ...shortProfile, jobs: Array.from({ length: 3 }, (_, index) => ({ id: `long-job-${index + 1}`, title: `Source-grounded engineer ${index + 1} — æøå`, company: 'Nordlys Fixture AS', startDate: '2017-01', endDate: '2025-12',
bullets: Array.from({ length: 30 }, (_, bullet) => `Source contribution ${index * 30 + bullet + 1}: ${sentence} ${sentence}`) })) };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: longProfile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: longProfile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const veryLong = await create('Very long source and custom CV — æøå', 'minimal', Array.from({ length: 45 }, (_, index) => `CV-only long contribution ${index + 1}: ${sentence} ${sentence}`));
const unchanged = async () => {
+1 -1
View File
@@ -60,7 +60,7 @@ test("[PRES-C06 C08 C09 C12] native preview/PDF fidelity corpus records unchange
const results = [];
for (const fixture of fixtures) {
const profile = fixture.long ? longProfile(fixture.language as "en" | "nb") : shortProfile;
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const create = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: fixture.key, settings: {
themeId: fixture.themeId, language: fixture.language, sections: [{ key: "summary" }, { key: "experience" }, { key: "education" }, { key: "projects" }, { key: "skills" }, { key: "languages" }, { key: "interests", hidden: true }], ...fixture.settings,
} } });
@@ -35,7 +35,7 @@ async function seed(page: Page, context: BrowserContext, admin: APIRequestContex
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2013-08', endDate: '2016-06' }],
projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: [sentence] }, { id: 'project-2', name: 'Accessible integration', bullets: [sentence] }], skills: ['C#', 'React', 'Norsk'],
};
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const outline: CvOutline = await (await page.request.get(`${apiUrl}/cv/outline`)).json();
const sections = ['summary', 'experience', 'education', 'projects', 'skills', 'certifications', 'languages', 'awards', 'publications', 'organisations', 'interests', 'references'].map(key => ({ key, hidden: !outline.sections.some(section => section.key === key) }));
+2 -2
View File
@@ -51,7 +51,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb']
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2013-08', endDate: '2016-06' }],
projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: ['Documented source-grounded reviews.'] }], skills: ['C#', 'React', 'Norsk'],
};
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const outline: CvOutline = await (await page.request.get(`${apiUrl}/cv/outline`)).json();
const entries = outline.sections.find(section => section.key === 'experience')!.entries;
@@ -141,7 +141,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb']
await expect.poll(async () => (await read()).settings.overrides[first.key!].title).toBe('Retained CV title');
await saved();
const changedProfile = { ...profile, jobs: profile.jobs.map(job => job.id === 'job-1' ? { ...job, title: 'Updated source title', company: 'Updated source employer', bullets: ['Updated source evidence — æøå.'] } : job) };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: changedProfile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: changedProfile } })).status()).toBe(200);
const changedSource = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const beforeReload = await read();
await page.reload();
@@ -92,7 +92,7 @@ for (const [mode, language, premium] of [['light', 'en', false], ['dark', 'nb',
const profile = { version: '1', contact: { fullName: 'Åse Fixture', headline: 'Verified career information', location: 'Oslo, Norge' }, summary: ['R01 Verified source content — æøå.'],
jobs: Array.from({ length: 5 }, (_, index) => ({ id: `job-${index}`, title: `E${String(index + 1).padStart(2, '0')} Plattformutvikler og dokumenterte integrasjoner`, company: 'Nordlys Fixture AS', startDate: '2020-01', endDate: '2025-01', bullets: Array.from({ length: 3 }, (_, bullet) => `B${String(index * 3 + bullet + 1).padStart(2, '0')} ${sentence.repeat(5)}`) })),
education: [{ id: 'edu-1', qualification: 'E08 Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-06' }], skills: ['React', 'C#', 'Norsk'], interests: ['HIDDEN-MARKER Not included'] };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Dokumenterte kvalifikasjoner — innstillinger for denne CV-en', settings: {
themeId: 'minimal', pageMarginMm: 16, baseFontSizePt: 10, bodyFont: 'Arial, Helvetica, sans-serif', accentColor: '#126b55', language: language === 'nb' ? 'nb-NO' : 'en',
@@ -34,7 +34,7 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
expect(created.status()).toBe(201);
if (index === 2 || index === 3) {
const id = (await created.json()).id;
expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { ...data, responseReceived: true, responseDate: date } })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${id}`)).json()).version, ...data, responseReceived: true, responseDate: date } })).status()).toBe(204);
}
}
await page.setViewportSize({ width: 1440, height: 900 });
@@ -86,7 +86,7 @@ test('[PRES-W01 W02 W03 J05 E08 E09] native Job header/Overview retains actions,
}
// Existing native status write, not a new header status API or inferred progression.
await page.goto('about:blank');
expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { ...jobData, status: 'Faglig vurdering' } })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/jobapplications/${id}`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${id}`)).json()).version, ...jobData, status: 'Faglig vurdering' } })).status()).toBe(204);
await page.goto(`/applications/${id}?section=overview`);
const custom = page.getByRole('region', { name: `${translations.nb.workspaceProgress}: Faglig vurdering` });
await expect(custom).toContainText(translations.nb.workspaceCustomProgress);
@@ -71,7 +71,7 @@ for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) || ['blob:', 'data:', 'about:'].includes(new URL(route.request().url()).protocol) ? route.continue() : route.abort());
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Recorded engineer' }, skills: ['C#', 'SQL'] } } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Recorded engineer' }, skills: ['C#', 'SQL'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys samfunnskritiske plattformer og inkluderende digitale tjenester æøå' } });
expect(company.status()).toBe(201); const companyId = (await company.json()).id;
const title = 'Senior utvikler og rådgiver for langsiktig plattformutvikling';
@@ -81,8 +81,8 @@ for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Application CV — Åse', jobApplicationId: id } });
expect(cv.status()).toBe(200); const variant = await cv.json();
expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: variant.id } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: 'Reviewed letter — æøå', source: 'manual' } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: 'Reviewed answer — æøå', recruiterMessageDraft: 'Message draft; never sent' } })).status()).toBe(204);
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { expectedVersion: (await (await page.request.get(`${jobUrl}/cover-letter`)).json()).revision, text: 'Reviewed letter — æøå', source: 'manual' } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { expectedVersion: (await (await page.request.get(`${jobUrl}`)).json()).version, applicationAnswerDraft: 'Reviewed answer — æøå', recruiterMessageDraft: 'Message draft; never sent' } })).status()).toBe(204);
expect((await page.request.post(`${jobUrl}/checklist`, { headers, data: { title: 'Review employer questions — æøå', category: 'custom' } })).status()).toBe(200);
expect((await page.request.post(`${jobUrl}/interview-prep`, { headers, data: { title: 'Ask about sustainable delivery', category: 'question', content: 'My recorded question', source: 'manual' } })).status()).toBe(200);
expect((await page.request.post(`${apiUrl}/correspondence`, { headers, data: { jobApplicationId: id, from: 'Company', subject: 'Interview invitation — æøå', content: 'Recorded invitation; no inferred company facts', channel: 'Email' } })).ok()).toBe(true);
@@ -23,7 +23,7 @@ const test = base.extend<{ owner: { headers: Record<string, string>; companyId:
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();
});
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS" } });
expect(company.status()).toBe(201);
const companyId = (await company.json()).id;
@@ -143,7 +143,7 @@ for (const sample of matrix) {
jobs: [{ title: 'Recorded engineer', company: 'Source-only employer', bullets: ['Recorded work, never an account field'] }],
skills: ['C#', 'Norsk'],
};
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: canonical } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: canonical } })).status()).toBe(200);
const cvIds: number[] = [];
for (const name of ['First protected CV', 'Sibling protected CV']) {
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
@@ -13,7 +13,7 @@ const password = 'E2ePassword123!';
type Owner = { id: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type Subsection = 'cv' | 'letter' | 'answers' | 'submission';
type Job = { id: number; jobTitle: string; notes: string | null };
type Job = { id: number; jobTitle: string; notes: string | null; version: number };
type RequestRecord = { method: string; path: string; body: unknown };
type Session = { owner: Owner; admin: APIRequestContext; administrator: Owner; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
@@ -86,7 +86,7 @@ function profile(headline = 'Original reviewed career evidence') {
summary: ['Canonical owner-reviewed evidence — æøå'], skills: ['C#', 'SQL'] };
}
async function source(api: APIRequestContext, owner: Owner, headline?: string) {
return json(await api.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: profile(headline), cvText: 'Exact owner-reviewed source — æøå' } }));
return json(await api.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await api.get(`${apiUrl}/career/profile`)).json()).revision, profile: profile(headline), cvText: 'Exact owner-reviewed source — æøå' } }));
}
async function createJob(api: APIRequestContext, owner: Owner, title = 'Recorded application material — æøå') {
const company = await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded application company — æøå', source: 'User' } }), 201);
@@ -100,10 +100,10 @@ async function variant(api: APIRequestContext, owner: Owner, name: string) {
} }));
}
async function saveLetter(api: APIRequestContext, owner: Owner, id: number, text: string) {
return json<CoverLetter>(await api.put(`${jobUrl(id)}/cover-letter`, { headers: owner.headers, data: { text, source: 'manual' } }));
return json<CoverLetter>(await api.put(`${jobUrl(id)}/cover-letter`, { headers: owner.headers, data: { expectedVersion: (await (await api.get(`${jobUrl(id)}/cover-letter`)).json()).revision, text, source: 'manual' } }));
}
async function saveDrafts(api: APIRequestContext, owner: Owner, id: number, answer: string, message: string) {
expect((await api.put(`${jobUrl(id)}/application-drafts`, { headers: owner.headers, data: { applicationAnswerDraft: answer, recruiterMessageDraft: message } })).status()).toBe(204);
expect((await api.put(`${jobUrl(id)}/application-drafts`, { headers: owner.headers, data: { expectedVersion: (await (await api.get(`${jobUrl(id)}`)).json()).version, applicationAnswerDraft: answer, recruiterMessageDraft: message } })).status()).toBe(204);
}
async function upload(api: APIRequestContext, owner: Owner, id: number) {
const bytes = Buffer.from('Exact archived application evidence — æøå\nOriginal bytes retained.\n', 'utf8');
@@ -186,7 +186,7 @@ test('[P7-06 PRES-W01 W04 W10] same-route job navigation waits for owned answers
await expect(drafts(page).message).toHaveCount(0);
expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toHaveLength(0);
await capture(page, info, desktop, 'held-second-job-aggregate-no-foreign-drafts');
const loaded = response(page, 'GET', workspaceUrl); held.release(); await loaded;
const loaded = response(page, 'GET', workspaceUrl); held.release(); const loadedOverview = await json<WorkspaceOverview>(await loaded);
await expect(drafts(page).answer).toHaveValue(secondAnswer);
await expect(drafts(page).message).toHaveValue(secondMessage);
const reviewed = 'Edited second-job answer only — æøå';
@@ -201,7 +201,7 @@ test('[P7-06 PRES-W01 W04 W10] same-route job navigation waits for owned answers
const persistedFirst = await json<WorkspaceOverview>(await page.request.get(`${jobUrl(first.job.id)}/workspace`));
expect(persistedFirst.applicationAnswerDraft).toBe(first.answer);
expect(persistedFirst.recruiterMessageDraft).toBe(first.message);
expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toEqual([{ method: 'PUT', path: `/api/jobapplications/${second.id}/application-drafts`, body: { applicationAnswerDraft: reviewed, recruiterMessageDraft: secondMessage } }]);
expect(count(session, 'PUT', `/api/jobapplications/${second.id}/application-drafts`)).toEqual([{ method: 'PUT', path: `/api/jobapplications/${second.id}/application-drafts`, body: { applicationAnswerDraft: reviewed, recruiterMessageDraft: secondMessage, expectedVersion: loadedOverview.revision } }]);
await capture(page, info, desktop, 'second-job-answer-saved-first-job-preserved');
} finally { held.release(); await page.unroute(workspaceUrl); }
});
@@ -302,7 +302,7 @@ test('[P7-06 forms] long letter failed held Save retains snapshot and dirty rest
await keyboard(page, body.getByRole('button', { name: translations.en.save, exact: true })); await expect.poll(() => saves).toBe(1);
await expect(letter(page)).toBeDisabled(); await expect(childFocus(page, translations.en.workspaceCoverLetter)).toBeFocused();
await expect(body.getByRole('button', { name: translations.en.assetsDiscardChanges, exact: true })).toBeDisabled();
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/cover-letter`).map(value => value.body)).toEqual([{ text: draft, source: 'manual' }]);
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/cover-letter`).map(value => value.body)).toEqual([{ text: draft, source: 'manual', expectedVersion: before.revision }]);
await capture(page, info, desktop, 'held-letter-save'); gate.release();
await expect(body.getByRole('alert')).toContainText(translations.en.assetsCoverSaveFailed); await expect(letter(page)).toHaveValue(draft);
expect(await json<CoverLetter>(await page.request.get(url))).toEqual(before); expect(saves).toBe(1);
@@ -337,7 +337,7 @@ test('[P7-06 forms] failed held Answers save preserves both drafts and ordinary
const body = section(page, translations.en.assetsApplicationDrafts);
await keyboard(page, body.getByRole('button', { name: translations.en.assetsSaveDrafts, exact: true }));
await expect.poll(() => writes).toBe(1); await expect(fields.answer).toBeDisabled(); await expect(fields.message).toBeDisabled(); await expect(childFocus(page, translations.en.assetsApplicationDrafts)).toBeFocused();
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`).map(value => value.body)).toEqual([{ applicationAnswerDraft: answer, recruiterMessageDraft: message }]);
expect(count(session, 'PUT', `/api/jobapplications/${seeded.job.id}/application-drafts`).map(value => value.body)).toEqual([{ applicationAnswerDraft: answer, recruiterMessageDraft: message, expectedVersion: original.version }]);
await capture(page, info, desktop, 'held-two-draft-save'); gate.release();
await expect(body.getByRole('alert')).toContainText(translations.en.assetsDraftsSaveFailed); await expect(fields.answer).toHaveValue(answer); await expect(fields.message).toHaveValue(message);
expect((await json<WorkspaceOverview>(await page.request.get(`${jobUrl(seeded.job.id)}/workspace`))).applicationAnswerDraft).toBe(seeded.answer); expect(writes).toBe(1);
@@ -69,7 +69,7 @@ async function seed(page: Page, owner: Owner) {
items: ['Recorded custom fact with relevant professional context.', 'Another recorded custom fact, preserved during unrelated contact editing.'] }],
};
const raw = 'Exact original career source — æøå\nRecorded work and qualifications.\n';
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } }));
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 } }));
return { canonical: await json<CareerReadback>(await page.request.get(`${apiUrl}/career/profile`)),
history: await json<unknown[]>(await page.request.get(`${apiUrl}/career/profile/versions`)) };
}
@@ -11,7 +11,7 @@ const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; email: string; headers: Record<string, string> };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type CareerReadback = { profile: StructuredCvProfile; completeness: unknown; cvText: string | null };
type CareerReadback = { profile: StructuredCvProfile; completeness: unknown; cvText: string | null; revision: number };
type Version = { version: number; source: string; isCurrent: boolean; createdAtUtc: string };
type Run = { id: number; status: string; artifactFileName: string | null; operation: unknown };
type ReviewDiff = { runId: number; status: string; diff: { categories: { category: string;
@@ -98,9 +98,9 @@ async function readSource(api: APIRequestContext) {
async function seedSource(page: Page, owner: Owner, twoVersions = false) {
const raw = 'Exact CURRENT original career source — æøå\nReviewed work and qualifications.\n';
await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: source(), cvText: raw } }));
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: source(), cvText: raw } }));
if (twoVersions) await json<CareerReadback>(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile: source('Second reviewed canonical context'), cvText: raw } }));
data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: source('Second reviewed canonical context'), cvText: raw } }));
return readSource(page.request);
}
@@ -280,7 +280,7 @@ test('[P7-03 PRES-C01 C02 C03 C04 C05 W07] explicit select/deselect acceptance u
await page.keyboard.press('Enter');
const firstAccepted = await firstResponse;
expect(firstAccepted.status()).toBe(200);
expect(firstAccepted.request().postDataJSON()).toEqual({ acceptedLowConfidenceIds: [] });
expect(firstAccepted.request().postDataJSON()).toEqual({ acceptedLowConfidenceIds: [], expectedVersion: before.canonical.revision });
await expect(article.getByText(translations.en.careerImportApplied, { exact: true })).toBeVisible();
await expect(article).toBeFocused();
const afterFirst = await readSource(page.request);
@@ -306,7 +306,7 @@ test('[P7-03 PRES-C01 C02 C03 C04 C05 W07] explicit select/deselect acceptance u
await secondArticle.getByRole('button', { name: translations.nb.profileCvApplyChanges, exact: true }).click();
const secondAccepted = await secondResponse;
expect(secondAccepted.status()).toBe(200);
expect(secondAccepted.request().postDataJSON()).toEqual({ acceptedLowConfidenceIds: ['Contact'] });
expect(secondAccepted.request().postDataJSON()).toEqual({ acceptedLowConfidenceIds: ['Contact'], expectedVersion: afterFirst.canonical.revision });
await expect(secondArticle).toBeFocused();
const afterSecond = await readSource(page.request);
expect(afterSecond.canonical.profile.contact.email).toBe(importedContactEmail);
@@ -314,7 +314,7 @@ test('[P7-03 PRES-C01 C02 C03 C04 C05 W07] explicit select/deselect acceptance u
expect((await json<{ email: string }>(await page.request.get(`${apiUrl}/auth/me`))).email).toBe(owner.email);
expect(afterSecond.canonical.cvText).toBe(before.canonical.cvText);
expect(afterSecond.history).toHaveLength(afterFirst.history.length + 1);
expect((await page.request.post(`${apiUrl}/profile-cv/runs/${secondRun}/accept`, { headers: owner.headers, data: { acceptedLowConfidenceIds: ['Contact'] } })).status()).toBe(409);
expect((await page.request.post(`${apiUrl}/profile-cv/runs/${secondRun}/accept`, { headers: owner.headers, data: { expectedVersion: afterFirst.canonical.revision, acceptedLowConfidenceIds: ['Contact'] } })).status()).toBe(409);
expect(await readSource(page.request)).toEqual(afterSecond);
const afterDocuments = await Promise.all(documents.urls.map(async url => json<unknown>(await page.request.get(url))));
expect(afterDocuments).toEqual(documents.before);
@@ -365,7 +365,7 @@ test('[P7-03 PRES-C02 C04 E09] stored reviews are owner scoped and foreign/anony
try {
const adminOwner = await login(admin, 'e2e@example.test');
const ownerB = await login(other, await createOwner(admin, adminOwner));
await json<CareerReadback>(await other.put(`${apiUrl}/career/profile`, { headers: ownerB.headers, data: { profile: source('Owner B reviewed context'), cvText: 'Owner B exact raw' } }));
await json<CareerReadback>(await other.put(`${apiUrl}/career/profile`, { headers: ownerB.headers, data: { expectedVersion: (await (await other.get(`${apiUrl}/career/profile`)).json()).revision, profile: source('Owner B reviewed context'), cvText: 'Owner B exact raw' } }));
const beforeB = await readSource(other);
const runB = seedPending(info, ownerB);
await assertReachableLowContact(other, runB, beforeB.canonical.profile.contact.email);
@@ -373,12 +373,12 @@ test('[P7-03 PRES-C02 C04 E09] stored reviews are owner scoped and foreign/anony
expect((await json<Run[]>(await other.get(`${apiUrl}/profile-cv/runs`))).map(run => run.id)).toEqual([runB]);
for (const [api, foreign, headers] of [[page.request, runB, owner.headers], [other, runA, ownerB.headers]] as const) {
expect((await api.get(`${apiUrl}/profile-cv/runs/${foreign}/diff`)).status()).toBe(404);
expect((await api.post(`${apiUrl}/profile-cv/runs/${foreign}/accept`, { headers, data: { acceptedLowConfidenceIds: ['Contact'] } })).status()).toBe(404);
expect((await api.post(`${apiUrl}/profile-cv/runs/${foreign}/accept`, { headers, data: { expectedVersion: 0, acceptedLowConfidenceIds: ['Contact'] } })).status()).toBe(404);
expect((await api.post(`${apiUrl}/profile-cv/runs/${foreign}/discard`, { headers })).status()).toBe(404);
}
expect((await anonymous.get(`${apiUrl}/profile-cv/runs`)).status()).toBe(401);
expect((await anonymous.get(`${apiUrl}/profile-cv/runs/${runA}/diff`)).status()).toBe(401);
expect((await anonymous.post(`${apiUrl}/profile-cv/runs/${runA}/accept`, { data: { acceptedLowConfidenceIds: [] } })).status()).toBe(401);
expect((await anonymous.post(`${apiUrl}/profile-cv/runs/${runA}/accept`, { data: { expectedVersion: 0, acceptedLowConfidenceIds: [] } })).status()).toBe(401);
expect((await anonymous.post(`${apiUrl}/profile-cv/runs/${runA}/discard`)).status()).toBe(401);
expect(await readSource(page.request)).toEqual(beforeA);
expect(await readSource(other)).toEqual(beforeB);
@@ -140,7 +140,7 @@ for (const sample of matrix) {
test.setTimeout(120_000);
const profile = { version: '1', contact: { fullName: 'Åse Kunnskapsbank', headline: 'Canonical source' },
jobs: [{ title: 'Engineer', company: 'Dokumentasjon AS', bullets: ['Recorded work, no invented outcomes.'] }], skills: ['C#', 'Norsk'] };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const before = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const cvs: CvVariant[] = [];
for (let i = 0; i < 4; i++) {
@@ -229,7 +229,7 @@ for (const failedPath of ['/career/profile', '/profile-cv/runs', '/cv/variants']
test('[P7-02 PRES-C04 E09] evidence pending/failure/read retry and failed save preserve drafts across section changes, cancellation and actual deletion', async ({ page, owner }, info) => {
test.setTimeout(120_000);
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: {
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision,
profile: { version: '1', contact: { fullName: 'Independent canonical source' }, skills: ['Original source fact'] }, cvText: 'Original raw source',
} })).status()).toBe(200);
const canonicalBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json();
@@ -332,8 +332,8 @@ test('[P7-02 PRES-C01 C02] existing raw source copy and history entry remain rea
const sample: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const profile = { version: '1', contact: { fullName: 'Canonical source' }, skills: ['Recorded skill'] };
const raw = 'Exact raw source — æøå\nA supported owner claim.\n';
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: { ...profile, skills: ['Recorded skill', 'Recorded later skill'] }, cvText: raw } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { ...profile, skills: ['Recorded skill', 'Recorded later skill'] }, cvText: raw } })).status()).toBe(200);
expect((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 } })).status()).toBe(200);
const before = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const history = await (await page.request.get(`${apiUrl}/career/profile/versions`)).json();
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
@@ -13,7 +13,7 @@ type CareerProfile = Record<string, unknown> & {
projects: SourceItem[];
certifications: SourceItem[];
};
type CareerReadback = { profile: CareerProfile; completeness: unknown; cvText: string | null };
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 };
@@ -89,7 +89,7 @@ test('[P7-03 PRES-C01 C03 C06 C07 C10 W05 W06] unrelated canonical UI save prese
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 } }));
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);
@@ -182,12 +182,12 @@ test('[P7-03 PRES-C01 C03 C06 C07 C10 W05 W06] unrelated canonical UI save prese
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('[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: { profile: initialProfile, cvText: initialRaw } }));
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]);
@@ -202,43 +202,43 @@ test('[P7-03 PRES-C01 C03 E05 E09] real HTTP bounds canonical last-writer saves
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 } }));
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 = { profile: { ...staleSnapshot.profile, contact: { ...staleSnapshot.profile.contact, headline: 'Stale snapshot accepted as last writer' } }, cvText: currentRaw };
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();
const lastWriter = await json<CareerReadback>(staleWrite);
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(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 }));
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(remote.profile);
expect(restored.profile).toEqual(staleSnapshot.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(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`, { headers: owner.headers })).status()).toBe(404);
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`, { headers: otherOwner.headers })).status()).toBe(404);
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: { ownerUserId: owner.id, profile: { version: '1', contact: { fullName: 'Other account only' }, skills: ['Other owner fact'] }, cvText: 'Other owner raw source' } }));
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);
@@ -249,6 +249,6 @@ test('[P7-03 PRES-C01 C03 E05 E09] real HTTP bounds canonical last-writer saves
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' });
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(); }
});
+1 -1
View File
@@ -57,7 +57,7 @@ async function seedSource(page: Page, owner: Owner) {
jobs: [{ id: 'library-source-job', title: 'Recorded engineer', company: 'Nordlys AS', bullets: ['Recorded source evidence.'], skills: ['C#'] }],
summary: ['Reviewed canonical summary, kept separate from CV-specific presentation.'] };
await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile, cvText: 'Exact owner-reviewed source — æøå\nRecorded work.\n' } }));
data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile, cvText: 'Exact owner-reviewed source — æøå\nRecorded work.\n' } }));
return readSource(page.request);
}
+4 -4
View File
@@ -58,9 +58,9 @@ async function upload(api: APIRequestContext, owner: Owner, parent: Job, name =
const bytes = Buffer.from(`${privateMarker}\nExact owned file bytes — æøå\n`, 'utf8'); expect((await api.post(`${apiUrl}/attachments`, { headers: owner.headers, multipart: { jobId: String(parent.id), files: { name, mimeType: 'text/plain', buffer: bytes } } })).ok()).toBe(true); const attachment = (await json<{ id: number; fileName: string }[]>(await api.get(`${apiUrl}/attachments/${parent.id}`))).find(value => value.fileName === name)!; expect(attachment.id).toBeGreaterThan(0); expect((await api.patch(`${apiUrl}/attachments/${attachment.id}`, { headers: owner.headers, data: { useForAi: false } })).status()).toBe(204); return { attachment, bytes, name };
}
async function seven(page: Page, owner: Owner, long = false) {
const empty = emptyStructuredCv(); await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: { ...empty, contact: { ...empty.contact, fullName: 'Åse Recorded Documents', headline: 'Owner-reviewed engineering evidence' }, summary: [privateMarker], skills: ['C#', 'SQL'] }, cvText: privateMarker } }));
const empty = emptyStructuredCv(); await json(await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { ...empty, contact: { ...empty.contact, fullName: 'Åse Recorded Documents', headline: 'Owner-reviewed engineering evidence' }, summary: [privateMarker], skills: ['C#', 'SQL'] }, cvText: privateMarker } }));
const parent = await job(page.request, owner, long ? 'Langvarig plattformutvikling for samfunnskritiske tjenester og internasjonale samarbeidspartnere Æ Ø Å' : undefined); const variant = await cv(page.request, owner, long ? 'CV med lang dokumenttittel om samfunnskritisk plattformutvikling og inkluderende arbeidsprosesser Æ Ø Å' : 'Recorded current and captured CV — æøå'); await json(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cv`, { headers: owner.headers, data: { variantId: variant.id } })); const published = await json<CvVariant>(await page.request.put(`${apiUrl}/cv/variants/${variant.id}/public`, { headers: owner.headers, data: { isPublic: true } }));
const letter = `${privateMarker}\nOriginal reviewed cover letter — æøå`; await json(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: owner.headers, data: { text: letter, source: 'manual' } })); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/application-drafts`, { headers: owner.headers, data: { applicationAnswerDraft: `${privateMarker} answer`, recruiterMessageDraft: `${privateMarker} recruiter draft` } })).status()).toBe(204); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/tailored-cv`, { headers: owner.headers, data: { tailoredCvText: `${privateMarker} manually tailored CV` } })).status()).toBe(204);
const letter = `${privateMarker}\nOriginal reviewed cover letter — æøå`; await json(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${parent.id}/cover-letter`)).json()).revision, text: letter, source: 'manual' } })); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/application-drafts`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${parent.id}`)).json()).version, applicationAnswerDraft: `${privateMarker} answer`, recruiterMessageDraft: `${privateMarker} recruiter draft` } })).status()).toBe(204); expect((await page.request.put(`${apiUrl}/jobapplications/${parent.id}/tailored-cv`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${parent.id}`)).json()).version, tailoredCvText: `${privateMarker} manually tailored CV` } })).status()).toBe(204);
const file = await upload(page.request, owner, parent, long ? 'Dokumentasjon av samfunnskritiske tjenester og inkluderende arbeidsprosesser med lange dokumentnavn Æ Ø Å.txt' : undefined); const draft = await email(page.request, owner, parent, long ? 'Lagret e-postutkast om samfunnskritiske tjenester og internasjonalt samarbeid Æ Ø Å' : 'Recorded saved email draft — æøå'); const preserved = await json<SubmittedPackage>(await page.request.post(`${apiUrl}/jobapplications/${parent.id}/submitted-packages`, { headers: owner.headers })); const items = (await readIndex(page.request)).items; expect(items.map(value => value.type).sort()).toEqual(Object.keys(typeKeys).sort()); return { parent, variant: published, letter, draft, preserved, file, items };
}
async function enter(page: Page, owner: Owner, sample: Sample = desktop, path = '/documents?source=phase7-documents&keep=%C3%A6%26x&followMode=waiting-update#library') {
@@ -148,9 +148,9 @@ test('[P7-07 Documents] actual sign-out clears private rows and a separate owner
});
test('[P7-07 Documents] existing letter source keeps dirty edits on navigation Cancel and reindexes only the explicit saved revision', async ({ page, session }, info) => {
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await json<CoverLetter>(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: session.owner.headers, data: { text: 'Original source letter — æøå', source: 'manual' } })); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; const from = indexPath(page); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); await expect(page).toHaveURL(new RegExp(`/jobs/${parent.id}\\?section=application&sub=letter$`)); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text!); const dirty = 'Reviewed local source change survives Documents navigation Cancel — æøå'; await field.fill(dirty);
test.setTimeout(150_000); const parent = await job(page.request, session.owner); const original = await json<CoverLetter>(await page.request.put(`${apiUrl}/jobapplications/${parent.id}/cover-letter`, { headers: session.owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${parent.id}/cover-letter`)).json()).revision, text: 'Original source letter — æøå', source: 'manual' } })); await enter(page, session.owner); const item = (await readIndex(page.request)).items[0]; const from = indexPath(page); await keyboard(page, row(page, item).getByRole('link', { name: copy('documentsOpenSource'), exact: true })); await expect(page).toHaveURL(new RegExp(`/jobs/${parent.id}\\?section=application&sub=letter$`)); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text!); const dirty = 'Reviewed local source change survives Documents navigation Cancel — æøå'; await field.fill(dirty);
const navigation = page.getByRole('button', { name: copy('documentsTitle'), exact: true }); await keyboard(page, navigation); const dialog = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await keyboard(page, dialog.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(dialog).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`)).toHaveLength(0); expect(await json<CoverLetter>(await page.request.get(`${apiUrl}/jobapplications/${parent.id}/cover-letter`))).toEqual(original); await capture(page, info, desktop, 'existing-letter-dirty-navigation-cancel');
const body = page.getByRole('region', { name: copy('workspaceCoverLetter'), exact: true }); const saved = waitResponse(page, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`); await keyboard(page, body.getByRole('button', { name: copy('save'), exact: true })); const revision = await json<CoverLetter>(await saved); expect(revision.text).toBe(dirty); expect(revision.currentVersion).toBe(2); await expect(body.getByRole('button', { name: copy('save'), exact: true })).toBeDisabled(); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`).map(value => value.body)).toEqual([{ text: dirty, source: 'manual' }]); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from); const indexed = (await readIndex(page.request)).items[0]; expect(indexed).toMatchObject({ id: item.id, historyCount: 2, version: 2, hasCurrentContent: true }); await expect(row(page, indexed)).toContainText(copy('documentsHistoryVersions', { count: 2 })); expect(writes(session)).toHaveLength(1);
const body = page.getByRole('region', { name: copy('workspaceCoverLetter'), exact: true }); const saved = waitResponse(page, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`); await keyboard(page, body.getByRole('button', { name: copy('save'), exact: true })); const revision = await json<CoverLetter>(await saved); expect(revision.text).toBe(dirty); expect(revision.currentVersion).toBe(2); await expect(body.getByRole('button', { name: copy('save'), exact: true })).toBeDisabled(); expect(count(session, 'PUT', `/api/jobapplications/${parent.id}/cover-letter`).map(value => value.body)).toEqual([{ text: dirty, source: 'manual', expectedVersion: original.revision }]); await page.goBack(); await expect(results(page)).toBeVisible(); expect(indexPath(page)).toBe(from); const indexed = (await readIndex(page.request)).items[0]; expect(indexed).toMatchObject({ id: item.id, historyCount: 2, version: 2, hasCurrentContent: true }); await expect(row(page, indexed)).toContainText(copy('documentsHistoryVersions', { count: 2 })); expect(writes(session)).toHaveLength(1);
});
test('[P7-07 Documents] eight finite bilingual theme widths retain long metadata keyboard actions and complete axe vectors', async ({ page, session }, info) => {
@@ -94,7 +94,7 @@ test('[P7-08 notifications] failed mutation serializes activation and read-only
});
test('[P7-08 notifications] labelled internal-link GET projection respects existing dirty-workspace Cancel before explicit navigation', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: session.owner.headers, data: { name: 'Notification context company — æøå' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: { companyId: company.id, jobTitle: 'Notification dirty-context role', status: 'Saved' } }), 201); const original = { text: 'Persisted letter before notification navigation — æøå', source: 'manual' }; await json(await page.request.put(`${apiUrl}/jobapplications/${job.id}/cover-letter`, { headers: session.owner.headers, data: original }));
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: session.owner.headers, data: { name: 'Notification context company — æøå' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: { companyId: company.id, jobTitle: 'Notification dirty-context role', status: 'Saved' } }), 201); const original = { text: 'Persisted letter before notification navigation — æøå', source: 'manual' }; await json(await page.request.put(`${apiUrl}/jobapplications/${job.id}/cover-letter`, { headers: session.owner.headers, data: { ...original, expectedVersion: (await (await page.request.get(`${apiUrl}/jobapplications/${job.id}/cover-letter`)).json()).revision } }));
await page.route(`${apiUrl}/notifications?limit=10`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)?.linkPath).toBeNull(); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, linkPath: '/documents' } : item) }); });
const source = `/jobs/${job.id}?section=application&sub=letter`; await enter(page, session.owner, desktop, source); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text); const dirty = 'Unsaved reviewed letter survives notification navigation Cancel — æøå'; await field.fill(dirty); await open(page); const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })); expect((await read).status()).toBe(204); const confirmation = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await expect(confirmation).toBeVisible(); await keyboard(page, confirmation.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(confirmation).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe(source); const stored = await json<{ text: string }>(await page.request.get(`${apiUrl}/jobapplications/${job.id}/cover-letter`)); expect(stored.text).toBe(original.text); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)?.linkPath).toBeNull();
await open(page); await capture(page, info, desktop, 'controlled-internal-link-dirty-cancel', 'Only linkPath=/documents is projected on an actual owned notice; actual producer API is asserted linkPath=null before and after. Existing unsaved-workspace guard is exercised.'); await close(page); expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(1); expect(session.requests.filter(value => value.method !== 'GET' && !value.path.endsWith(`/${seeded.notice.id}/read`))).toEqual([]);
+2 -2
View File
@@ -121,7 +121,7 @@ for (const sample of matrix) {
userName: owner.userName, firstName: 'Åse', lastName: 'Oppsett', displayName: 'Åse Oppsett',
} })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile: { version: '1', contact: { fullName: 'Canonical career name' }, skills: ['Manually entered C#'] } } })).status()).toBe(200);
data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { version: '1', contact: { fullName: 'Canonical career name' }, skills: ['Manually entered C#'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Oppsett AS' } });
expect(company.status()).toBe(201);
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
@@ -256,7 +256,7 @@ test('[P7-02 PRES-S02 E08] recovery to four complete setup steps removes guidanc
userName: owner.userName, firstName: 'Åse', lastName: 'Oppsett', displayName: 'Åse Oppsett',
} })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile: { version: '1', skills: ['Manually entered SQL'] } } })).status()).toBe(200);
data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { version: '1', skills: ['Manually entered SQL'] } } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Complete setup AS' } });
expect(company.status()).toBe(201);
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers,
+1 -1
View File
@@ -62,7 +62,7 @@ async function seed(owner: Owner, long = false): Promise<Documents> {
'Recorded review and maintenance of existing services, preserving factual source material without inventing metrics or qualifications.'] : ['Recorded owner-reviewed source evidence.'], skills: ['C#'] })),
};
await json(await owner.api.put(`${apiUrl}/career/profile`, { headers: owner.headers,
data: { profile, cvText: 'Exact owner-reviewed original source — æøå\nRecorded work and qualifications.\n' } }));
data: { expectedVersion: (await (await owner.api.get(`${apiUrl}/career/profile`)).json()).revision, profile, cvText: 'Exact owner-reviewed original source — æøå\nRecorded work and qualifications.\n' } }));
const variant = await json<CvVariant>(await owner.api.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name: 'Recorded shared CV', settings: { themeId: 'minimal', language: 'en', showPhoto: false, showPageNumbers: false, showIcons: true,
sections: [], customSections: [], headline: 'Recorded CV-specific shared presentation',
@@ -34,7 +34,7 @@ const test = base.extend<{ headers: Record<string, string> }>({
? route.continue() : route.abort();
});
await context.addInitScript(() => localStorage.setItem("uiLanguage", "en"));
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: canonical } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: canonical } })).status()).toBe(200);
await provide(headers);
} finally { await admin.dispose(); }
},
@@ -86,8 +86,8 @@ test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves
const sibling = await siblingResponse.json();
const jobUrl = `${apiUrl}/jobapplications/${job.id}`;
expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: variant.id } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: "Original reviewed letter", source: "manual" } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: "Original answer — æøå", recruiterMessageDraft: "Original recruiter message" } })).status()).toBe(204);
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { expectedVersion: (await (await page.request.get(`${jobUrl}/cover-letter`)).json()).revision, text: "Original reviewed letter", source: "manual" } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { expectedVersion: (await (await page.request.get(`${jobUrl}`)).json()).version, applicationAnswerDraft: "Original answer — æøå", recruiterMessageDraft: "Original recruiter message" } })).status()).toBe(204);
expect((await page.request.get(`${jobUrl}/tailoring`)).status()).toBe(200);
const bytes = Buffer.from("Original attachment — æøå\n", "utf8");
const upload = await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(job.id), files: { name: "evidence.txt", mimeType: "text/plain", buffer: bytes } } });
@@ -111,10 +111,10 @@ test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves
expect(snapshot.cvVariantVersion).toBe(variant.version);
expect(before.applicationMaterialJson).toContain("Original answer");
expect(snapshot.attachments).toHaveLength(1);
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { text: "Later letter", source: "manual" } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/cover-letter`, { headers, data: { expectedVersion: (await (await page.request.get(`${jobUrl}/cover-letter`)).json()).revision, text: "Later letter", source: "manual" } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/cv/variants/${variant.id}`, { headers, data: { name: "Later CV", settings: { ...variant.settings, headline: "Later headline" }, expectedVersion: variant.version } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { ...canonical, contact: { ...canonical.contact, headline: "Later canonical headline" } } } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { applicationAnswerDraft: "Later answer", recruiterMessageDraft: "Later recruiter message" } })).status()).toBe(204);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { ...canonical, contact: { ...canonical.contact, headline: "Later canonical headline" } } } })).status()).toBe(200);
expect((await page.request.put(`${jobUrl}/application-drafts`, { headers, data: { expectedVersion: (await (await page.request.get(`${jobUrl}`)).json()).version, applicationAnswerDraft: "Later answer", recruiterMessageDraft: "Later recruiter message" } })).status()).toBe(204);
expect((await page.request.delete(`${apiUrl}/attachments/${attachment.id}`, { headers })).status()).toBe(204);
expect(await (await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}`)).json()).toEqual(before);
const archived = await page.request.get(`${jobUrl}/submitted-packages/${snapshot.id}/attachments/${snapshot.attachments[0].id}`);
@@ -123,7 +123,7 @@ test("[PRES-W01 PRES-W05 PRES-W06 PRES-W07 PRES-W08] legacy workspace preserves
expect((await page.request.put(`${jobUrl}/cv`, { headers, data: { variantId: null } })).status()).toBe(200);
expect((await page.request.get(`${apiUrl}/cv/variants/${variant.id}`)).status()).toBe(200);
expect(await (await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`)).json()).toEqual(sibling);
const restored = await page.request.post(`${jobUrl}/cover-letter/versions/1/restore`, { headers });
const restored = await page.request.post(`${jobUrl}/cover-letter/versions/1/restore`, { data: { expectedVersion: (await (await page.request.get(`${jobUrl}/cover-letter`)).json()).revision }, headers });
expect(restored.status()).toBe(200);
expect((await restored.json()).currentVersion).toBe(3);
expect((await (await page.request.get(`${jobUrl}/cover-letter`)).json()).text).toBe("Original reviewed letter");
@@ -41,7 +41,7 @@ const test = base.extend<{ owner: Owner }>({
summary: [sentence], jobs: Array.from({ length: 3 }, (_, index) => ({ id: `audit-role-${index}`, title: `Platform developer ${index + 1}`, company: 'Nordlys Audit AS', startDate: '2020-01', endDate: '2025-12', bullets: Array.from({ length: 4 }, () => sentence.repeat(3)) })),
education: [{ id: 'audit-education', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2019-06' }],
projects: [{ id: 'audit-project', name: 'Recorded accessibility project', bullets: [sentence] }], skills: ['C#', 'React', 'SQL'], languages: [{ language: 'Norsk', proficiency: 'Fluent' }] };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys samfunnskritiske plattformer og inkluderende digitale tjenester æøå' } });
expect(company.status()).toBe(201);
const companyId = (await company.json()).id;
@@ -193,7 +193,7 @@ test('[P6-04] measured native route, long CV and complete large Board workflows'
const sentence = 'Documented accessible services and reliable user workflows. Dokumenterte tilgjengelige tjenester og pålitelige arbeidsflyter. ';
profile.jobs = Array.from({ length: 8 }, (_, index) => ({ id: `performance-role-${index}`, title: `Measured platform developer ${index + 1}`, company: 'Nordlys Audit AS', startDate: '2020-01', endDate: '2025-12',
bullets: Array.from({ length: 4 }, (_, bullet) => `Evidence ${index + 1}.${bullet + 1}: ${sentence.repeat(3)}`) }));
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile } })).status()).toBe(200);
await context.addInitScript(() => {
const observations = { longTasks: [] as number[], layoutShift: 0 };
Object.assign(window, { phase6Performance: observations });
@@ -28,7 +28,7 @@ const test = base.extend<{ owner: { headers: Record<string, string>; companyId:
});
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS", location: "Tromsø" } });
expect(company.status()).toBe(201);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: {
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: {
version: "1", contact: { fullName: "Åse Eksempel", headline: "Engineer" },
jobs: [{ title: "Engineer", company: "Nordlys AS", bullets: ["Built reliable APIs"] }], skills: ["C#", "Norsk"],
} } })).status()).toBe(200);
+1 -1
View File
@@ -28,7 +28,7 @@ const test = base.extend<{ owner: { headers: Record<string, string>; jobId: numb
const companyId = (await company.json()).id;
const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: "Tilgjengelig plattformutvikler", status: "Saved" } });
expect(job.ok()).toBe(true);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200);
const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Readiness CV" } });
expect(cv.status()).toBe(200);
await provide({ headers, jobId: (await job.json()).id, companyId, cvId: (await cv.json()).id });
+1 -1
View File
@@ -493,7 +493,7 @@ test("the Code template exports a long structured CV as searchable multi-page PD
const profileResponse = await page.request.put(`${apiUrl}/career/profile`, {
headers,
data: { profile, cvText: "Long sanitized CV fixture for PDF verification." },
data: { expectedVersion: (await (await page.request.get(`${apiUrl}/career/profile`)).json()).revision, profile, cvText: "Long sanitized CV fixture for PDF verification." },
});
expect(profileResponse.ok()).toBeTruthy();
+52 -22
View File
@@ -52,6 +52,7 @@ const tailoring = {
};
const coverLetter = {
revision: 7,
text: "Dear team",
currentVersion: 2,
versions: [
@@ -232,7 +233,7 @@ test("editing marks the draft dirty and saving sends the new text", async () =>
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter",
{ text: "Dear hiring team", source: "manual", aiAction: undefined },
{ text: "Dear hiring team", source: "manual", aiAction: undefined, expectedVersion: 7 },
));
});
@@ -255,7 +256,7 @@ test("restoring an old version calls the restore endpoint", async () => {
fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter/versions/1/restore"));
"/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 }));
});
test("an empty cover letter offers the template and an empty history", async () => {
@@ -321,7 +322,7 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/cover-letter",
{ text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate" },
{ text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate", expectedVersion: 7 },
));
});
@@ -355,11 +356,11 @@ test("a failed load surfaces an error", async () => {
// ---------- Application package drafts ----------
test("application answer and recruiter drafts save from the dedicated workspace", async () => {
mockedApi.put.mockResolvedValue({ data: undefined } as any);
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
const onSaved = jest.fn();
render(
<ApplicationPackageDraftsSection
<ApplicationPackageDraftsSection initialRevision={7}
jobId={7}
initialApplicationAnswer="Saved answer"
initialRecruiterMessage="Saved recruiter note"
@@ -376,17 +377,17 @@ test("application answer and recruiter drafts save from the dedicated workspace"
"/jobapplications/7/application-drafts",
{
applicationAnswerDraft: "Edited answer",
recruiterMessageDraft: "Edited recruiter note",
recruiterMessageDraft: "Edited recruiter note", expectedVersion: 7,
},
));
expect(onSaved).toHaveBeenCalled();
});
test("application package drafts can be cleared without deleting ordinary notes", async () => {
mockedApi.put.mockResolvedValue({ data: undefined } as any);
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
render(
<ApplicationPackageDraftsSection
<ApplicationPackageDraftsSection initialRevision={7}
jobId={7}
initialApplicationAnswer="Saved answer"
initialRecruiterMessage="Saved recruiter note"
@@ -399,7 +400,7 @@ test("application package drafts can be cleared without deleting ordinary notes"
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/application-drafts",
{ applicationAnswerDraft: "", recruiterMessageDraft: "" },
{ applicationAnswerDraft: "", recruiterMessageDraft: "", expectedVersion: 7 },
));
});
@@ -428,7 +429,7 @@ test("failed cover-letter saving retains editable dirty text and original Save r
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
const payload = { text, source: "manual", aiAction: undefined };
const payload = { text, source: "manual", aiAction: undefined, expectedVersion: 7 };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/cover-letter", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/cover-letter", payload);
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
@@ -437,10 +438,10 @@ test("failed cover-letter saving retains editable dirty text and original Save r
});
test("failed application-answer saving keeps both dirty drafts editable and retries only the original save payload", async () => {
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204 } as any);
mockedApi.put.mockRejectedValueOnce({ response: { status: 503 } }).mockResolvedValueOnce({ status: 204, headers: { "x-job-version": "8" } } as any);
const onDirtyChange = jest.fn();
const onSaved = jest.fn();
render(<ApplicationPackageDraftsSection jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
render(<ApplicationPackageDraftsSection initialRevision={7} jobId={7} initialApplicationAnswer="Saved answer" initialRecruiterMessage="Saved message" onDirtyChange={onDirtyChange} onSaved={onSaved} />);
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Retained answer" } });
fireEvent.change(screen.getByLabelText(copy("assetsRecruiterMessage")), { target: { value: "Retained recruiter draft" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
@@ -456,7 +457,7 @@ test("failed application-answer saving keeps both dirty drafts editable and retr
expect(mockedApi.get).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft" };
const payload = { applicationAnswerDraft: "Retained answer", recruiterMessageDraft: "Retained recruiter draft", expectedVersion: 7 };
expect(mockedApi.put).toHaveBeenNthCalledWith(1, "/jobapplications/7/application-drafts", payload);
expect(mockedApi.put).toHaveBeenNthCalledWith(2, "/jobapplications/7/application-drafts", payload);
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
@@ -505,7 +506,7 @@ test("dirty version restore cancels without a write and confirmed held restore a
const confirm = within(confirmed).getByRole("button", { name: copy("assetsRestoreConfirm") });
act(() => { fireEvent.click(confirm); fireEvent.click(confirm); });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore");
expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/cover-letter/versions/1/restore", { expectedVersion: 7 });
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(screen.getByRole("button", { name: /Restore version 1/i })).toBeDisabled();
await act(async () => { write.resolve({ data: {
@@ -533,7 +534,7 @@ test("held letter Save submits one snapshot and prevents an AI Apply from replac
const save = section("workspaceCoverLetter").getByRole("button", { name: "Save" });
act(() => { fireEvent.click(save); fireEvent.click(save); });
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined });
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Explicit manual save snapshot", source: "manual", aiAction: undefined, expectedVersion: 7 });
expect(apply).toBeDisabled();
fireEvent.click(apply);
expect(screen.getByRole("textbox", { name: "Cover letter" })).toHaveValue("Explicit manual save snapshot");
@@ -563,7 +564,7 @@ test("held AI generation locks its controls and preserves captured action attrib
fireEvent.click(screen.getByRole("button", { name: /Apply to editor/i }));
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Captured generated suggestion" } } as any);
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Captured generated suggestion", source: "ai", aiAction: "generate", expectedVersion: 7 }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
@@ -719,20 +720,20 @@ test("keyed job changes keep job B's answer drafts and callbacks independent of
const onSavedA = jest.fn();
const onSavedB = jest.fn();
const onDirtyB = jest.fn();
const mounted = render(<ApplicationPackageDraftsSection key={7} jobId={7}
const mounted = render(<ApplicationPackageDraftsSection initialRevision={7} key={7} jobId={7}
initialApplicationAnswer="Saved A answer" initialRecruiterMessage="Saved A message" onSaved={onSavedA} />);
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Pending A answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Pending A message" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", {
applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message",
applicationAnswerDraft: "Pending A answer", recruiterMessageDraft: "Pending A message", expectedVersion: 7,
});
mounted.rerender(withProviders(<ApplicationPackageDraftsSection key={8} jobId={8}
mounted.rerender(withProviders(<ApplicationPackageDraftsSection initialRevision={7} key={8} jobId={8}
initialApplicationAnswer="Saved B answer" initialRecruiterMessage="Saved B message" onSaved={onSavedB} onDirtyChange={onDirtyB} />));
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") }), { target: { value: "Unsaved B answer" } });
fireEvent.change(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") }), { target: { value: "Unsaved B message" } });
await act(async () => { saveA.resolve({ data: undefined }); await saveA.promise; });
await act(async () => { saveA.resolve({ data: undefined, headers: { "x-job-version": "8" } }); await saveA.promise; });
expect(screen.getByRole("textbox", { name: copy("assetsApplicationAnswer") })).toHaveValue("Unsaved B answer");
expect(screen.getByRole("textbox", { name: copy("assetsRecruiterMessage") })).toHaveValue("Unsaved B message");
expect(screen.getByRole("button", { name: copy("assetsSaveDrafts") })).toBeEnabled();
@@ -741,11 +742,40 @@ test("keyed job changes keep job B's answer drafts and callbacks independent of
expect(onSavedB).not.toHaveBeenCalled();
expect(mockedApi.put).toHaveBeenCalledTimes(1);
mockedApi.put.mockResolvedValueOnce({ data: undefined } as any);
mockedApi.put.mockResolvedValueOnce({ data: undefined, headers: { "x-job-version": "8" } } as any);
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(onSavedB).toHaveBeenCalledTimes(1));
expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/8/application-drafts", {
applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message",
applicationAnswerDraft: "Unsaved B answer", recruiterMessageDraft: "Unsaved B message", expectedVersion: 7,
});
expect(onSavedA).not.toHaveBeenCalled();
});
test("a parent refresh cannot attach a newer revision to dirty application drafts", async () => {
mockedApi.put.mockResolvedValue({ data: undefined, headers: { "x-job-version": "8" } } as any);
const mounted = render(<ApplicationPackageDraftsSection jobId={7} initialRevision={7} initialApplicationAnswer="Original answer" initialRecruiterMessage="Original message" />);
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Local answer" } });
mounted.rerender(withProviders(<ApplicationPackageDraftsSection jobId={7} initialRevision={11} initialApplicationAnswer="Remote answer" initialRecruiterMessage="Remote message" />));
expect(screen.getByLabelText(copy("assetsApplicationAnswer"))).toHaveValue("Local answer");
expect(screen.getByLabelText(copy("assetsRecruiterMessage"))).toHaveValue("Original message");
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/application-drafts", { applicationAnswerDraft: "Local answer", recruiterMessageDraft: "Original message", expectedVersion: 7 }));
await waitFor(() => expect(screen.queryByText(copy("assetsUnsavedChanges"))).not.toBeInTheDocument());
fireEvent.change(screen.getByLabelText(copy("assetsApplicationAnswer")), { target: { value: "Next answer" } });
fireEvent.click(screen.getByRole("button", { name: copy("assetsSaveDrafts") }));
await waitFor(() => expect(mockedApi.put).toHaveBeenLastCalledWith("/jobapplications/7/application-drafts", { applicationAnswerDraft: "Next answer", recruiterMessageDraft: "Original message", expectedVersion: 8 }));
});
test.each([409, 0])("cover-letter rejection (%s) retains draft and source revision without a fresh read", async (status) => {
routeGet(); mockedApi.put.mockRejectedValue({ response: { status, data: { code: "revision_conflict" } } });
render(<ApplicationCoverLetterSection jobId={7} />);
fireEvent.change(await screen.findByRole("textbox", { name: copy("jobDetailsCoverLetter") }), { target: { value: "Retained conflict draft" } });
const reads = mockedApi.get.mock.calls.length;
fireEvent.click(section("workspaceCoverLetter").getByRole("button", { name: copy("save") }));
await section("workspaceCoverLetter").findByRole("alert");
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("Retained conflict draft");
expect(screen.getByText(copy("assetsUnsavedChanges"))).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cover-letter", { text: "Retained conflict draft", source: "manual", aiAction: undefined, expectedVersion: 7 });
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
if (status === 409) expect(section("workspaceCoverLetter").getByRole("alert")).toHaveTextContent(copy("revisionConflict"));
});
+12 -6
View File
@@ -14,6 +14,7 @@ export type WorkspaceNextStep = { key: string; label: string; reason: string; se
export type WorkspaceOverview = {
id: number;
revision: number;
jobTitle: string;
company: string | null;
location: string | null;
@@ -252,6 +253,7 @@ export type CoverLetterVersion = {
};
export type CoverLetter = {
revision: number;
text: string | null;
currentVersion: number;
versions: CoverLetterVersion[];
@@ -295,10 +297,10 @@ export const applicationAssetsApi = {
api.get<TailoringPlan>(`/jobapplications/${jobId}/tailoring`).then((r) => r.data),
coverLetter: (jobId: number) =>
api.get<CoverLetter>(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data),
saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) =>
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
restoreCoverLetter: (jobId: number, version: number) =>
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
saveCoverLetter: (jobId: number, text: string, expectedVersion: number, source = "manual", aiAction?: string) =>
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction, expectedVersion }).then((r) => r.data),
restoreCoverLetter: (jobId: number, version: number, expectedVersion: number) =>
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`, { expectedVersion }).then((r) => r.data),
submittedPackages: (jobId: number) =>
api.get<SubmittedPackage[]>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
captureSubmittedPackage: (jobId: number) =>
@@ -307,8 +309,12 @@ export const applicationAssetsApi = {
api.get<SubmittedPackageDetail>(`/jobapplications/${jobId}/submitted-packages/${packageId}`).then((r) => r.data),
downloadSubmittedAttachment: (jobId: number, packageId: number, attachmentId: number) =>
api.get<Blob>(`/jobapplications/${jobId}/submitted-packages/${packageId}/attachments/${attachmentId}`, { responseType: "blob" }).then((r) => r.data),
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) =>
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined),
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string, expectedVersion: number) =>
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft, expectedVersion }).then((response) => {
const revision = Number(response.headers?.["x-job-version"]);
if (!Number.isInteger(revision) || revision <= 0) throw new Error("Missing committed job revision.");
return revision;
}),
};
// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come
+49 -9
View File
@@ -29,7 +29,7 @@ function deferred<T>() {
}
function sourceResponse() {
return { data: { profile, cvText: rawText, completeness: { percent: 72, missing: [], sections: [] } } };
return { data: { revision: 7, profile, cvText: rawText, completeness: { percent: 72, missing: [], sections: [] } } };
}
function renderPage() {
@@ -88,10 +88,10 @@ beforeEach(() => {
{ version: 3, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false },
] } as any);
if (url === "/profile-cv/runs") return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
return Promise.resolve({ data: { revision: 8 } } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.put.mockResolvedValue({ data: { revision: 8 } } as any);
mockedApi.post.mockResolvedValue({ data: { revision: 8 } } as any);
});
test("a locale update retains dirty structured and raw drafts without reading their source again", async () => {
@@ -124,7 +124,7 @@ test("a pending initial read keeps one request and reports its failure in the la
expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeDisabled();
expect(screen.queryByRole("button", { name: en.saving })).not.toBeInTheDocument();
await changeLocale();
await act(async () => { pending.reject({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } }); });
await act(async () => { pending.reject({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: { revision: 8 } } }); });
expect(readCount("/career/profile")).toBe(1);
expect(readCount("/auth/me")).toBe(1);
@@ -164,7 +164,7 @@ test("a held Save locks every editor mutation, raw input and history restore unt
expect(screen.getByLabelText(en.profileCvTextLabel)).toBeDisabled();
expect(mockedApi.post).not.toHaveBeenCalled();
} finally {
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
await act(async () => { pending.resolve({ data: { revision: 8 } }); await pending.promise; });
}
fireEvent.click(screen.getByRole("tab", { name: en.careerWorkspaceProfileTab }));
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Snapshot owner");
@@ -187,13 +187,13 @@ test("two Save activations before React commits pending state send only one cano
expect(mockedApi.put).toHaveBeenCalledTimes(1);
expect(mockedApi.post).not.toHaveBeenCalled();
} finally {
await act(async () => { pending.resolve({ data: {} }); await pending.promise; });
await act(async () => { pending.resolve({ data: { revision: 8 } }); await pending.promise; });
}
});
test("a failed Save retains a scoped localized error and draft for retry through the same Save button", async () => {
localStorage.setItem("uiLanguage", "nb");
mockedApi.put.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: {} } });
mockedApi.put.mockRejectedValueOnce({ isAxiosError: true, message: "Request failed with status code 500", response: { status: 500, data: { revision: 8 } } });
renderPage();
await waitFor(() => expect(screen.getByLabelText(nb.profileCvContactFullName)).toHaveValue("Recorded owner"));
fireEvent.change(screen.getByLabelText(nb.profileCvContactFullName), { target: { value: "Beholdt utkast" } });
@@ -259,12 +259,52 @@ test("manual edits preserve long-tail data, custom links, opaque IDs and modeled
awards: ["Recorded award", "Manual award"],
contact: { ...profile.contact, links: [{ label: "Edited portfolio", url: "https://example.test/portfolio" }] },
jobs: [{ ...profile.jobs[0], title: "Edited role" }],
}, cvText: "Manually retained source\nNo extraction requested" });
}, cvText: "Manually retained source\nNo extraction requested", expectedVersion: 7 });
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test.each([409, 0])("a rejected save (%s) keeps the draft and its original revision for deliberate retry", async (status) => {
mockedApi.put.mockRejectedValueOnce({ response: { status, data: { code: "revision_conflict" } } });
renderPage();
await ready();
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Recoverable draft" } });
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await editor().findByRole("alert");
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Recoverable draft");
expect(screen.getByText(en.assetsUnsavedChanges)).toBeInTheDocument();
expect(mockedApi.put).toHaveBeenLastCalledWith("/career/profile", expect.objectContaining({ expectedVersion: 7 }));
expect(readCount("/career/profile")).toBe(1);
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put.mock.calls[1][1]).toEqual(mockedApi.put.mock.calls[0][1]);
});
test("consecutive acknowledged career saves advance the revision returned with the saved snapshot", async () => {
mockedApi.put.mockResolvedValueOnce({ data: { ...sourceResponse().data, revision: 8 } } as any);
renderPage(); await ready();
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await waitFor(() => expect(screen.getByRole("button", { name: en.profileSaveChanges })).toBeEnabled());
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Next snapshot" } });
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put.mock.calls.map(([, body]) => (body as any).expectedVersion)).toEqual([7, 8]);
});
test("a delayed prior lifecycle read cannot replace edits in the current career editor", async () => {
const oldRead = deferred<any>(); let reads = 0;
const get = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url === "/career/profile" && ++reads === 1 ? oldRead.promise : get(url));
render(<React.StrictMode><ToastProvider><I18nProvider><CareerProfilePage /></I18nProvider></ToastProvider></React.StrictMode>);
await ready();
fireEvent.change(screen.getByLabelText(en.profileCvContactFullName), { target: { value: "Current lifecycle draft" } });
await act(async () => oldRead.resolve({ data: { ...sourceResponse().data, revision: 2, profile: { ...profile, contact: { ...profile.contact, fullName: "Late old source" } } } }));
expect(screen.getByLabelText(en.profileCvContactFullName)).toHaveValue("Current lifecycle draft");
fireEvent.click(screen.getByRole("button", { name: en.profileSaveChanges }));
expect(mockedApi.put).toHaveBeenCalledWith("/career/profile", expect.objectContaining({ expectedVersion: 7 }));
});
test("a read-only provider cannot edit canonical fields or trigger canonical writes", async () => {
provider = "external";
renderPage();
@@ -18,7 +18,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
const en = translations.en;
const nb = translations.nb;
const profile = { ...emptyStructuredCv(), contact: { fullName: "Recorded owner" }, languages: [{ name: "Norwegian", level: "A2" }] };
const source = { data: { profile, cvText: "Retained canonical raw", completeness: { percent: 50, missing: [], sections: [] } } };
const source = { data: { revision: 7, profile, cvText: "Retained canonical raw", completeness: { percent: 50, missing: [], sections: [] } } };
const versions = [
{ version: 2, source: "manual", createdAtUtc: "2026-10-12T01:00:00Z", isCurrent: true },
{ version: 1, source: "manual", createdAtUtc: "2026-10-11T01:00:00Z", isCurrent: false },
@@ -69,7 +69,7 @@ beforeEach(() => {
if (url === "/auth/me") return Promise.resolve({ data: { id: "owner", provider: "local", email: "owner@example.test" } } as any);
if (url === "/career/profile/versions") return Promise.resolve({ data: versions } as any);
if (url === "/profile-cv/runs") return Promise.resolve({ data: runs } as any);
if (url.endsWith("/diff")) return Promise.resolve({ data: { runId: Number(url.split("/")[3]), status: "pending_review", diff } } as any);
if (url.endsWith("/diff")) return Promise.resolve({ data: { runId: Number(url.split("/")[3]), status: "pending_review", revision: 7, diff } } as any);
return Promise.reject(new Error(`Unexpected GET ${url}`));
});
mockedApi.put.mockResolvedValue(source as any);
@@ -135,7 +135,7 @@ test("selecting then deselecting low-confidence data sends the explicit empty se
fireEvent.click(chooseLow()); expect(chooseLow()).not.toBeChecked();
runs = [{ ...run, status: "applied" }];
fireEvent.click(screen.getByRole("button", { name: en.profileCvApplyChanges }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { acceptedLowConfidenceIds: [] }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { expectedVersion: 7, acceptedLowConfidenceIds: [] }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
@@ -145,7 +145,7 @@ test("held acceptance locks review choices and all canonical editors and prevent
const apply = screen.getByRole("button", { name: en.profileCvApplyChanges }); apply.focus(); fireEvent.click(apply); fireEvent.click(apply);
expect(screen.getByRole("article", { name: "File import 41" })).toHaveFocus();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { acceptedLowConfidenceIds: ["Languages|french"] });
expect(mockedApi.post).toHaveBeenCalledWith("/profile-cv/runs/41/accept", { expectedVersion: 7, acceptedLowConfidenceIds: ["Languages|french"] });
expect(chooseLow()).toBeDisabled(); expect(screen.getByRole("button", { name: en.careerImportApplying })).toBeDisabled();
navigate(en.careerWorkspaceProfileTab);
expect(screen.getByLabelText(en.profileCvContactFullName)).toBeDisabled();
@@ -241,7 +241,7 @@ test("a failed operation retry stays scoped and only resends the same operation
test("Bokmål localizes comparison labels while preserving imported facts named like UI labels or object properties", async () => {
localStorage.setItem("uiLanguage", "nb");
const read = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, diff: {
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, revision: 7, diff: {
totalAdded: 2, totalUpdated: 0, totalLowConfidence: 1, hasChanges: true, categories: [
{ category: "Projects", added: [{ id: "Projects|references", label: "References", confidence: "Medium", fieldChanges: [] }], updated: [], unchangedCount: 0, lowConfidenceCount: 0 },
{ category: "Languages", added: [{ id: "Languages|constructor", label: "constructor", confidence: "Low", fieldChanges: [] }], updated: [], unchangedCount: 0, lowConfidenceCount: 1 },
@@ -259,7 +259,7 @@ test("Bokmål localizes comparison labels while preserving imported facts named
test("the known low-confidence contact placeholder is localized", async () => {
localStorage.setItem("uiLanguage", "nb");
const read = mockedApi.get.getMockImplementation()!;
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, diff: {
mockedApi.get.mockImplementation((url: string) => url.endsWith("/diff") ? Promise.resolve({ data: { runId: 41, revision: 7, diff: {
totalAdded: 0, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [
{ category: "Contact", added: [], updated: [{ id: "Contact", label: "Contact details", confidence: "Low", fieldChanges: [{ field: "Email", oldValue: "current@example.test", newValue: "pending@example.test" }] }], unchangedCount: 0, lowConfidenceCount: 1 },
],
@@ -17,7 +17,7 @@ jest.mock("./cvBuilder", () => ({
const mockedList = cvBuilderApi.list as jest.MockedFunction<typeof cvBuilderApi.list>;
const mockedApi = api as jest.Mocked<typeof api>;
const careerResponse = { profile: { version: "1", contact: { fullName: "Stored career name" }, skills: ["Stored C#"] }, completeness: { percent: 70, missing: ["Projects"], sections: [] }, cvText: "Stored raw text" };
const careerResponse = { revision: 7, profile: { version: "1", contact: { fullName: "Stored career name" }, skills: ["Stored C#"] }, completeness: { percent: 70, missing: ["Projects"], sections: [] }, cvText: "Stored raw text" };
function renderOverview({
percent = 0,
@@ -38,7 +38,7 @@ type AssetError = ViewResourceError & { messageKey: TranslationKey; retry?: () =
function assetError(error: unknown, messageKey: TranslationKey, retryable = false): AssetError {
const status = (error as { response?: { status?: number } })?.response?.status;
const unauthorized = status === 401 || status === 403;
return { kind: unauthorized ? "unauthorized" : "error", message: getApiErrorMessage(error, ""), messageKey, retryable: retryable && !unauthorized, status };
return { kind: unauthorized ? "unauthorized" : "error", message: status === 409 ? "" : getApiErrorMessage(error, ""), messageKey: status === 409 ? "revisionConflict" : messageKey, retryable: retryable && !unauthorized, status };
}
function useAsset<T>(load: () => Promise<T>, deps: React.DependencyList) {
@@ -538,7 +538,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
contentRef.current?.focus();
setBusy(true);
try {
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction);
const next = await applicationAssetsApi.saveCoverLetter(jobId, value, data.revision, source, aiAction);
if (!isCurrent()) return;
setData(next);
setDraft(null);
@@ -568,7 +568,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
if (!isCurrent()) return;
contentRef.current?.focus();
setBusy(true);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version);
const next = await applicationAssetsApi.restoreCoverLetter(jobId, version, data.revision);
if (!isCurrent()) return;
setData(next);
setDraft(null);
@@ -832,18 +832,21 @@ export function ApplicationPackageDraftsSection({
jobId,
initialApplicationAnswer,
initialRecruiterMessage,
initialRevision,
onSaved,
onDirtyChange,
}: {
jobId: number;
initialApplicationAnswer: string;
initialRecruiterMessage: string;
initialRevision: number;
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const { t } = useI18n();
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [revision, setRevision] = useState(initialRevision);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<AssetError | null>(null);
@@ -853,10 +856,10 @@ export function ApplicationPackageDraftsSection({
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => {
if (draft === null) setSaved(initial);
if (draft === null) { setSaved(initial); setRevision(initialRevision); }
// `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobId, initialApplicationAnswer, initialRecruiterMessage]);
}, [jobId, initialApplicationAnswer, initialRecruiterMessage, initialRevision]);
const value = draft ?? saved;
const dirty = draft !== null && (
@@ -876,8 +879,9 @@ export function ApplicationPackageDraftsSection({
contentRef.current?.focus();
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
const nextRevision = await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage, revision);
if (!active.current) return;
setRevision(nextRevision);
setSaved(value);
setDraft(null);
setError(null);
+49 -32
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Autocomplete,
@@ -71,6 +71,9 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const { toast } = useToast();
const { t } = useI18n();
const [loading, setLoading] = useState(false);
const [version, setVersion] = useState<number | null>(null);
const session = useRef(0);
const saving = useRef(false);
const { companies } = useCompanies();
const [company, setCompany] = useState<Company | null>(null);
@@ -103,10 +106,15 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
const [hasOtherAttachment, setHasOtherAttachment] = useState(false);
useEffect(() => {
const current = ++session.current;
saving.current = false;
setVersion(null);
if (!open || !jobId) return;
setLoading(true);
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
if (current !== session.current) return;
const j = r.data;
setVersion(j.version);
setCompany(j.company ?? null);
setJobTitle(j.jobTitle ?? "");
setStatus(j.status ?? "Applied");
@@ -135,16 +143,24 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
setHasCoverLetter(Boolean((j as any).hasCoverLetter));
setHasPortfolio(Boolean((j as any).hasPortfolio));
setHasOtherAttachment(Boolean((j as any).hasOtherAttachment));
}).finally(() => setLoading(false));
}).catch(() => {
if (current === session.current) toast(t("editJobSaveFailed"), "error");
}).finally(() => { if (current === session.current) setLoading(false); });
return () => { session.current = current + 1; };
// Locale updates must retain the loaded draft.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, jobId]);
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && !loading, [company, jobTitle, loading]);
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && version != null && !loading, [company, jobTitle, loading, version]);
const save = async () => {
if (!jobId || !company?.id) return;
if (!jobId || !company?.id || !canSave || saving.current) return;
const current = session.current;
saving.current = true;
setLoading(true);
try {
await api.put(`/jobapplications/${jobId}`, {
expectedVersion: version,
jobTitle: jobTitle.trim(),
companyId: company.id,
status,
@@ -169,18 +185,19 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
dateApplied: dateApplied || null,
jobUrl: jobUrl.trim() || null,
});
if (current !== session.current) return;
toast(t("save"), "success");
onSaved();
onClose();
} catch {
toast(t("editJobSaveFailed"), "error");
} catch (error) {
if (current === session.current) toast(t((error as { response?: { status?: number } }).response?.status === 409 ? "revisionConflict" : "editJobSaveFailed"), "error");
} finally {
setLoading(false);
if (current === session.current) { saving.current = false; setLoading(false); }
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<Dialog open={open} onClose={() => { if (!saving.current) onClose(); }} fullWidth maxWidth="md">
<DialogTitle>{t("editJobTitle")}</DialogTitle>
<DialogContent>
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.paper" }}>
@@ -193,48 +210,48 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobApplicationDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
<TextField label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
<Autocomplete disabled={loading} options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label={t("company")} sx={FIELD_SX} />} />
<TextField disabled={loading} label={t("editJobJobTitle")} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} sx={FIELD_SX} />
<DatePicker disabled={loading} label={t("editJobAppliedOn")} value={parsePickerDate(dateApplied)} onChange={(value) => setDateApplied(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("addJobModalJobUrl")} value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} sx={FIELD_SX} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobStatusUpdate")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
<TextField disabled={loading} select label={t("editJobCurrentStatus")} value={status} onChange={(e) => setStatus(e.target.value)} sx={FIELD_SX}>
{PIPELINE_STATUSES.map((s) => <MenuItem key={s} value={s}>{statusLabel(t, s)}</MenuItem>)}
</TextField>
<DatePicker label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<DatePicker label={t("editJobReplyReceivedOn")} disabled={!responseReceived} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
<DatePicker label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<DatePicker disabled={loading} label={t("editJobStatusChangedOn")} value={parsePickerDate(statusChangedAt)} onChange={(value) => setStatusChangedAt(toPickerIso(value))} slotProps={{ textField: { ...PICKER_TEXT_FIELD_PROPS, helperText: status === initialStatus ? t("editJobStatusChangedHelpIdle") : t("editJobStatusChangedHelpActive") } }} />
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox disabled={loading} checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label={t("editJobReplyReceived")} /></Box>
<DatePicker disabled={loading || !responseReceived} label={t("editJobReplyReceivedOn")} value={parsePickerDate(responseDate)} onChange={(value) => setResponseDate(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("editJobNextAction")} value={nextAction} onChange={(e) => setNextAction(e.target.value)} sx={FIELD_SX} />
<DatePicker disabled={loading} label={t("editJobFollowUpOn")} value={parsePickerDate(followUpAt)} onChange={(value) => setFollowUpAt(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
</Box>
</Paper>
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobRoleDetails")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
<TextField label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<TextField disabled={loading} label={t("location")} value={location} onChange={(e) => setLocation(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("addJobModalSalary")} value={salary} onChange={(e) => setSalary(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryMinLabel")} type="number" value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryMaxLabel")} type="number" value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} sx={FIELD_SX} />
<TextField disabled={loading} label={t("salaryCurrencyLabel")} value={salaryCurrency} onChange={(e) => setSalaryCurrency(e.target.value)} sx={FIELD_SX} inputProps={{ maxLength: 8 }} />
<TextField disabled={loading} select SelectProps={{ native: true }} label={t("salaryPeriodLabel")} value={salaryPeriod} onChange={(e) => setSalaryPeriod(e.target.value)} sx={FIELD_SX} InputLabelProps={{ shrink: true }}>
<option value=""></option>
<option value="year">{t("salaryPeriodYear")}</option>
<option value="month">{t("salaryPeriodMonth")}</option>
<option value="hour">{t("salaryPeriodHour")}</option>
</TextField>
<DatePicker label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
<TextField label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobTranslatedDescription")} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField label={t("editJobCoverLetter")} value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
<DatePicker disabled={loading} label={t("editJobDeadline")} value={parsePickerDate(deadline)} onChange={(value) => setDeadline(toPickerIso(value))} slotProps={{ textField: PICKER_TEXT_FIELD_PROPS }} />
<TextField disabled={loading} label={t("editJobDescriptionLanguage")} value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} sx={FIELD_SX} />
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput disabled={loading} value={tags} onChange={setTags} /></Box>
<TextField disabled={loading} label={t("editJobNotes")} value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={t("correspondenceCharacters", { count: notes.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobDescriptionOriginal")} value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: description.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobTranslatedDescription")} value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: translatedDescription.length })} sx={{ gridColumn: "1 / -1" }} />
<TextField disabled={loading} label={t("editJobCoverLetter")} value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={t("correspondenceCharacters", { count: coverLetterText.length })} sx={{ gridColumn: "1 / -1" }} />
</Box>
</Paper>
@@ -252,7 +269,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("cancel")}</Button>
<Button onClick={onClose} disabled={loading}>{t("cancel")}</Button>
<Button variant="contained" onClick={save} disabled={!canSave}>{t("save")}</Button>
</DialogActions>
</Dialog>
@@ -11,7 +11,7 @@ import { checkSessionCookie, getSessionContext, isSessionInvalidated, SESSION_IN
// savedAt is omitted alongside id: both are assigned by the server on create, so an imported
// row has no business supplying one.
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt" | "version"> & {
company: Pick<Company, "name" | "location" | "source">;
};
@@ -7,14 +7,17 @@ export default function TagsInput({
label,
value,
onChange,
disabled = false,
}: {
label?: string;
value: string[];
onChange: (next: string[]) => void;
disabled?: boolean;
}) {
const { t } = useI18n();
return (
<Autocomplete
disabled={disabled}
multiple
freeSolo
options={[]}
@@ -0,0 +1,52 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
import EditJobDialog from "./components/EditJobDialog";
jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn() } }));
jest.mock("./hooks/useCompanies", () => ({ useCompanies: () => ({ companies: [{ id: 2, name: "Synthetic company" }] }) }));
const client = api as jest.Mocked<typeof api>;
function deferred() { let resolve!: (value: any) => void; const promise = new Promise<any>((done) => { resolve = done; }); return { promise, resolve }; }
const job = (id: number, version: number, jobTitle: string) => ({ id, version, jobTitle, status: "Applied", company: { id: 2, name: "Synthetic company" }, dateApplied: "2026-10-01" });
const view = (id: number, onSaved = jest.fn(), onClose = jest.fn()) => <LocalizationProvider dateAdapter={AdapterDateFns}><ToastProvider><I18nProvider><EditJobDialog open jobId={id} onSaved={onSaved} onClose={onClose} /></I18nProvider></ToastProvider></LocalizationProvider>;
beforeEach(() => { jest.resetAllMocks(); localStorage.clear(); client.get.mockResolvedValue({ data: job(1, 7, "Recorded title") } as any); });
test.each([409, 0])("job save rejection (%s) preserves the draft and sends the source revision", async (status) => {
client.put.mockRejectedValue({ response: { status, data: { code: "revision_conflict" } } });
const onSaved = jest.fn(); const onClose = jest.fn();
render(view(1, onSaved, onClose));
await screen.findByDisplayValue("Recorded title");
fireEvent.change(screen.getByLabelText("Job title"), { target: { value: "Recoverable title" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Save" })).toBeEnabled());
expect(screen.getByLabelText("Job title")).toHaveValue("Recoverable title");
expect(client.put).toHaveBeenCalledWith("/jobapplications/1", expect.objectContaining({ jobTitle: "Recoverable title", expectedVersion: 7 }));
expect(onSaved).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
});
test("a held job save excludes editing and closing until the captured snapshot settles", async () => {
const pending = deferred(); client.put.mockReturnValue(pending.promise);
const onClose = jest.fn(); render(view(1, jest.fn(), onClose));
await screen.findByDisplayValue("Recorded title");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(screen.getByLabelText("Job title")).toBeDisabled();
expect(screen.getByRole("combobox", { name: "Current status" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: undefined, headers: { "x-job-version": "8" } }));
});
test("a delayed old job read cannot overwrite the newer job session", async () => {
const oldRead = deferred(); client.get.mockReturnValueOnce(oldRead.promise).mockResolvedValueOnce({ data: job(2, 11, "New session title") } as any);
const mounted = render(view(1)); mounted.rerender(view(2));
await screen.findByDisplayValue("New session title");
fireEvent.change(screen.getByLabelText("Job title"), { target: { value: "New session draft" } });
await act(async () => oldRead.resolve({ data: job(1, 7, "Late old title") }));
expect(screen.getByLabelText("Job title")).toHaveValue("New session draft");
});
+2
View File
@@ -2558,6 +2558,7 @@ export const translations = {
editJobPortfolio: "Portfolio",
editJobOtherAttachment: "Other attachment",
editJobSaveFailed: "Save failed.",
revisionConflict: "This record changed elsewhere. Your draft is retained. Reload and review the latest version before saving.",
savedViewsTooltip: "Saved views",
savedViewsTitle: "Saved views",
savedViewsSubtitle: "Save the current filters as a 1-click view.",
@@ -5431,6 +5432,7 @@ export const translations = {
editJobPortfolio: "Portefølje",
editJobOtherAttachment: "Annet vedlegg",
editJobSaveFailed: "Lagring mislyktes.",
revisionConflict: "Denne oppføringen ble endret et annet sted. Utkastet ditt er beholdt. Last inn og gjennomgå siste versjon før du lagrer.",
savedViewsTooltip: "Lagrede visninger",
savedViewsTitle: "Lagrede visninger",
savedViewsSubtitle: "Lagre gjeldende filtre som en visning med ett klikk.",
@@ -31,6 +31,7 @@ function copy(key: string, language: UiLanguage = "en", params: Record<string, n
function offer(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
return {
version: 1,
id, jobTitle: `Offer role ${id}`, company: { id, name: `Company ${id}` }, status: "Offer",
dateApplied: "2026-08-01", savedAt: "2026-07-20", responseReceived: true, daysSince: 10,
...overrides,
@@ -63,6 +63,7 @@ test("[P7-11 reminders] generated fixed and parameterized reasons follow locale
function job(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
return {
version: 1,
id, jobTitle: `Reminder role ${id}`, company: { id, name: `Company ${id}` }, status: "Waiting",
dateApplied: "2026-11-01", savedAt: "2026-10-20", responseReceived: false, daysSince: 14,
needsFollowUp: true, followUpAt: "2026-11-17", followUpReason: "Legacy source reason",
+1
View File
@@ -5,6 +5,7 @@ const DEFAULT_SECTION_ORDER = ["summary", "skills", "experience", "education", "
export function emptyTailoredCvDraft(): TailoredCvDraft {
return {
revision: null,
templateId: "ats-minimal",
headline: "",
summary: [],
+2
View File
@@ -62,6 +62,7 @@ export interface TailoredCvRenderOptions {
}
export interface TailoredCvDraft {
revision: number | null;
id?: number | null;
canonicalProfileVersion?: number | null;
templateId: string;
@@ -82,6 +83,7 @@ export interface TailoredCvDraft {
export interface JobApplication {
id: number;
version: number;
jobTitle: string;
company: Company;
companyId?: number;
@@ -264,6 +264,7 @@ export function ApplicationWorkspace({
{section === "application" && subsection === "answers" && overview && jobId > 0 && <ApplicationPackageDraftsSection
key={jobId}
jobId={jobId}
initialRevision={overview.revision}
initialApplicationAnswer={overview?.applicationAnswerDraft ?? ""}
initialRecruiterMessage={overview?.recruiterMessageDraft ?? ""}
onSaved={load}
+35 -18
View File
@@ -68,7 +68,7 @@ type CvImportDiff = {
}>;
};
type CvRunDiffResponse = { runId: number; status: string; diff: CvImportDiff };
type CvRunDiffResponse = { runId: number; status: string; diff: CvImportDiff; revision: number };
type QueuedCvRunResponse = {
queued: boolean;
@@ -146,7 +146,7 @@ function initialsFrom(values: Array<string | undefined>) {
// Phase 3: the master profile now comes from the relational source of truth via /career/profile.
type CareerSectionStatus = { key: string; label: string; complete: boolean; count: number };
type CareerCompleteness = { percent: number; missing: string[]; sections: CareerSectionStatus[] };
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null };
type CareerProfileResponse = { profile: StructuredCvProfile; completeness: CareerCompleteness; cvText?: string | null; revision: number };
type CareerVersion = { version: number; source: string; createdAtUtc: string; isCurrent: boolean };
type CareerWorkspaceSection = "overview" | "profile" | "evidence" | "import";
@@ -175,6 +175,8 @@ export default function CareerProfilePage() {
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const profileMutationPending = useRef(false);
const [profileRevision, setProfileRevision] = useState<number | null>(null);
const profileReadId = useRef(0);
const [uploadingCv, setUploadingCv] = useState(false);
const [improvingCv, setImprovingCv] = useState(false);
const [rebuildingCv, setRebuildingCv] = useState(false);
@@ -231,7 +233,9 @@ export default function CareerProfilePage() {
setLoading(true);
setRestoreError(null);
try {
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`, { expectedVersion: profileRevision });
setProfileRevision(r.data.revision);
setProfileCvText(r.data.cvText ?? "");
setStructuredCv(normalizeStructuredCv(r.data?.profile ?? emptyStructuredCv()));
setCompleteness(r.data?.completeness ?? null);
setProfileDirty(false);
@@ -239,19 +243,20 @@ export default function CareerProfilePage() {
await loadVersions();
toast(t("profileUpdated"), "success");
} catch (error) {
const message = getApiErrorMessage(error, "");
const message = (error as { response?: { status?: number } }).response?.status === 409 ? "revision_conflict" : getApiErrorMessage(error, "");
setRestoreError(message);
toast(message || translationRef.current("careerRestoreFailed"), "error");
toast(message === "revision_conflict" ? translationRef.current("revisionConflict") : message || translationRef.current("careerRestoreFailed"), "error");
} finally {
profileMutationPending.current = false;
setLoading(false);
}
}, [loadVersions, loading, me?.provider, profileDirty, t, toast]);
}, [loadVersions, loading, me?.provider, profileDirty, profileRevision, t, toast]);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const [runsLoading, setRunsLoading] = useState(true);
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
const runsReadId = useRef(0);
const [runDiffs, setRunDiffs] = useState<Record<number, CvImportDiff>>({});
const runDiffRevisions = useRef<Record<number, number>>({});
const [runDiffErrors, setRunDiffErrors] = useState<Record<number, string>>({});
const [runDiffLoading, setRunDiffLoading] = useState<Record<number, boolean>>({});
const diffReadGeneration = useRef(0);
@@ -269,6 +274,7 @@ export default function CareerProfilePage() {
// A comparison and its choices refer to the source that was current when it was read.
++diffReadGeneration.current;
setRunDiffs({});
runDiffRevisions.current = {};
setRunDiffErrors({});
setRunDiffLoading({});
setAcceptedLowConfidenceIds({});
@@ -283,7 +289,10 @@ export default function CareerProfilePage() {
setRunDiffErrors((current) => { const next = { ...current }; delete next[runId]; return next; });
try {
const response = await api.get<CvRunDiffResponse>(`/profile-cv/runs/${runId}/diff`);
if (isCurrent()) setRunDiffs((current) => ({ ...current, [runId]: response.data.diff }));
if (isCurrent()) {
runDiffRevisions.current[runId] = response.data.revision;
setRunDiffs((current) => ({ ...current, [runId]: response.data.diff }));
}
} catch (error) {
if (isCurrent()) setRunDiffErrors((current) => ({ ...current, [runId]: getApiErrorMessage(error, "") }));
} finally {
@@ -329,6 +338,7 @@ export default function CareerProfilePage() {
}, [loadExtractionRuns, t, toast]);
const loadProfile = useCallback(async () => {
const readId = ++profileReadId.current;
setLoading(true);
try {
// /career reads the structured profile from the relational source of truth (/career/profile);
@@ -337,6 +347,8 @@ export default function CareerProfilePage() {
api.get<CareerProfileResponse>("/career/profile"),
api.get<MeResponse>("/auth/me"),
]);
if (readId !== profileReadId.current) return;
setProfileRevision(careerResponse.data.revision);
setMe(meResponse.data);
setProfileCvText(careerResponse.data?.cvText ?? "");
const profile = normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv());
@@ -346,10 +358,11 @@ export default function CareerProfilePage() {
setHeadline(profile.contact.headline ?? "");
setLoadError(null);
} catch (error) {
if (readId !== profileReadId.current) return;
setMe(null);
setLoadError(getApiErrorMessage(error, ""));
} finally {
setLoading(false);
if (readId === profileReadId.current) setLoading(false);
}
}, []);
@@ -357,6 +370,8 @@ export default function CareerProfilePage() {
void loadProfile();
void loadExtractionRuns();
void loadVersions();
const readId = profileReadId.current;
return () => { profileReadId.current = readId + 1; };
}, [loadExtractionRuns, loadProfile, loadVersions]);
useEffect(() => {
@@ -411,7 +426,7 @@ export default function CareerProfilePage() {
setReviewAction(action);
setRunActionErrors((current) => { const next = { ...current }; delete next[runId]; return next; });
try {
if (action === "accept") await api.post(`/profile-cv/runs/${runId}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[runId] ?? [] });
if (action === "accept") await api.post(`/profile-cv/runs/${runId}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[runId] ?? [], expectedVersion: runDiffRevisions.current[runId] });
else await api.post(`/profile-cv/runs/${runId}/discard`);
setExtractionRuns((current) => current.map((run) => run.id === runId ? { ...run, status: action === "accept" ? "applied" : "discarded" } : run));
if (action === "accept") {
@@ -420,8 +435,8 @@ export default function CareerProfilePage() {
} else await loadExtractionRuns();
toast(translationRef.current(action === "accept" ? "profileCvChangesApplied" : "profileCvExtractionDiscarded"), action === "accept" ? "success" : "info");
} catch (error) {
const key = action === "accept" ? "profileCvApplyChangesFailed" : "profileCvDiscardFailed";
const message = getApiErrorMessage(error, "");
const key = (error as { response?: { status?: number } }).response?.status === 409 ? "revisionConflict" : action === "accept" ? "profileCvApplyChangesFailed" : "profileCvDiscardFailed";
const message = key === "revisionConflict" ? "" : getApiErrorMessage(error, "");
setRunActionErrors((current) => ({ ...current, [runId]: { key, message } }));
toast(message || translationRef.current(key), "error");
} finally {
@@ -543,7 +558,7 @@ export default function CareerProfilePage() {
versionsError={versionsError}
onRetryVersions={() => void loadVersions()}
restoreDisabled={profileDirty}
restoreError={restoreError}
restoreError={restoreError === "revision_conflict" ? t("revisionConflict") : restoreError}
/> : null}
{workspaceSection === "profile" ? <CropImageDialog
open={cropOpen}
@@ -903,13 +918,14 @@ export default function CareerProfilePage() {
profileMutationPending.current = true;
setParsingCvSections(true);
try {
const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText });
const res = await api.post<{ structuredCv?: StructuredCvProfile; revision: number }>("/profile-cv/parse", { text: profileCvText, expectedVersion: profileRevision });
setProfileRevision(res.data.revision);
editStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
invalidateImportComparisons();
void loadVersions();
toast(t("profileCvStructureParsed"), "success");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
toast(e?.response?.status === 409 ? t("revisionConflict") : getApiErrorMessage(e, t("profileCvStructureParseFailed")), "error");
} finally {
profileMutationPending.current = false;
setParsingCvSections(false);
@@ -981,7 +997,7 @@ export default function CareerProfilePage() {
<Typography variant="body2" color="text.secondary" sx={{ width: "100%" }}>{t("profileCvStructuredEditorHelp")}</Typography>
{saveError !== null ? <Alert severity="error" sx={{ width: "100%" }}>
{t("profileUpdateFailed")}
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError}</Typography> : null}
{saveError ? <Typography variant="body2" sx={{ mt: 0.5 }}>{saveError === "revision_conflict" ? t("revisionConflict") : saveError}</Typography> : null}
</Alert> : null}
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label={t("assetsUnsavedChanges")} /> : null}
<Button
@@ -996,7 +1012,8 @@ export default function CareerProfilePage() {
// Save the master profile through the relational source of truth. The endpoint
// persists the structured children + version and keeps the legacy blob projection
// in sync; identity fields are never touched (they belong to /profile).
const saved = await api.put<CareerProfileResponse>("/career/profile", { profile: structuredCv, cvText: profileCvText });
const saved = await api.put<CareerProfileResponse>("/career/profile", { profile: structuredCv, cvText: profileCvText, expectedVersion: profileRevision });
setProfileRevision(saved.data.revision);
setStructuredCv(normalizeStructuredCv(saved.data?.profile ?? structuredCv));
setCompleteness(saved.data?.completeness ?? null);
setProfileDirty(false);
@@ -1004,9 +1021,9 @@ export default function CareerProfilePage() {
void loadVersions();
toast(t("profileUpdated"), "success");
} catch (error) {
const message = getApiErrorMessage(error, "");
const message = (error as { response?: { status?: number } }).response?.status === 409 ? "revision_conflict" : getApiErrorMessage(error, "");
setSaveError(message);
toast(message || translationRef.current("profileUpdateFailed"), "error");
toast(message === "revision_conflict" ? translationRef.current("revisionConflict") : message || translationRef.current("profileUpdateFailed"), "error");
} finally {
profileMutationPending.current = false;
setSaving(false);
@@ -16,6 +16,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
function buildJob(overrides: Partial<JobApplication>): JobApplication {
return {
version: 1,
id: 1,
jobTitle: 'Backend Developer',
company: { id: 1, name: 'Acme' },