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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 02:00:08 +02:00
1 parent c9ee84e5e4
commit 03d2a712d0
2 files changed
+81 -10

No files matched your search

+58 -7
View File
@@ -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();
});
});
+23 -3
View File
@@ -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();
});