diff --git a/server/tests/unit/nest/day-boundaries.test.ts b/server/tests/unit/nest/day-boundaries.test.ts index 9b0970da4..7094519bd 100644 --- a/server/tests/unit/nest/day-boundaries.test.ts +++ b/server/tests/unit/nest/day-boundaries.test.ts @@ -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; diff --git a/server/tests/unit/nest/feeds.service.test.ts b/server/tests/unit/nest/feeds.service.test.ts index 5a5e37161..9ab43137b 100644 --- a/server/tests/unit/nest/feeds.service.test.ts +++ b/server/tests/unit/nest/feeds.service.test.ts @@ -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 ───────────────────────────────────────────────