Files
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

173 lines
5.8 KiB
TypeScript

/**
* 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);
});
});