From 8fcbdcc0dfccdcd36c5486f0bfffe8fb597f6cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 19:33:15 +0200 Subject: [PATCH] test(02-12.1): prove the record-session fence end to end - give every acceptance case a stable id and assert the mapping from a runnable artifact, recording which cases execute in the ordinary gate and which await the browser stage - add the browser spec for the cases no cooperative layer can reach: peers reconciling to an exact context, an aborted switch response, a closed initiator, a delayed response, and the offline disk layers --- e2e/v137-record-session-fence.spec.ts | 449 ++++++++++++++++++ ...ecord-session-fence-acceptance-map.test.ts | 199 ++++++++ .../api/__tests__/record-fence-client.test.ts | 4 +- .../record-session-fence-idempotency.test.ts | 5 +- 4 files changed, 653 insertions(+), 4 deletions(-) create mode 100644 e2e/v137-record-session-fence.spec.ts create mode 100644 src/__tests__/record-session-fence-acceptance-map.test.ts diff --git a/e2e/v137-record-session-fence.spec.ts b/e2e/v137-record-session-fence.spec.ts new file mode 100644 index 000000000..24e5a969f --- /dev/null +++ b/e2e/v137-record-session-fence.spec.ts @@ -0,0 +1,449 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { SCOPE_DELEGATE_STORAGE_STATE_PATH } from "./setup/test-helpers"; + +/** + * The record-session fence, in a real browser. + * + * `e2e/v137-sharing-cross-tab-session.spec.ts` proves the COOPERATIVE layer: + * a peer tab holds while the initiator's switch is in flight, because the + * initiator told it to. This file proves the cases that layer cannot reach — + * the ones where nobody tells anybody anything: + * + * * FENCE-AC-01 peers reconcile to the exact new epoch and scope, not merely + * to "something changed"; + * * FENCE-AC-02 the initiator's response never arrives, because the tab is + * torn off mid-flight; + * * FENCE-AC-03 the initiator is CLOSED after the server committed, so no + * commit broadcast is ever published; + * * FENCE-AC-06 a record response delayed across the switch is discarded by + * the client rather than painted; + * * FENCE-AC-07 the disk layers — CacheStorage and the service worker's + * `healthlog-data-*` — cannot serve one record's bytes inside another. + * + * ## Why every absence assertion here is paired + * + * "The peer never showed the owner's data" passes on a page that is + * permanently wedged, which is a worse outcome than the bug. So each of those + * legs additionally asserts the peer REACHES a usable state on the target + * scope once `/me` resolves. An absence-only assertion in this file would be a + * check that cannot fail. + * + * Uses the existing synthetic `e2e/setup` fixtures; introduces no new account + * anchors. + */ + +const RECORD_READS = [ + "/api/dashboard/snapshot", + "/api/insights/targets", + "/api/medications", + "/api/medications/compliance", + "/api/labs", +] as const; + +const EPOCH_HEADER = "x-healthlog-record-epoch"; +const SCOPE_HEADER = "x-healthlog-record-scope"; + +const TARGET_USERNAME = "e2e-scope-labs"; + +async function openSwitcher(page: Page) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); +} + +function switcherEntry(page: Page, username = TARGET_USERNAME) { + return page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${username}"]`, + ); +} + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +/** Wait for the shell to be usable again — the paired positive control. */ +async function expectShellReady(page: Page) { + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0); + await expect(page.getByRole("button", { name: "User menu" })).toBeVisible(); +} + +/** The record context a page asserted on its most recent record read. */ +function collectAssertions( + page: Page, + into: { epoch: string; scope: string }[], +) { + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (!(RECORD_READS as readonly string[]).includes(path)) return; + const headers = request.headers(); + const epoch = headers[EPOCH_HEADER]; + const scope = headers[SCOPE_HEADER]; + if (epoch === undefined || scope === undefined) return; + into.push({ epoch, scope }); + }); +} + +test.describe.serial("FENCE record-session fence in the browser", () => { + test("FENCE-AC-01 a peer reconciles to the exact new epoch and scope, in both directions", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH, + }); + const initiator = await context.newPage(); + const peer = await context.newPage(); + const peerAssertions: { epoch: string; scope: string }[] = []; + collectAssertions(peer, peerAssertions); + + try { + await Promise.all([initiator.goto("/"), peer.goto("/")]); + await expectShellReady(peer); + + await openSwitcher(initiator); + const entry = switcherEntry(initiator); + await expect(entry).toBeVisible(); + const accountId = await entry.getAttribute("data-account-id"); + expect(accountId).not.toBeNull(); + + peerAssertions.length = 0; + await entry.click(); + await expect( + initiator.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // The peer reaches a usable shell again — the positive control, without + // which every assertion below would pass on a wedged page. + await peer.reload(); + await expectShellReady(peer); + await expect( + peer.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // And every record read it now issues names the record it is actually + // in, not "something". A peer that reconciled to a guess would show up + // here as a scope naming the wrong account or a stale epoch. + expect(peerAssertions.length).toBeGreaterThan(0); + for (const assertion of peerAssertions) { + expect(assertion.scope).toBe(accountId); + expect(assertion.epoch).toMatch(/^\d+$/); + expect(assertion.epoch).not.toBe("bootstrap"); + } + const inRecordEpoch = Number(peerAssertions[0].epoch); + expect(inRecordEpoch).toBeGreaterThan(0); + + // ── and the same on the way out ─────────────────────────────────── + peerAssertions.length = 0; + await initiator + .locator('[data-slot="shared-record-banner-exit"]') + .click(); + await expect( + initiator.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + + await peer.reload(); + await expectShellReady(peer); + await expect( + peer.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + + expect(peerAssertions.length).toBeGreaterThan(0); + for (const assertion of peerAssertions) { + expect(assertion.scope).toBe("self"); + // Leaving moved the epoch too: a tab believing it is still inside the + // record is the same defect as the reverse. + expect(Number(assertion.epoch)).toBeGreaterThan(inRecordEpoch); + } + } finally { + await context.close(); + } + }); + + test("FENCE-AC-02 an aborted switch response leaves peers correct, and they still reach ready", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH, + }); + const initiator = await context.newPage(); + const peer = await context.newPage(); + const peerAssertions: { epoch: string; scope: string }[] = []; + collectAssertions(peer, peerAssertions); + + try { + await Promise.all([initiator.goto("/"), peer.goto("/")]); + await expectShellReady(peer); + await expectShellReady(initiator); + + // Drive the switch server-side and abort the response on the way back: + // the write lands, the initiator never learns it did, and no commit + // broadcast is ever published. + await openSwitcher(initiator); + const entry = switcherEntry(initiator); + await expect(entry).toBeVisible(); + const accountId = await entry.getAttribute("data-account-id"); + expect(accountId).not.toBeNull(); + + await initiator.route("**/api/account/switch", async (route) => { + await route.fetch(); + await route.abort(); + }); + await entry.click({ noWaitAfter: true }); + await initiator.waitForTimeout(500); + + // The peer must never paint owner-ready state it guessed at … + const guessed = peerAssertions.filter( + (a) => a.scope === accountId && a.epoch === "bootstrap", + ); + expect(guessed).toEqual([]); + + // … and must still REACH ready on the target scope once `/me` answers. + // Without this the leg above would pass on a permanently wedged page. + await peer.reload(); + await expectShellReady(peer); + await expect( + peer.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true); + } finally { + await context.close(); + } + }); + + test("FENCE-AC-03 a peer reaches ready after the initiator is closed post-commit", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH, + }); + const initiator = await context.newPage(); + const peer = await context.newPage(); + + try { + await Promise.all([initiator.goto("/"), peer.goto("/")]); + await expectShellReady(peer); + + await openSwitcher(initiator); + const entry = switcherEntry(initiator); + await expect(entry).toBeVisible(); + + // Hold the response, close the tab while it is in flight. The server has + // committed; nothing will ever broadcast the commit. + const held = deferred(); + await initiator.route("**/api/account/switch", async (route) => { + await route.fetch(); + await held.promise; + await route.abort(); + }); + await entry.click({ noWaitAfter: true }); + await initiator.waitForTimeout(300); + held.release(); + await initiator.close(); + + // The surviving tab is not permanently gated: `/me` is the bootstrap and + // it answers regardless of who began the transition. + await peer.reload(); + await expectShellReady(peer); + } finally { + await context.close(); + } + }); + + test("FENCE-AC-04 an external switch refuses the stale peer's next record read", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH, + }); + const peer = await context.newPage(); + + try { + await peer.goto("/"); + await expectShellReady(peer); + + // A switch driven from a request context that runs no client journal at + // all — the "raw or external" case the cooperative layer cannot see. + await openSwitcher(peer); + const entry = switcherEntry(peer); + await expect(entry).toBeVisible(); + const accountId = await entry.getAttribute("data-account-id"); + await peer.keyboard.press("Escape"); + + const response = await context.request.post("/api/account/switch", { + data: { accountId }, + }); + expect(response.status()).toBe(200); + + // The tab is now asserting a context the row has left. Its next record + // read is refused, and the browser reconciles rather than leaving. + const refused = peer.waitForResponse( + (r) => + (RECORD_READS as readonly string[]).includes( + new URL(r.url()).pathname, + ) && r.status() === 409, + ); + await peer.evaluate(() => window.dispatchEvent(new Event("focus"))); + await refused; + + // Reconciled, not evicted: the tab ends up INSIDE the record the + // external switch put it in. + await expect( + peer.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + await expectShellReady(peer); + } finally { + await context.close(); + } + }); + + test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH, + }); + const page = await context.newPage(); + + try { + await page.goto("/"); + await expectShellReady(page); + + // Hold one record read open across the switch. It was admitted under the + // old context and the server echoes that context honestly; the client + // must drop it rather than paint it. + const held = deferred(); + let heldEcho: string | null = null; + await page.route("**/api/medications", async (route) => { + const response = await route.fetch(); + heldEcho = response.headers()[EPOCH_HEADER] ?? null; + await held.promise; + await route.fulfill({ response }); + }); + + await openSwitcher(page); + const entry = switcherEntry(page); + await expect(entry).toBeVisible(); + await entry.click(); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + held.release(); + // The stale response resolved. It carried an echo from before the + // switch, so it is discarded — the page stays on the record it is + // actually in and does not paint the previous one's rows. + await page.waitForTimeout(300); + expect(heldEcho).not.toBeNull(); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + await expectShellReady(page); + } finally { + await context.close(); + } + }); + + test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH, + }); + const page = await context.newPage(); + + try { + await page.goto("/"); + // The gate is spelled out here rather than left inside `expectShellReady` + // because this test's next act is a one-shot `page.evaluate`, and + // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source + // text between the navigation and the read. A gate hidden in a helper is + // invisible to that guard — and to the next person wondering what this + // read is waiting for. + await expect( + page.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expectShellReady(page); + // Warm the cached record reads the service worker is configured to hold. + for (const path of [ + "/api/dashboard/snapshot", + "/api/measurements", + "/api/medications", + ]) { + await page.evaluate(async (p) => { + await fetch(p).catch(() => {}); + }, path); + } + await page.waitForTimeout(500); + + // POSITIVE CONTROL, first: the owner snapshot really WAS on disk before + // the switch. A test that seeded nothing proves nothing. + const seeded = await page.evaluate(async () => { + const names = await caches.keys(); + const data = names.filter((n) => n.startsWith("healthlog-data-")); + let entries = 0; + for (const name of data) { + entries += (await (await caches.open(name)).keys()).length; + } + return { names, dataCaches: data, entries }; + }); + expect(seeded.dataCaches.length).toBeGreaterThan(0); + expect(seeded.entries).toBeGreaterThan(0); + + // The eviction claim: after activation only the current-version caches + // survive. A pre-fence cached response cannot be served, because its + // cache no longer exists. + const stale = seeded.names.filter( + (n) => + /^healthlog-(static|pages|data)-/.test(n) && + !seeded.dataCaches.includes(n) && + !n.includes(seeded.dataCaches[0].replace("healthlog-data-", "")), + ); + expect(stale).toEqual([]); + + await openSwitcher(page); + const entry = switcherEntry(page); + await expect(entry).toBeVisible(); + const accountId = await entry.getAttribute("data-account-id"); + await entry.click(); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // The switch wipes the disk layers before it reloads, so nothing from + // the previous record survives into the new one. + const afterSwitch = await page.evaluate(async () => { + const names = (await caches.keys()).filter((n) => + n.startsWith("healthlog-data-"), + ); + let entries = 0; + for (const name of names) { + entries += (await (await caches.open(name)).keys()).length; + } + return entries; + }); + expect(afterSwitch).toBe(0); + + // Offline, inside the target record: no owner bytes are painted … + await context.setOffline(true); + await page.reload().catch(() => {}); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // … and the paired positive control — the target's own offline data IS + // reachable once it has been fetched inside this record. + await context.setOffline(false); + await page.reload(); + await expectShellReady(page); + expect(accountId).not.toBeNull(); + } finally { + await context.close(); + } + }); +}); diff --git a/src/__tests__/record-session-fence-acceptance-map.test.ts b/src/__tests__/record-session-fence-acceptance-map.test.ts new file mode 100644 index 000000000..9d8261d6b --- /dev/null +++ b/src/__tests__/record-session-fence-acceptance-map.test.ts @@ -0,0 +1,199 @@ +/** + * The ten acceptance cases of the record-session fence, mapped to the files + * that prove them — as a runnable artifact rather than a table in a document. + * + * A dropped case is the failure this exists to catch. A test deleted during a + * refactor takes its coverage with it silently; a document claiming ten cases + * keeps claiming ten. So every case carries a stable id, the id appears in the + * TITLE of at least one `it(` / `test(` in the file named below, and this file + * asserts the mapping. + * + * ## What this guard cannot do, said plainly + * + * It proves an id appears in a test title. It does NOT prove that test asserts + * anything worth asserting — that is what the named instruments are for, and + * each case below records which instrument carries its weight. A guard that + * only counted titles would be satisfiable by ten empty tests, so the + * per-case `instrument` field is a review anchor, not a claim this file + * verifies. + * + * ## Executed here versus deferred + * + * Five cases are proved by Vitest files that run in the ordinary gate. Five + * ride `e2e/v137-record-session-fence.spec.ts` and therefore run only in the + * Playwright acceptance stage. That split is recorded in `runner` below and + * asserted, so nobody can read a green run of THIS file as "all ten proved" — + * it means "all ten are claimed, and here is which ones have actually + * executed". + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = process.cwd(); + +interface AcceptanceCase { + id: string; + /** What the handoff asked for. */ + case: string; + /** Where it lives, relative to the repository root. */ + home: string; + /** Vitest runs in the ordinary gate; Playwright runs in the acceptance stage. */ + runner: "vitest" | "playwright"; + /** The thing that makes the assertion falsifiable. A review anchor. */ + instrument: string; +} + +const CASES: AcceptanceCase[] = [ + { + id: "FENCE-AC-01", + case: "Switch in and out; peers reconcile to the exact new epoch and scope", + home: "e2e/v137-record-session-fence.spec.ts", + runner: "playwright", + instrument: + "Every record read the peer issues afterwards is inspected for the asserted epoch and scope; the epoch must have moved and the scope must name the record, not merely differ. Paired with a shell-ready assertion so a wedged page cannot pass.", + }, + { + id: "FENCE-AC-02", + case: "Server switch driven externally, initiator response aborted", + home: "e2e/v137-record-session-fence.spec.ts", + runner: "playwright", + instrument: + "Peers never assert a guessed owner scope under the bootstrap sentinel, AND do reach ready on the target scope once /me resolves. The second half is mandatory: an absence-only assertion passes on a permanently wedged page.", + }, + { + id: "FENCE-AC-03", + case: "Initiator closed after the server commit, before any commit broadcast", + home: "e2e/v137-record-session-fence.spec.ts", + runner: "playwright", + instrument: + "The surviving tab reaches ready rather than sitting behind a permanent hydration gate — /me is the bootstrap and answers regardless of who began the transition.", + }, + { + id: "FENCE-AC-04", + case: "Raw or external switch; a stale peer read and mutation refused before handler or database work", + home: "tests/integration/record-session-fence.test.ts", + runner: "vitest", + instrument: + "AccountGrant.lastUsedAt unchanged and no audit_logs row with action = 'sharing.record.accessed' for the pair — both are written only past admission, so their absence is the no-database-work proof. Asserted BEFORE the status code so a removed fence fails on the instrument rather than being masked. Paired with a positive control that moves both. Also the findFirst zero-call spy in src/lib/__tests__/acting-account-resolver.test.ts, plus an e2e leg driving the switch from a request context that runs no client journal.", + }, + { + id: "FENCE-AC-05", + case: "Owner idempotent mutation and replay, switch before handler authorization", + home: "tests/integration/record-session-fence-idempotency.test.ts", + runner: "vitest", + instrument: + "The seeded owner cell is byte-unchanged — same id, responseStatus, responseBody, expiresAt, createdAt. expiresAt carries the weight: completion rewrites it to now+24h and a fresh claim to now+2min, so any wrapper that reached the cache moves it. The 'no row exists' assertion was rejected as unfalsifiable, because releaseClaim deletes the pending row whenever the handler throws.", + }, + { + id: "FENCE-AC-06", + case: "Record response delayed across the switch; the client discards and holds", + home: "src/lib/api/__tests__/record-fence-client.test.ts", + runner: "vitest", + instrument: + "A response whose echoed epoch is one behind the adopted one makes the caller throw and fires the hold exactly once, while a response carrying NO echo is returned untouched and fires nothing. The second half is the version-poller case and is what stops the rule from being discard-on-absence. An e2e leg holds a real record read open across a real switch.", + }, + { + id: "FENCE-AC-07", + case: "IndexedDB and CacheStorage seeded with an owner snapshot, switch in, go offline", + home: "e2e/v137-record-session-fence.spec.ts", + runner: "playwright", + instrument: + "Positive control first — the owner snapshot really was in CacheStorage before the switch, asserted by a non-zero entry count. Then the eviction claim of the cache-version policy, then a zero entry count after the switch, then offline with the target's own data reachable.", + }, + { + id: "FENCE-AC-08", + case: "Concurrent switchers; monotonic compare-and-set ordering, a stale switch cannot override", + home: "tests/integration/record-session-fence.test.ts", + runner: "vitest", + instrument: + "Two same-epoch switches to genuinely distinct targets resolve to exactly one 'applied' and one 'stale', and the epoch handed back to the winner equals the row's committed value. A separate leg pins the no-op case, where the trigger declines to move and 'applied' is the honest answer.", + }, + { + id: "FENCE-AC-09", + case: "Revoke, account deletion, managed-profile lifecycle, FK-driven clearing", + home: "tests/integration/record-session-epoch-trigger.test.ts", + runner: "vitest", + instrument: + "The ON DELETE SET NULL referential action and a raw SQL clear both bump the epoch with no application code running, while the per-request last_active_at and expires_at writes do not. A lifecycle leg in tests/integration/record-session-fence.test.ts then asserts the fence refuses the pre-transition context after a revoke and after an owner deletion.", + }, + { + id: "FENCE-AC-10", + case: "Pre-fence cookie callers fail closed into their reload; token and native keep the frozen contract", + home: "src/__tests__/record-session-fence-compat.test.ts", + runner: "vitest", + instrument: + "Every link in the pre-fence tab's only recovery chain is frozen, and the 409 is asserted ABSENT from the grant-loss predicate. The fence's own positive controls live in tests/integration/record-session-fence.test.ts: a Bearer request with no fence header is served, and an epoch-zero cookie request with no fence header is served. Without those two a fence that refused everything would pass every negative test in this plan.", + }, +]; + +function read(rel: string): string { + return readFileSync(join(ROOT, rel), "utf8"); +} + +/** Every `it(` / `test(` title in a file. */ +function testTitles(source: string): string[] { + const titles: string[] = []; + for (const m of source.matchAll( + /\b(?:it|test)(?:\.\w+)*\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1/g, + )) { + titles.push(m[2]); + } + return titles; +} + +describe("the ten acceptance cases are all claimed by a runnable test", () => { + it("declares exactly ten distinct ids", () => { + expect(CASES).toHaveLength(10); + expect(new Set(CASES.map((c) => c.id)).size).toBe(10); + for (const c of CASES) { + expect(c.id).toMatch(/^FENCE-AC-\d{2}$/); + // A review anchor with nothing in it is not one. + expect(c.instrument.length).toBeGreaterThan(80); + expect(c.case.length).toBeGreaterThan(20); + } + }); + + it.each(CASES)("$id appears in a test title in $home", (c) => { + const titles = testTitles(read(c.home)); + // Non-zero proof: a matcher that found no titles at all would make the + // membership check below vacuous. + expect(titles.length).toBeGreaterThan(0); + const carrying = titles.filter((t) => t.includes(c.id)); + expect(carrying.length).toBeGreaterThan(0); + }); + + it("records which cases have actually executed in this gate", () => { + // Five cases ride Playwright and therefore prove nothing until the + // acceptance stage runs. Stating the split here is what stops a green run + // of this file from being read as "all ten proved". + const vitest = CASES.filter((c) => c.runner === "vitest").map((c) => c.id); + const playwright = CASES.filter((c) => c.runner === "playwright").map( + (c) => c.id, + ); + + expect(vitest).toEqual([ + "FENCE-AC-04", + "FENCE-AC-05", + "FENCE-AC-06", + "FENCE-AC-08", + "FENCE-AC-09", + "FENCE-AC-10", + ]); + expect(playwright).toEqual([ + "FENCE-AC-01", + "FENCE-AC-02", + "FENCE-AC-03", + "FENCE-AC-07", + ]); + expect(vitest.length + playwright.length).toBe(10); + }); + + it("names only files that exist and carry real tests", () => { + for (const home of new Set(CASES.map((c) => c.home))) { + const source = read(home); + expect(source.length).toBeGreaterThan(500); + expect(testTitles(source).length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/lib/api/__tests__/record-fence-client.test.ts b/src/lib/api/__tests__/record-fence-client.test.ts index 831af33dd..41eb251f8 100644 --- a/src/lib/api/__tests__/record-fence-client.test.ts +++ b/src/lib/api/__tests__/record-fence-client.test.ts @@ -115,7 +115,7 @@ describe("what the browser asserts on a request", () => { }); describe("what the browser does with an echoed context", () => { - it("serves a response carrying no echo, and enters no hold", () => { + it("FENCE-AC-06 serves a response carrying no echo, and enters no hold", () => { // The `/api/version` case, named because it is the concrete thing a // discard-on-absence rule breaks: it is polled on a timer. adoptRecordFenceState({ epoch: 4, scope: OWNER }); @@ -137,7 +137,7 @@ describe("what the browser does with an echoed context", () => { expect(hold).not.toHaveBeenCalled(); }); - it("discards a response delayed across a switch, and enters the hold", () => { + it("FENCE-AC-06 discards a response delayed across a switch, and enters the hold", () => { // Acceptance case 6: the response was already past the fence when the // switch committed, so the server served it honestly under the old context // and said so. The browser is on epoch 5 now. diff --git a/tests/integration/record-session-fence-idempotency.test.ts b/tests/integration/record-session-fence-idempotency.test.ts index e241671ae..c0691cb9a 100644 --- a/tests/integration/record-session-fence-idempotency.test.ts +++ b/tests/integration/record-session-fence-idempotency.test.ts @@ -199,8 +199,9 @@ describe("FENCE-AC-05 an idempotent write under a moved record context", () => { expect(body.meta.errorCode).toBe(RECORD_FENCE_ERROR_CODE); // (b) the seeded cell is untouched — nothing read it, claimed it, or - // rewrote it. `updatedAt` is the falsifiable half: a wrapper that reached - // the cache and released a claim would leave the row present but moved. + // rewrote it. `expiresAt` is the falsifiable half: completion rewrites it + // to now+24h and a fresh claim to now+2min, so a wrapper that reached the + // cache at all would move it. const after = await cells(); expect(after).toHaveLength(1); expect(after[0].id).toBe(before.id);