fix(rd21): retain draft revisions and qualify conflict recovery
This commit is contained in:
@@ -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(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user