mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
This commit is contained in:
1 parent
a9700bfa62
commit
557865acca
1 file changed
+257
@@ -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<Outcome[]> {
|
||||
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<string, unknown>) =>
|
||||
(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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user