fix(database): qualify RD21 saves against pinned MariaDB 12.2.2

This commit is contained in:
cesnimda
2026-10-24 01:47:50 +02:00
parent 0d16a691e0
commit a2c9bda0d1
30 changed files with 2226 additions and 63 deletions
@@ -9,7 +9,7 @@ async function login(request: APIRequestContext, email: string) {
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
}
test('P9 characterize retained RD21 stale writes separately from CV revision safety', async ({ playwright }, info) => {
test('P9 RD21 source revisions reject stale writes and preserve acknowledged winners', async ({ playwright }, info) => {
const admin = await playwright.request.newContext();
const first = await playwright.request.newContext();
const second = await playwright.request.newContext();
@@ -19,31 +19,47 @@ test('P9 characterize retained RD21 stale writes separately from CV revision saf
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const a = await login(first, email); const b = await login(second, email);
const submitted = { version: '1', contact: { fullName: 'Synthetic concurrent owner' }, summary: ['Initial summary'] };
expect((await first.put(`${api}/career/profile`, { headers: a, data: { profile: submitted } })).status()).toBe(200);
const careerA = (await (await first.get(`${api}/career/profile`)).json()).profile;
const careerB = (await (await second.get(`${api}/career/profile`)).json()).profile;
const initialCareer = await (await first.get(`${api}/career/profile`)).json();
expect((await first.put(`${api}/career/profile`, { headers: a, data: { expectedVersion: initialCareer.revision, profile: submitted } })).status()).toBe(200);
const sourceA = await (await first.get(`${api}/career/profile`)).json();
const sourceB = await (await second.get(`${api}/career/profile`)).json();
expect(sourceB).toEqual(sourceA);
const careerA = sourceA.profile; const careerB = sourceB.profile;
careerA.summary = ['First editor committed summary']; careerB.contact.headline = 'Second editor headline';
expect((await first.put(`${api}/career/profile`, { headers: a, data: { profile: careerA } })).status()).toBe(200);
const staleCareer = await second.put(`${api}/career/profile`, { headers: b, data: { profile: careerB } });
expect([200, 409]).toContain(staleCareer.status());
const careerAfter = (await (await first.get(`${api}/career/profile`)).json()).profile;
expect((await first.put(`${api}/career/profile`, { headers: a, data: { expectedVersion: sourceA.revision, profile: careerA } })).status()).toBe(200);
const careerWinner = await (await first.get(`${api}/career/profile`)).json();
expect(careerWinner.revision).toBe(sourceA.revision + 1);
expect(careerWinner.profile.summary).toEqual(careerA.summary);
const careerHistory = await (await first.get(`${api}/career/profile/versions`)).json();
const staleCareer = await second.put(`${api}/career/profile`, { headers: b, data: { expectedVersion: sourceB.revision, profile: careerB } });
expect(staleCareer.status()).toBe(409);
const careerReadback = await (await second.get(`${api}/career/profile`)).json();
expect(careerReadback).toEqual(careerWinner);
expect(await (await first.get(`${api}/career/profile/versions`)).json()).toEqual(careerHistory);
const careerAfter = careerReadback.profile;
const company = await first.post(`${api}/companies`, { headers: a, data: { name: 'Synthetic stale job fixture' } });
expect(company.status()).toBe(201);
const created = await first.post(`${api}/jobapplications`, { headers: a, data: { companyId: (await company.json()).id, jobTitle: 'Original job title', status: 'Saved' } });
expect(created.status()).toBe(201); const jobId = (await created.json()).id;
const jobA = await (await first.get(`${api}/jobapplications/${jobId}`)).json();
const jobB = await (await second.get(`${api}/jobapplications/${jobId}`)).json();
expect(jobB).toEqual(jobA);
jobA.jobTitle = 'First editor committed title'; jobB.notes = 'Second editor notes';
expect([200, 204]).toContain((await first.put(`${api}/jobapplications/${jobId}`, { headers: a, data: jobA })).status());
const staleJob = await second.put(`${api}/jobapplications/${jobId}`, { headers: b, data: jobB });
expect([200, 204, 409]).toContain(staleJob.status());
const jobAfter = await (await first.get(`${api}/jobapplications/${jobId}`)).json();
expect((await first.put(`${api}/jobapplications/${jobId}`, { headers: a, data: { ...jobA, expectedVersion: jobA.version } })).status()).toBe(204);
const jobWinner = await (await first.get(`${api}/jobapplications/${jobId}`)).json();
expect(jobWinner.version).toBe(jobA.version + 1);
expect(jobWinner.jobTitle).toBe(jobA.jobTitle);
const staleJob = await second.put(`${api}/jobapplications/${jobId}`, { headers: b, data: { ...jobB, expectedVersion: jobB.version } });
expect(staleJob.status()).toBe(409);
const jobAfter = await (await second.get(`${api}/jobapplications/${jobId}`)).json();
expect(jobAfter).toEqual(jobWinner);
const careerLostFirstEdit = careerAfter.summary[0] !== careerA.summary[0];
const jobLostFirstEdit = jobAfter.jobTitle !== jobA.jobTitle;
expect(careerLostFirstEdit).toBe(false); expect(jobLostFirstEdit).toBe(false);
await info.attach('phase9-rd21-characterization', { body: JSON.stringify({ id: 'RD21',
career: { staleStatus: staleCareer.status(), expectedFirstSummary: careerA.summary, actualSummary: careerAfter.summary, lostFirstEdit: careerLostFirstEdit },
job: { staleStatus: staleJob.status(), expectedFirstTitle: jobA.jobTitle, actualTitle: jobAfter.jobTitle, lostFirstEdit: jobLostFirstEdit },
safetyQualification: careerLostFirstEdit || jobLostFirstEdit ? 'FAIL: stale writes can overwrite acknowledged edits' : 'Finite scenario retained prior edits',
limitation: 'This passing characterization records observed behavior; it does not accept unsafe stale writes or provide a new revision contract. Creation-intent idempotency remains separate.' }, null, 2), contentType: 'application/json' });
safetyQualification: 'Both stale replacements rejected with 409; exact winner state and Career history retained',
limitation: 'Finite sequential A/B scenario on the configured isolated API; not simultaneous provider contention or creation-intent idempotency proof.' }, null, 2), contentType: 'application/json' });
} finally { await second.dispose(); await first.dispose(); await admin.dispose(); }
});
@@ -29,7 +29,7 @@ test('P9 bounded sustained reads with 250 jobs and long CV while using native wo
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 } })).status()).toBe(200);
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: (await (await page.request.get(`${api}/career/profile`)).json()).revision, profile } })).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 })))