From 03d2a712d08f6ffe0eaee7eda1967d9574cc8a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 02:00:08 +0200 Subject: [PATCH] Move the contract tests to the end that now writes Three integration cases pinned the old behaviour: the sync handshake stamping the checkpoint, and the calendar read persisting the prediction. Both are the faults this release removes, so the cases were asserting a bug. Each file now asserts the read writes nothing AND that the other end does. The second half is not decoration: without it, "nothing was written" would pass just as well against a column nothing writes at all. The cycle case seeds its own history and its own profile row rather than reaching a route to get one, because reaching the route is exactly what the prediction must no longer depend on. --- tests/integration/cycle-routes.test.ts | 65 +++++++++++++++++++++++--- tests/integration/sync-state.test.ts | 26 +++++++++-- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index 446245858..26f755aae 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -480,16 +480,67 @@ describe("cycle calendar", () => { // GENERAL_HEALTH default → fertile window suppressed. expect(json.data.prediction.fertileWindowStart).toBeNull(); - // The cache row is persisted fire-and-forget (the route never blocks - // the read on it), so poll briefly for it to land. - let cache = null; - for (let i = 0; i < 20 && cache === null; i++) { - cache = await prisma.cyclePrediction.findUnique({ + // The read persists nothing. The prediction row exists for the reminder + // job, and writing it here made the job's behaviour depend on whether + // anyone had opened this page. Poll anyway rather than reading once, so + // that a write which merely arrives late still fails the case. + for (let i = 0; i < 20; i++) { + const cache = await prisma.cyclePrediction.findUnique({ where: { userId: FEMALE_USER_ID }, }); - if (cache === null) await new Promise((r) => setTimeout(r, 25)); + expect(cache).toBeNull(); + await new Promise((r) => setTimeout(r, 25)); } - expect(cache).not.toBeNull(); + }); + + it("the nightly refresh writes the prediction the read no longer writes", async () => { + // The other end of the move. Without this the case above would pass + // against a prediction nothing writes at all. + // + // `beforeEach` truncates, so this case seeds its own history rather than + // inheriting the one above. It also never touches the calendar route: the + // point is that the prediction lands without anyone opening that page. + // + // The profile row is seeded directly for the same reason. Every + // `/api/cycle/*` route creates one on the way past, so in production the + // cohort is "has ever used cycle tracking" rather than "opened the + // calendar recently". Calling a route to get one here would defeat the + // case. + const prisma = getPrismaClient(); + await prisma.cycleProfile.create({ data: { userId: FEMALE_USER_ID } }); + for (const [start, end, len] of [ + ["2026-01-01", "2026-01-28", 28], + ["2026-01-29", "2026-02-25", 28], + ] as const) { + await prisma.menstrualCycle.create({ + data: { + userId: FEMALE_USER_ID, + startDate: start, + endDate: end, + lengthDays: len, + periodEndDate: null, + }, + }); + } + await prisma.menstrualCycle.create({ + data: { userId: FEMALE_USER_ID, startDate: "2026-02-26" }, + }); + + expect( + await prisma.cyclePrediction.findUnique({ + where: { userId: FEMALE_USER_ID }, + }), + ).toBeNull(); + + const { refreshPredictionCacheForUser } = + await import("@/lib/cycle/prediction-refresh"); + await refreshPredictionCacheForUser(FEMALE_USER_ID); + + expect( + await prisma.cyclePrediction.findUnique({ + where: { userId: FEMALE_USER_ID }, + }), + ).not.toBeNull(); }); }); diff --git a/tests/integration/sync-state.test.ts b/tests/integration/sync-state.test.ts index 01d349efd..b3318519f 100644 --- a/tests/integration/sync-state.test.ts +++ b/tests/integration/sync-state.test.ts @@ -103,21 +103,41 @@ describe("GET /api/sync/state (real Postgres)", () => { expect(json.data.intakes.liveCount).toBe(0); expect(json.data.intakes.tombstonedCount).toBe(0); - // Server-side, lastSyncedAt now carries a value. + // The handshake asks where to resume. It does not deliver anything, so it + // must not move the resume point: advancing past a window the client has + // not drained is how rows go missing. const after = await getPrismaClient().user.findUnique({ where: { id: TEST_USER_ID }, select: { lastSyncedAt: true }, }); - expect(after?.lastSyncedAt).not.toBeNull(); + expect(after?.lastSyncedAt).toBeNull(); }); - it("returns the previous checkpoint on the second call", async () => { + it("still reports null on the second call, because asking twice is not syncing", async () => { + // The old contract stamped the checkpoint on the first call, so the second + // read found one. Nothing has arrived between these two calls, so the + // honest answer is still null. const { GET } = await import("@/app/api/sync/state/route"); await GET(makeRequest()); const res = await GET(makeRequest()); const json = (await res.json()) as { data: { lastSyncedAt: string | null }; }; + expect(json.data.lastSyncedAt).toBeNull(); + }); + + it("reports a checkpoint once data has actually arrived", async () => { + // The other half of the move: the mark now comes from an ingest boundary. + // Without this case the two above would pass just as well against a column + // nothing writes at all, which is the failure they exist to rule out. + const { markSyncCheckpoint } = await import("@/lib/sync/checkpoint"); + await markSyncCheckpoint(TEST_USER_ID); + + const { GET } = await import("@/app/api/sync/state/route"); + const res = await GET(makeRequest()); + const json = (await res.json()) as { + data: { lastSyncedAt: string | null }; + }; expect(json.data.lastSyncedAt).not.toBeNull(); });