mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
The named failure now has a test that drives the whole chain a request takes: fixture rows through the real reader, the real score record write, the real dashboard snapshot, the real verdict rung, and the real digest that gives the morning push its sentence. Only the IO around that chain is faked. A version built on a hand-written snapshot literal would have stayed green through a release where the assembly between the two ends dropped the field, which has happened here before. Every case runs twice on the same data. Once with the recipe changed three days ago, once with it changed a month ago. The second run is what proves the fixture really does produce an eleven-point fall, so the first run's silence is the guard working rather than an account with nothing to say. The decline is gradual on purpose: a cliff would raise each pillar's own noise floor and the delta would be suppressed for a reason that has nothing to do with the recipe. Beside it, the boundary's edges, the notice key's two dimensions and the seam's shape, each with a case proving the guard stays quiet on innocent data. Against real Postgres, a row now has to come back carrying the recipe version and change date the account's stored configuration supplied.
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
/**
|
|
* The persisted health score day, end to end against real Postgres.
|
|
*
|
|
* A mocked Prisma proves nothing here. Every claim this file makes is about
|
|
* what is actually in the table after a real request: that computing a score
|
|
* writes the day down, that computing it again under different readings
|
|
* leaves the earlier day exactly as it stood, and that the row survives a
|
|
* backup and a restore rather than coming back blank while the restore
|
|
* reports success.
|
|
*
|
|
* The three lifecycle paths a new user-scoped table has to join, and where
|
|
* each is proved:
|
|
*
|
|
* - delete-all-data — `tests/integration/settings-data-wipe.test.ts` seeds
|
|
* every table from `information_schema` and asserts every model in the
|
|
* wipe plan is empty afterwards, so this table is covered there the
|
|
* moment it enters the plan. `src/__tests__/data-wipe-completeness.test.ts`
|
|
* is what forces it into the plan in the first place.
|
|
* - the backup writer — proved below over `buildFullBackupPayload`, which
|
|
* is the builder the weekly worker and both export routes share.
|
|
* - the restore path — proved below over the real admin restore ROUTE, not
|
|
* over the helper it delegates to. That distinction is the whole point:
|
|
* the structural guard in `backup-plan-classification.test.ts` reads the
|
|
* declared restore FILES, so a helper that exists and is never called
|
|
* passes it. Only a round trip through the route catches that.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { caches } from "@/lib/cache/server-cache";
|
|
import { encrypt } from "@/lib/crypto";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { parseBackupPayload } from "@/lib/validations/backup";
|
|
|
|
import { cookieJar, headerJar } 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),
|
|
}));
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserData: vi.fn(),
|
|
invalidateUserHealthScore: vi.fn(),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
interface AnalyticsEnvelope {
|
|
data: {
|
|
healthScore: {
|
|
composite: {
|
|
status: "ok" | "insufficient";
|
|
value?: { score: number; band: string; composition: string[] };
|
|
};
|
|
} | null;
|
|
} | null;
|
|
}
|
|
|
|
async function seedSession(username: string, role: "USER" | "ADMIN" = "USER") {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
role,
|
|
heightCm: 178,
|
|
dateOfBirth: new Date("1985-07-09"),
|
|
// Pinned so the local day the row is filed under is a fact of the
|
|
// fixture rather than a property of the machine running the suite.
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
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;
|
|
}
|
|
|
|
/** BP needs twelve paired readings inside the trailing 90-day window. */
|
|
async function seedBp(userId: string, now: number, days: number, sys: number) {
|
|
const prisma = getPrismaClient();
|
|
for (let i = 0; i < days; i++) {
|
|
const at = new Date(now - i * DAY);
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId,
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: sys,
|
|
unit: "mmHg",
|
|
measuredAt: at,
|
|
},
|
|
});
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId,
|
|
type: "BLOOD_PRESSURE_DIA",
|
|
value: 78,
|
|
unit: "mmHg",
|
|
measuredAt: at,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/** SLEEP needs fourteen distinct wake-day nights. */
|
|
async function seedSleep(userId: string, now: number, nights: number) {
|
|
const prisma = getPrismaClient();
|
|
for (let i = 0; i < nights; i++) {
|
|
const wake = new Date(now - i * DAY);
|
|
wake.setUTCHours(6, 0, 0, 0);
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId,
|
|
type: "SLEEP_DURATION",
|
|
value: 450,
|
|
unit: "min",
|
|
measuredAt: wake,
|
|
sleepStage: "ASLEEP",
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/** ADIPOSITY needs one waist reading against the seeded height. */
|
|
async function seedWaist(userId: string, now: number) {
|
|
await getPrismaClient().measurement.create({
|
|
data: {
|
|
userId,
|
|
type: "WAIST_CIRCUMFERENCE",
|
|
value: 82,
|
|
unit: "cm",
|
|
measuredAt: new Date(now),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function readAnalytics(): Promise<AnalyticsEnvelope> {
|
|
const { GET } = await import("@/app/api/analytics/route");
|
|
const res = await (GET as (req: Request) => Promise<Response>)(
|
|
new Request("http://localhost/api/analytics"),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()) as AnalyticsEnvelope;
|
|
}
|
|
|
|
describe("the score a surface shows is the score that gets written down", () => {
|
|
it("persists the composite, band and composition the analytics route returned", async () => {
|
|
const user = await seedSession("hsr-roundtrip");
|
|
const now = Date.now();
|
|
await seedBp(user.id, now, 20, 122);
|
|
await seedSleep(user.id, now, 14);
|
|
await seedWaist(user.id, now);
|
|
|
|
const shown = (await readAnalytics()).data!.healthScore!;
|
|
expect(shown.composite.status).toBe("ok");
|
|
|
|
const rows = await getPrismaClient().healthScoreRecord.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
const row = rows[0];
|
|
// The shown value and the stored value are not compared through a fixture
|
|
// in the middle — the number asserted here came back over the wire from
|
|
// the same request that wrote the row.
|
|
expect(row.composite).toBe(shown.composite.value!.score);
|
|
expect(row.band).toBe(shown.composite.value!.band);
|
|
expect(row.composition).toEqual(shown.composite.value!.composition);
|
|
expect(row.pillarScores).toBeTruthy();
|
|
// JSONB does not preserve insertion order, so the SET of keys is the
|
|
// claim, not their order. The order that matters lives in `composition`,
|
|
// which is a text array and asserted above.
|
|
expect(
|
|
Object.keys(row.pillarScores as Record<string, number>).sort(),
|
|
).toEqual([...shown.composite.value!.composition].sort());
|
|
expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(row.timezone).toBe("UTC");
|
|
expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10));
|
|
// The recipe the day was scored under, resolved on the write path from
|
|
// the account's own stored configuration. This account never opened the
|
|
// settings surface, so the honest answer is version 0 with no change
|
|
// date — the thing the first authored recipe will later be a move FROM.
|
|
expect(row.configVersion).toBe(0);
|
|
expect(row.configChangedAt).toBeNull();
|
|
});
|
|
|
|
it("stamps the account's own recipe on the row, straight from the stored configuration", async () => {
|
|
// The supply half of the delta guard, over real Postgres. Nothing in
|
|
// the request carries the recipe: the write path resolves it from the
|
|
// same column the composition came from, so a version that never
|
|
// reached the writer shows up as a row that cannot say which recipe
|
|
// produced it — and the seam becomes underivable a release later.
|
|
const user = await seedSession("hsr-recipe");
|
|
const now = Date.now();
|
|
await seedBp(user.id, now, 20, 122);
|
|
await seedSleep(user.id, now, 14);
|
|
await seedWaist(user.id, now);
|
|
const changedAt = new Date(now - 3 * DAY);
|
|
await getPrismaClient().user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
healthScoreConfigJson: {
|
|
excludedPillars: ["FITNESS"],
|
|
version: 6,
|
|
changedAt: changedAt.toISOString(),
|
|
},
|
|
},
|
|
});
|
|
|
|
expect((await readAnalytics()).data!.healthScore!.composite.status).toBe(
|
|
"ok",
|
|
);
|
|
|
|
const row = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(row.configVersion).toBe(6);
|
|
expect(row.configChangedAt?.toISOString()).toBe(changedAt.toISOString());
|
|
});
|
|
|
|
it("leaves the recorded day untouched when the same day is computed again under a changed standing", async () => {
|
|
const user = await seedSession("hsr-never-restate");
|
|
const now = Date.now();
|
|
await seedBp(user.id, now, 20, 122);
|
|
await seedSleep(user.id, now, 14);
|
|
await seedWaist(user.id, now);
|
|
|
|
const first = (await readAnalytics()).data!.healthScore!;
|
|
expect(first.composite.status).toBe("ok");
|
|
const before = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
|
|
// Change what the score is made of, two ways at once: a fourth pillar
|
|
// joins the composition, and blood pressure grades worse. Either alone
|
|
// moves the live number.
|
|
await getPrismaClient().measurement.deleteMany({
|
|
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
|
|
});
|
|
await getPrismaClient().measurement.deleteMany({
|
|
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
|
|
});
|
|
await seedBp(user.id, now, 20, 168);
|
|
for (let i = 0; i < 30; i++) {
|
|
await getPrismaClient().measurement.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 11_000,
|
|
unit: "steps",
|
|
measuredAt: new Date(now - i * DAY),
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
});
|
|
}
|
|
|
|
// `/api/analytics` reads through a 60-second cache, so without this the
|
|
// second request would replay the first request's body and nothing would
|
|
// recompute. The assertion below is what caught it.
|
|
caches.analytics.deleteByPrefix(`${user.id}|`);
|
|
caches.insightsDerived.deleteByPrefix(`${user.id}|`);
|
|
|
|
const second = (await readAnalytics()).data!.healthScore!;
|
|
expect(second.composite.status).toBe("ok");
|
|
// The live number really did move — without this the test below would
|
|
// pass against a recomputation that changed nothing.
|
|
expect(second.composite.value!.score).not.toBe(
|
|
first.composite.value!.score,
|
|
);
|
|
|
|
const after = await getPrismaClient().healthScoreRecord.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(after).toHaveLength(1);
|
|
expect(after[0]).toEqual(before);
|
|
});
|
|
});
|
|
|
|
describe("the recorded day survives a backup and a restore", () => {
|
|
it("carries the row out through the shared backup builder", async () => {
|
|
const user = await seedSession("hsr-backup");
|
|
const now = Date.now();
|
|
await seedBp(user.id, now, 20, 122);
|
|
await seedSleep(user.id, now, 14);
|
|
await seedWaist(user.id, now);
|
|
await readAnalytics();
|
|
|
|
const stored = await getPrismaClient().healthScoreRecord.findFirstOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
const { payload, counts } = await buildFullBackupPayload(
|
|
getPrismaClient(),
|
|
user.id,
|
|
{ purpose: "disaster-recovery" },
|
|
);
|
|
expect(counts.healthScoreRecords).toBe(1);
|
|
const carried = (payload as { healthScoreRecords: unknown[] })
|
|
.healthScoreRecords;
|
|
expect(carried).toHaveLength(1);
|
|
expect(carried[0]).toMatchObject({
|
|
id: stored.id,
|
|
dayKey: stored.dayKey,
|
|
timezone: stored.timezone,
|
|
composite: stored.composite,
|
|
band: stored.band,
|
|
scoreVersion: stored.scoreVersion,
|
|
composition: stored.composition,
|
|
inputFingerprint: stored.inputFingerprint,
|
|
});
|
|
// And the file it produced is one the restore side will accept, rather
|
|
// than one that parses everywhere except at the moment it is needed.
|
|
expect(
|
|
parseBackupPayload(JSON.stringify(payload)).healthScoreRecords,
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("brings the row back through the admin restore route", async () => {
|
|
const prisma = getPrismaClient();
|
|
const admin = await seedSession("hsr-restore", "ADMIN");
|
|
const now = Date.now();
|
|
await seedBp(admin.id, now, 20, 122);
|
|
await seedSleep(admin.id, now, 14);
|
|
await seedWaist(admin.id, now);
|
|
await readAnalytics();
|
|
|
|
const before = await prisma.healthScoreRecord.findFirstOrThrow({
|
|
where: { userId: admin.id },
|
|
});
|
|
const { payload } = await buildFullBackupPayload(prisma, admin.id, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: admin.id,
|
|
type: "HEALTH_SCORE_RECORD_RESTORE_TEST",
|
|
data: encrypt(JSON.stringify(payload)),
|
|
},
|
|
});
|
|
|
|
// Wipe the table so a restore that carries nothing cannot pass by leaving
|
|
// the row that was already there.
|
|
await prisma.healthScoreRecord.deleteMany({ where: { userId: admin.id } });
|
|
expect(
|
|
await prisma.healthScoreRecord.count({ where: { userId: admin.id } }),
|
|
).toBe(0);
|
|
|
|
const { POST } = await import("@/app/api/admin/backups/[id]/restore/route");
|
|
const res = await POST(
|
|
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as unknown as Parameters<typeof POST>[0],
|
|
{ params: Promise.resolve({ id: backup.id }) },
|
|
);
|
|
const body = (await res.json()) as {
|
|
data: { summary: { healthScoreRecords: number } } | null;
|
|
error: string | null;
|
|
};
|
|
expect(body.error).toBeNull();
|
|
expect(res.status).toBe(200);
|
|
expect(body.data!.summary.healthScoreRecords).toBe(1);
|
|
|
|
const restored = await prisma.healthScoreRecord.findMany({
|
|
where: { userId: admin.id },
|
|
});
|
|
expect(restored).toHaveLength(1);
|
|
expect(restored[0].id).toBe(before.id);
|
|
expect(restored[0].dayKey).toBe(before.dayKey);
|
|
expect(restored[0].composite).toBe(before.composite);
|
|
expect(restored[0].band).toBe(before.band);
|
|
expect(restored[0].composition).toEqual(before.composition);
|
|
expect(restored[0].pillarScores).toEqual(before.pillarScores);
|
|
expect(restored[0].inputFingerprint).toBe(before.inputFingerprint);
|
|
expect(restored[0].computedAt.toISOString()).toBe(
|
|
before.computedAt.toISOString(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("delete all data", () => {
|
|
it("removes the recorded days along with the rest of the record", async () => {
|
|
const user = await seedSession("hsr-wipe");
|
|
const now = Date.now();
|
|
await seedBp(user.id, now, 20, 122);
|
|
await seedSleep(user.id, now, 14);
|
|
await seedWaist(user.id, now);
|
|
await readAnalytics();
|
|
expect(
|
|
await getPrismaClient().healthScoreRecord.count({
|
|
where: { userId: user.id },
|
|
}),
|
|
).toBe(1);
|
|
|
|
const { DELETE } = await import("@/app/api/settings/data/route");
|
|
const res = await (
|
|
DELETE as unknown as (req: Request) => Promise<Response>
|
|
)(
|
|
new Request("http://localhost/api/settings/data", {
|
|
method: "DELETE",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
expect(
|
|
await getPrismaClient().healthScoreRecord.count({
|
|
where: { userId: user.id },
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
});
|