mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
173 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|