test(server): plan 4 — concurrency pins for boundaries-upsert and token-mint races (3d carry)

Task 8b-2 item 2: R7's two remaining un-transacted-statement races flagged
by the 3d Task 7 review (L4) but never pinned — RoadtripDayBoundariesService
.save's belongs-check-then-upsert, and FeedsService.generateTripToken's
read-then-write. Both tests pin today's actual outcome (an atomic ON
CONFLICT upsert survives cleanly; the token mint has a last-write-wins
loser) with the same concurrent-request-shape Promise.all/allSettled
pattern roadtrip.service.test.ts's existing R7 vias pin uses. Not a fix.
This commit is contained in:
jubnl
2026-09-24 06:57:28 +02:00
parent 4341139374
commit f0816e557e
2 changed files with 54 additions and 0 deletions
@@ -103,6 +103,31 @@ it('REST changes broadcast the complete state and exclude the saving socket', as
expect(await controller.remove(String(tripA.id), 1)).toEqual({ boundaries: [] });
});
// R7 (task-7-review.md L4 / 3d ledger's carry): `save`'s ownership check
// (RB2 `belongs`) and its upsert (RB3) are two un-transacted statements, the
// same class of race as `roadtrip.service.test.ts`'s R7 vias pin. Both
// concurrent callers pass their own `belongs` check independently (neither
// references the other's write), then race on `upsertBoundary`'s single
// `ON CONFLICT (trip, day_number) DO UPDATE` — that statement is atomic, so
// the row can never end up a hybrid of the two payloads; exactly one caller's
// fraction survives. Pinning today's actual outcome, not a fix.
it('R7: two concurrent saves for the same day_number both succeed; the upsert leaves one caller\'s row intact, never a hybrid (unserialized belongs-check-then-upsert)', async () => {
const { service, tripA, fromId, toId } = setup();
const boundaryA = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.25 };
const boundaryB = { day_number: 1, from_assignment_id: fromId, to_assignment_id: toId, fraction: 0.75 };
const results = await Promise.allSettled([
service.save(tripA.id, boundaryA),
service.save(tripA.id, boundaryB),
]);
expect(results.every((r) => r.status === 'fulfilled')).toBe(true);
const rows = await service.list(tripA.id);
// No duplicate row from the race, and no hybrid of the two fractions.
expect(rows).toHaveLength(1);
expect([0.25, 0.75]).toContain(rows[0].fraction);
});
it('MCP checks demo, trip access and edit permission before saving', async () => {
const s = setup();
const ctx = { userId: 1 } as McpContext;
@@ -220,6 +220,35 @@ describe('trip feed token lifecycle', () => {
// Proven directly at the repository too — the affected count IS the 0-row signal.
expect(await tripsRepo.setFeedTokenIfReachable(trip.id, stranger.id, mintedToken)).toBe(0);
});
// R7 (task-7-review.md L4 / 3d ledger's carry): FD1 (`getFeedTokenIfReachable`,
// the "does one exist" check) and FD2 (`setFeedTokenIfReachable`, the write)
// are two un-transacted statements. Two concurrent `generateTripToken` calls
// for the same reachable trip with no existing token both read "none" at
// FD1, so both mint a DISTINCT token, and both write — the last write wins
// (FD2 is an unconditional UPDATE, not a conflict-checked upsert), so only
// one of the two minted URLs ever resolves. Pinning today's actual outcome
// (same class as `roadtrip.service.test.ts`'s R7 vias pin), not a fix.
it('R7: two concurrent generateTripToken calls both mint, but only the last write survives — the other caller\'s URL never resolves (unserialized FD1-then-FD2)', async () => {
const { user, trip } = seedTrip(); // no token yet
const [r1, r2] = await Promise.all([
svc.generateTripToken(trip.id, user.id, BASE),
svc.generateTripToken(trip.id, user.id, BASE),
]);
const token1 = r1.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
const token2 = r2.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1];
// FD1 saw no token for either caller, so two distinct tokens were minted.
expect(token1).not.toBe(token2);
const stored = (testDb.prepare('SELECT feed_token FROM trips WHERE id = ?').get(trip.id) as { feed_token: string | null }).feed_token;
expect([token1, token2]).toContain(stored);
const loser = stored === token1 ? token2 : token1;
// The loser's URL is well-formed, but resolves nothing.
expect(await svc.buildTripIcs(loser)).toBeNull();
expect(await svc.buildTripIcs(stored!)).not.toBeNull();
});
});
// ── User (all-trips) feed token ───────────────────────────────────────────────