From 557865accaec20910365909a38ba2a832e0d77fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:43:14 +0200 Subject: [PATCH] test(measurements): golden comparison of set-based batch insert and reconciler Posts one batch for two accounts with the same stored rows, once as the route runs and once with the set-based insert forced onto its fallback, and asserts identical per-entry outcomes and stored rows across fresh samples, a stored duplicate, a tombstone, a stats: overwrite, an external id twice in the batch and a natural-key collision. --- ...asurements-batch-set-insert-golden.test.ts | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 tests/integration/measurements-batch-set-insert-golden.test.ts diff --git a/tests/integration/measurements-batch-set-insert-golden.test.ts b/tests/integration/measurements-batch-set-insert-golden.test.ts new file mode 100644 index 000000000..1d1b8eaf6 --- /dev/null +++ b/tests/integration/measurements-batch-set-insert-golden.test.ts @@ -0,0 +1,257 @@ +/** + * Golden comparison for the set-based insert in `POST /api/measurements/batch`. + * + * Fresh samples are inserted with one `INSERT … ON CONFLICT DO NOTHING` per + * chunk; whatever that skips goes through the per-entry reconciler. The claim + * is that this changes nothing a client can observe. The same batch is posted + * for two accounts holding the same stored rows: once as the route runs, and + * once with the set-based insert forced to fail, which sends every entry + * through the reconciler (the route's own fallback). Per-entry outcomes and + * the stored rows must be identical, across fresh samples, an exact stored + * duplicate, a tombstoned sample, a `stats:` overwrite, an external id twice + * in one batch, and a natural-key collision under a new external id. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +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); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const SET_USER = "golden-set-based"; +const RECONCILER_USER = "golden-reconciler"; + +interface Entry { + hkIdentifier: string; + value: number; + unit: string; + startDate: string; + endDate: string; + externalId: string; +} + +function weight(externalId: string, at: string, value: number): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value, + unit: "kg", + startDate: at, + endDate: at, + externalId, + }; +} + +function pulse(externalId: string, at: string, value: number): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value, + unit: "count/min", + startDate: at, + endDate: at, + externalId, + }; +} + +function steps(day: string, value: number): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value, + unit: "count", + startDate: `${day}T00:00:00.000Z`, + endDate: `${day}T18:00:00.000Z`, + externalId: `stats:HKQuantityTypeIdentifierStepCount:${day}`, + }; +} + +/** What both accounts hold before the batch. */ +const SEED: Entry[] = [ + weight("uuid-live", "2026-07-10T07:00:00.000Z", 80.1), + weight("uuid-tomb", "2026-07-11T07:00:00.000Z", 80.4), + weight("uuid-natural", "2026-07-12T07:00:00.000Z", 80.2), + steps("2026-07-12", 5_000), +]; + +/** The batch under comparison. */ +const BATCH: Entry[] = [ + weight("uuid-fresh-1", "2026-07-13T07:00:00.000Z", 79.9), + pulse("uuid-fresh-2", "2026-07-13T08:00:00.000Z", 61), + // Exactly what is stored. + weight("uuid-live", "2026-07-10T07:00:00.000Z", 80.1), + // A sample its owner deleted, sent again. + weight("uuid-tomb", "2026-07-11T07:00:00.000Z", 80.4), + // A day total HealthKit recomputed. + steps("2026-07-12", 6_200), + // One external id twice in the batch. + pulse("uuid-twice", "2026-07-14T08:00:00.000Z", 63), + pulse("uuid-twice", "2026-07-14T08:00:00.000Z", 63), + // A new id on the instant and source of a stored reading. + weight("uuid-natural-new", "2026-07-12T07:00:00.000Z", 80.3), + weight("uuid-fresh-3", "2026-07-15T07:00:00.000Z", 79.7), +]; + +function request(entries: Entry[]): NextRequest { + return new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ entries }), + }); +} + +async function signIn(userId: string) { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id: userId, username: userId, email: `${userId}@example.test` }, + }); + const session = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +type Outcome = { index: number; status: string; reason?: string }; + +async function post(entries: Entry[]): Promise { + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST(request(entries)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { entries: Outcome[] } }; + return body.data.entries.map(({ index, status, reason }) => ({ + index, + status, + ...(reason ? { reason } : {}), + })); +} + +async function seed(userId: string) { + await signIn(userId); + const outcomes = await post(SEED); + expect(outcomes.every((o) => o.status === "inserted")).toBe(true); + await getPrismaClient().measurement.updateMany({ + where: { userId, externalId: "uuid-tomb" }, + data: { deletedAt: new Date("2026-07-12T00:00:00Z") }, + }); +} + +async function storedRows(userId: string) { + const rows = await getPrismaClient().measurement.findMany({ + where: { userId }, + select: { + type: true, + source: true, + value: true, + unit: true, + measuredAt: true, + externalId: true, + deletedAt: true, + }, + orderBy: [{ externalId: "asc" }, { measuredAt: "asc" }], + }); + return rows.map((r) => ({ ...r, deletedAt: r.deletedAt !== null })); +} + +/** + * Make the route's set-based insert fail, so every entry it buffered goes + * through the reconciler instead. Only `createManyAndReturn` inside an + * interactive transaction is refused; the reconciler never calls it. + */ +async function forceReconcilerPath() { + const { prisma } = await import("@/lib/db"); + const original = prisma.$transaction.bind(prisma); + let refused = 0; + vi.spyOn(prisma, "$transaction").mockImplementation((( + arg: unknown, + options?: unknown, + ) => { + if (typeof arg !== "function") { + return (original as (a: unknown, o?: unknown) => unknown)(arg, options); + } + return (original as (a: unknown, o?: unknown) => unknown)( + (tx: Record) => + (arg as (t: unknown) => unknown)( + new Proxy(tx, { + get(target, key) { + const value = Reflect.get(target, key); + if (key !== "measurement") return value; + return new Proxy(value as object, { + get(model, method) { + if (method === "createManyAndReturn") { + return async () => { + refused++; + throw new Error("set-based insert switched off"); + }; + } + return Reflect.get(model, method); + }, + }); + }, + }), + ), + options, + ); + }) as never); + return () => refused; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("batch set-based insert matches the reconciler entry for entry", () => { + it("answers and stores the same for both paths", async () => { + await seed(SET_USER); + const viaSet = await post(BATCH); + + cookieJar.clear(); + await seed(RECONCILER_USER); + const refusals = await forceReconcilerPath(); + const viaReconciler = await post(BATCH); + // The comparison only means something if the fallback really ran. + expect(refusals()).toBeGreaterThan(0); + + expect(viaSet).toEqual(viaReconciler); + expect(await storedRows(SET_USER)).toEqual( + await storedRows(RECONCILER_USER), + ); + + // And the shared answer is the documented one for each case. + const byIndex = new Map(viaSet.map((o) => [o.index, o.status])); + expect(byIndex.get(0)).toBe("inserted"); + expect(byIndex.get(1)).toBe("inserted"); + expect(byIndex.get(2)).toBe("duplicate"); + expect(byIndex.get(4)).toBe("updated"); + expect([byIndex.get(5), byIndex.get(6)].sort()).toEqual([ + "duplicate", + "inserted", + ]); + expect(byIndex.get(8)).toBe("inserted"); + }); +});