Files
HealthLog/tests/integration/source-priority-two-axis.test.ts
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

272 lines
8.8 KiB
TypeScript

/**
* v1.4.25 — two-axis source-priority resolution, end-to-end.
*
* Wave 8c lays down a per-user, per-metric, per-device-type canonical
* picker. The picker is unit-tested at the helper level; this file
* locks the integration path: a user with both axes set in
* `User.sourcePriorityJson`, multiple Measurement rows for the same
* day across sources + device-types, and the analytics route's
* SLEEP_DURATION aggregator picking exactly the canonical row.
*
* Three fixtures, all on the same Berlin day:
*
* 1. Plain default user — only Withings rows. The picker is a
* pass-through; the route returns the raw total. Locks the
* v1.4.25-today behaviour against regressions.
*
* 2. Per-metric override — Withings + Apple Health both contributed
* sleep for the same night. The user's per-metric ladder pins
* Withings first (default for sleep is Apple Health > Withings),
* so the route should report Withings' total only.
*
* 3. Per-device override — Apple Watch + iPhone both wrote sleep
* rows under APPLE_HEALTH. The default device-type ladder ranks
* watch above phone, so the route should pick the watch row and
* drop the iPhone row.
*
* Sleep is the right metric to verify end-to-end because the
* analytics route's sleep aggregation is the only call site of
* `pickCanonicalSourceRows()` today; this test guards the wire-up
* without needing a new aggregator.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
/**
* The night the fixtures sleep, ten days back from whenever the suite runs.
* The analytics route summarises a window that ends now; a fixed date slides
* out of it as the calendar moves on.
*/
const SLEEP_DAY = new Date(Date.now() - 10 * 86_400_000)
.toISOString()
.slice(0, 10);
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();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
interface AnalyticsEnvelope {
data: {
summaries: Record<
string,
{ count: number; latest: number | null; mean: number | null }
>;
};
}
async function seedUserWithSession(
username: string,
sourcePriorityJson?: unknown,
) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
...(sourcePriorityJson !== undefined
? {
sourcePriorityJson:
sourcePriorityJson as import("@/generated/prisma/client").Prisma.InputJsonValue,
}
: {}),
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
async function callAnalytics(): Promise<AnalyticsEnvelope> {
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
return (await response.json()) as AnalyticsEnvelope;
}
describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () => {
it("plain default user: single-source rows pass through unchanged", async () => {
// Locks the v1.4.25-today behaviour: a user with no override and
// only one source per metric should see the raw daily total.
const prisma = getPrismaClient();
const user = await seedUserWithSession("plain-default");
// 420 minutes of sleep, one Withings row, no override.
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 420,
unit: "minutes",
source: "WITHINGS",
measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`),
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
expect(summary.count).toBe(1);
expect(summary.latest).toBe(420);
});
// The slim summaries reader post-processes sleep rows through the canonical
// per-night writer picker. These DB-backed cases pin that final route seam,
// not only the pure source-priority helper.
it("per-metric override pins Withings first when Apple Health also reported sleep", async () => {
// Same night, both sources contributed sleep duration. Without an
// override the default ladder (APPLE_HEALTH > WITHINGS) would win;
// with the override the picker returns the Withings row instead.
const prisma = getPrismaClient();
const user = await seedUserWithSession("per-metric-override", {
metricPriority: {
sleep: ["WITHINGS", "APPLE_HEALTH"],
},
});
// Apple Health stage row — 480 minutes total.
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
externalId: "ah-sleep-1",
},
});
// Withings nightly summary — 410 minutes.
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 410,
unit: "minutes",
source: "WITHINGS",
measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`),
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
// One canonical night (the Withings row); the Apple Health row is
// dropped from the aggregation but still lives in the DB.
expect(summary.count).toBe(1);
expect(summary.latest).toBe(410);
});
it("per-device override drops iPhone rows in favour of Apple Watch rows", async () => {
// Same source, same night — but Apple Watch and iPhone both wrote
// sleep samples. The default device-type ladder ranks watch above
// phone, so the watch row should survive.
const prisma = getPrismaClient();
const user = await seedUserWithSession("per-device-override");
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 380,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
externalId: "ah-sleep-watch",
deviceType: "watch",
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`),
externalId: "ah-sleep-phone",
deviceType: "phone",
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
// Watch wins; the phone row drops out of the aggregate.
expect(summary.count).toBe(1);
expect(summary.latest).toBe(380);
});
it("user override flips device-type ladder so phone wins", async () => {
// Same fixture as the previous test, but the user pinned phone
// first via the deviceTypePriority.default ladder.
const prisma = getPrismaClient();
const user = await seedUserWithSession("phone-first", {
deviceTypePriority: { default: ["phone", "watch"] },
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
externalId: "ah-sleep-watch",
deviceType: "watch",
},
});
await prisma.measurement.create({
data: {
userId: user.id,
type: "SLEEP_DURATION",
value: 380,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`),
externalId: "ah-sleep-phone",
deviceType: "phone",
},
});
const envelope = await callAnalytics();
const summary = envelope.data.summaries.SLEEP_DURATION;
expect(summary.count).toBe(1);
expect(summary.latest).toBe(380);
});
});