/** * v1.4.30 — `GET /api/sync/state` real-Postgres integration. * * The endpoint is the iOS SyncMode handshake. Asserts: * - first call returns `lastSyncedAt: null` then writes the * checkpoint * - subsequent call sees the previous checkpoint * - measurement counters distinguish live vs tombstoned rows */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; const TEST_USER_ID = "user-sync-state"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { headers: vi.fn(async () => ({ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, })), cookies: vi.fn(async () => ({ get: (name: string) => { const value = cookieJar.get(name); return value ? { name, value } : undefined; }, set: (name: string, value: string) => { cookieJar.set(name, value); }, delete: (name: string) => { cookieJar.delete(name); }, })), }; }); beforeEach(async () => { await truncateAllTables(getPrismaClient()); cookieJar.clear(); await getPrismaClient().user.create({ data: { id: TEST_USER_ID, username: "sync-state", email: "sync-state@example.test", timezone: "Europe/Berlin", }, }); const session = await getPrismaClient().session.create({ data: { userId: TEST_USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000), }, }); cookieJar.set("healthlog_session", session.id); }); function makeRequest(): NextRequest { return new NextRequest("http://localhost/api/sync/state", { method: "GET" }); } describe("GET /api/sync/state (real Postgres)", () => { it("returns lastSyncedAt: null on the first call and writes the checkpoint", async () => { const { GET } = await import("@/app/api/sync/state/route"); const res = await GET(makeRequest()); expect(res.status).toBe(200); const json = (await res.json()) as { data: { userId: string; timezone: string; lastSyncedAt: string | null; serverNow: string; measurements: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number; }; mood: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number; }; intakes: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number; }; }; }; expect(json.data.userId).toBe(TEST_USER_ID); expect(json.data.timezone).toBe("Europe/Berlin"); expect(json.data.lastSyncedAt).toBeNull(); expect(json.data.measurements.liveCount).toBe(0); expect(json.data.measurements.tombstonedCount).toBe(0); // v1.7.0 — mood + intake blocks now report on the same shape. expect(json.data.mood.liveCount).toBe(0); expect(json.data.mood.tombstonedCount).toBe(0); expect(json.data.intakes.liveCount).toBe(0); expect(json.data.intakes.tombstonedCount).toBe(0); // 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).toBeNull(); }); 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(); }); it("distinguishes live vs tombstoned measurement counts", async () => { const prisma = getPrismaClient(); await prisma.measurement.createMany({ data: [ { userId: TEST_USER_ID, type: "PULSE", value: 64, unit: "bpm", source: "MANUAL", measuredAt: new Date("2026-05-16T10:00:00.000Z"), }, { userId: TEST_USER_ID, type: "PULSE", value: 70, unit: "bpm", source: "MANUAL", measuredAt: new Date("2026-05-16T11:00:00.000Z"), deletedAt: new Date("2026-05-16T12:00:00.000Z"), }, ], }); const { GET } = await import("@/app/api/sync/state/route"); const res = await GET(makeRequest()); const json = (await res.json()) as { data: { measurements: { liveCount: number; tombstonedCount: number } }; }; expect(json.data.measurements.liveCount).toBe(1); expect(json.data.measurements.tombstonedCount).toBe(1); }); });