diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 03d749045..f83ef5a35 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -40,6 +40,7 @@ import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; +import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -71,6 +72,7 @@ interface RestoreResponse { customMetrics: number; correlationPatterns: number; intradayProfiles: number; + healthScoreRecords: number; }; } @@ -764,6 +766,14 @@ const handler = apiHandler( payload, ); + // The score as it was shown on each local day. Both ends live in + // `src/lib/export/health-score-backup.ts`, same as the two above. + const healthScoreCleared = await restoreHealthScoreData( + tx, + ownerId, + payload, + ); + const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { @@ -1128,6 +1138,7 @@ const handler = apiHandler( customMetrics: profileCleared.customMetrics, correlationPatterns: profileCleared.correlationPatterns, intradayProfiles: intradayCleared.intradayProfiles, + healthScoreRecords: healthScoreCleared.healthScoreRecords, }; }, { diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index eba7ea28a..96842301d 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -324,7 +324,9 @@ describe("writing the row", () => { * not a comment. */ function delegate(count: number) { - const createMany = vi.fn(async () => ({ count })); + const createMany = vi.fn(async (_args: { skipDuplicates?: boolean }) => ({ + count, + })); return { db: { healthScoreRecord: { createMany } } as never, createMany, diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 1e631c218..d5b5003bc 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -54,6 +54,12 @@ export const WIPE_MODELS = [ // record and nothing else holds it, so a wipe that leaves it behind leaves // behind an account's activity pattern. "IntradayCumulativeProfile", + // The health score as it was shown, one row per local day. Not a cache of + // the live computation — nothing can rebuild a past day once the readings + // behind it have moved — so leaving it behind leaves behind a record of how + // the account was doing, day by day, after a confirmation that said + // otherwise. + "HealthScoreRecord", "MeasurementReminder", // ── Medication ────────────────────────────────────────────────────────── diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 6c78f9096..07f0505c6 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -103,6 +103,12 @@ export const BACKED_UP_MODELS = [ "CustomMetric", "CustomMetricEntry", "CorrelationPattern", + // Reads like a derived tier and is not one. The live computation always + // answers for today from today's rows, so once the readings behind a past + // day have been corrected, re-synced or simply grown, nothing can reproduce + // what that day said. The row is the only copy of the number the account + // actually saw, which is exactly the test for BACKED_UP rather than DERIVED. + "HealthScoreRecord", // ── Activity and nutrition ──────────────────────────────────────────────── "Workout", @@ -157,6 +163,7 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/export/records-backup.ts", "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", + "src/lib/export/health-score-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -164,6 +171,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/app/api/admin/backups/[id]/restore/route.ts", "src/lib/export/profile-backup.ts", "src/lib/export/intraday-profile-backup.ts", + "src/lib/export/health-score-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -206,6 +214,7 @@ export const TWO_ENDED_MODELS: readonly string[] = [ "CustomMetric", "CustomMetricEntry", "CorrelationPattern", + "HealthScoreRecord", "Workout", "NutrientIntakeDay", "InboundDocument", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 4a8d970ea..e0452a9f1 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -31,6 +31,12 @@ import { type ProfileBackupCounts, type ProfileBackupSection, } from "@/lib/export/profile-backup"; +import { + buildHealthScoreBackupSection, + countHealthScoreBackupSection, + type HealthScoreBackupCounts, + type HealthScoreBackupSection, +} from "@/lib/export/health-score-backup"; import { buildIntradayProfileBackupSection, countIntradayProfileBackupSection, @@ -42,7 +48,8 @@ export interface FullBackupCounts extends RecordsBackupCounts, ProfileBackupCounts, - IntradayProfileBackupCounts { + IntradayProfileBackupCounts, + HealthScoreBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -117,6 +124,7 @@ export async function buildFullBackupPayload( records, profile, intradayProfiles, + healthScoreRecords, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -225,6 +233,13 @@ export async function buildFullBackupPayload( buildIntradayProfileBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The score as it was SHOWN on each local day. Reads like a derived tier + // and is not one: today's number always comes from today's rows, so once + // the readings behind a past day have moved, nothing can recompute what + // that day said. The row is the only copy. + buildHealthScoreBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -250,6 +265,7 @@ export async function buildFullBackupPayload( const recordsSection: RecordsBackupSection = records; const profileSection: ProfileBackupSection = profile; const intradaySection: IntradayProfileBackupSection = intradayProfiles; + const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -396,6 +412,7 @@ export async function buildFullBackupPayload( ...recordsSection, ...profileSection, ...intradaySection, + ...healthScoreSection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -418,6 +435,7 @@ export async function buildFullBackupPayload( ...countRecordsBackupSection(records), ...countProfileBackupSection(profile), ...countIntradayProfileBackupSection(intradayProfiles), + ...countHealthScoreBackupSection(healthScoreRecords), }, }; } diff --git a/src/lib/export/health-score-backup.ts b/src/lib/export/health-score-backup.ts new file mode 100644 index 000000000..c8b7f9985 --- /dev/null +++ b/src/lib/export/health-score-backup.ts @@ -0,0 +1,206 @@ +/** + * The stored health score days, with both backup ends in one file. + * + * `HealthScoreRecord` looks derived and is not. The live computation answers + * for today from today's rows, so a day whose readings have since been + * corrected, re-synced or simply extended cannot be recomputed as it stood; + * the row is the only copy of the number the account actually saw. Classifying + * it `DERIVED` and leaving it out would produce a restore that comes back with + * a blank history and reports success, which is the shape of defect the + * nutrient day totals, the custom mood tags and the custom cycle symptoms each + * shipped in turn. + * + * The builder and the restore live together deliberately, the way + * `intraday-profile-backup.ts` and `src/lib/cycle/backup.ts` do: a reader + * asking "is this carried at both ends?" answers it in one file, and a reader + * who greps only the restore ROUTE gets a false negative, because the route + * delegates. + * + * Nothing here is encrypted, so there is no portable/disaster-recovery split + * on the values. The only difference between the two purposes is identity: a + * canonical disaster-recovery payload carries the row id and its timestamps so + * the row comes back as itself, a portable export carries the day alone. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import type { ScoreBand } from "@/lib/analytics/score/types"; + +export interface HealthScoreBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** One local day's score, exactly as it was shown. */ +export interface HealthScoreRecordBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + /** Local calendar day (`YYYY-MM-DD`) in `timezone`. */ + dayKey: string; + /** + * The zone the day was cut on. Carried for the same reason the intraday + * curves carry theirs: an account that moved zones has days of different + * lengths behind it, and a restore that guessed would make them look + * comparable. + */ + timezone: string; + composite: number; + band: ScoreBand; + scoreVersion: number; + composition: string[]; + /** Each counted pillar's own score, keyed by pillar id. */ + pillarScores: Record; + inputFingerprint: string; + configVersion: number | null; + configChangedAt: string | null; + computedAt: string; + createdAt?: string; +} + +export interface HealthScoreBackupSection { + healthScoreRecords: HealthScoreRecordBackupEntry[]; +} + +export interface HealthScoreBackupCounts { + healthScoreRecords: number; +} + +/** + * Read the stored score days as JSON-safe entries. + * + * `pillarScores` arrives as an opaque `JsonValue`; it is narrowed here rather + * than cast, so a row holding something other than a flat id-to-number map + * reaches the payload as an empty map instead of as a shape the restore's + * schema would reject at the worst possible moment. + */ +function readPillarScores(value: Prisma.JsonValue): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === "number") out[key] = entry; + } + return out; +} + +/** + * Build the stored-score slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole `PrismaClient`, matching the + * other section builders, so the route's global client and the worker's local + * one share one read. + */ +export async function buildHealthScoreBackupSection( + prisma: Pick, + userId: string, + options: HealthScoreBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + // The table carries no tombstone — a day is written once and never deleted + // short of a wipe — so both purposes read the same set. + const rows = await prisma.healthScoreRecord.findMany({ + where: { userId }, + orderBy: { dayKey: "desc" }, + }); + + return { + healthScoreRecords: rows.map((row) => ({ + ...(disasterRecovery + ? { id: row.id, createdAt: row.createdAt.toISOString() } + : {}), + dayKey: row.dayKey, + timezone: row.timezone, + composite: row.composite, + band: row.band as ScoreBand, + scoreVersion: row.scoreVersion, + composition: row.composition, + pillarScores: readPillarScores(row.pillarScores), + inputFingerprint: row.inputFingerprint, + configVersion: row.configVersion, + configChangedAt: row.configChangedAt?.toISOString() ?? null, + computedAt: row.computedAt.toISOString(), + })), + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countHealthScoreBackupSection( + section: HealthScoreBackupSection, +): HealthScoreBackupCounts { + return { healthScoreRecords: section.healthScoreRecords.length }; +} + +/** Counts the score-record restore wiped, for the audit trail. */ +export interface HealthScoreRestoreCleared { + healthScoreRecords: number; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: `BackupPayload` defaults it, so every caller + * already satisfies this, and a caller that stops satisfying it fails to + * compile instead of passing `undefined` into a loop that iterates zero times + * and reports success. + */ +export interface HealthScoreRestoreInput { + healthScoreRecords: HealthScoreRecordBackupEntry[]; +} + +/** + * Re-create the account's stored score days. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. Note the asymmetry with the live write path next door: THAT one + * yields on conflict, because a second computation of a day it already holds + * must change nothing. This one does not, because the file is the authority + * here and a day it carries twice is a corrupt file, not a race. Two entries + * claiming the same local day therefore throw with a sentence rather than + * letting `skipDuplicates` pick a winner and report success. + */ +export async function restoreHealthScoreData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: HealthScoreRestoreInput, +): Promise { + const cleared = await tx.healthScoreRecord.deleteMany({ + where: { userId: ownerId }, + }); + + const seen = new Set(); + for (const record of payload.healthScoreRecords) { + if (seen.has(record.dayKey)) { + throw new Error( + `Duplicate health score record for ${record.dayKey}. One account has ` + + "at most one score per local day, and picking a winner here would " + + "silently discard the other reading of that day.", + ); + } + seen.add(record.dayKey); + } + + if (payload.healthScoreRecords.length > 0) { + await tx.healthScoreRecord.createMany({ + data: payload.healthScoreRecords.map((record) => ({ + ...(record.id ? { id: record.id } : {}), + userId: ownerId, + dayKey: record.dayKey, + timezone: record.timezone, + composite: record.composite, + band: record.band, + scoreVersion: record.scoreVersion, + composition: record.composition, + pillarScores: record.pillarScores as Prisma.InputJsonValue, + inputFingerprint: record.inputFingerprint, + configVersion: record.configVersion, + configChangedAt: record.configChangedAt + ? new Date(record.configChangedAt) + : null, + computedAt: new Date(record.computedAt), + ...(record.createdAt ? { createdAt: new Date(record.createdAt) } : {}), + })), + }); + } + + return { healthScoreRecords: cleared.count }; +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b7917266e..07cf9d86c 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -577,6 +577,30 @@ const intradayProfileBackupSchema = z }) .passthrough(); +const healthScoreRecordBackupSchema = z + .object({ + id: z.string().min(1).optional(), + dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + // The zone the day was cut on. An account that moved zones has days of + // different lengths behind it, and a restore that guessed would make them + // look comparable. + timezone: z.string().min(1), + composite: z.number().int().min(0).max(100), + // The same closed set the column's CHECK constraint carries. A band the + // schema let through and the database refused would fail the whole + // restore transaction on one bad row. + band: z.enum(["green", "yellow", "red"]), + scoreVersion: z.number().int(), + composition: z.array(z.string().min(1)).min(1), + pillarScores: z.record(z.string(), z.number()), + inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/), + configVersion: z.number().int().nullable().default(null), + configChangedAt: isoDateTime.nullable().default(null), + computedAt: isoDateTime, + createdAt: isoDateTime.optional(), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -849,6 +873,10 @@ export const backupPayloadSchema = z // The hourly shape of a cumulative day. Defaulted for the same reason as // the pair above: a file written before the table existed carries no key. intradayProfiles: z.array(intradayProfileBackupSchema).default([]), + // The score as it was shown, day by day. Defaulted for the same reason as + // the sections above: a file written before the table existed carries no + // key, and an account whose score never resolved writes []. + healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() @@ -913,6 +941,8 @@ export interface BackupSummary { correlationPatterns: number; /** Stored day curves for the cumulative metrics, across every metric. */ intradayProfiles: number; + /** Local days whose health score was written down as it was shown. */ + healthScoreRecords: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -947,6 +977,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { ), correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, + healthScoreRecords: payload.healthScoreRecords.length, }; } diff --git a/tests/integration/health-score-record.test.ts b/tests/integration/health-score-record.test.ts new file mode 100644 index 000000000..b17ecbe6f --- /dev/null +++ b/tests/integration/health-score-record.test.ts @@ -0,0 +1,407 @@ +/** + * 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 { + const { GET } = await import("@/app/api/analytics/route"); + const res = await (GET as (req: Request) => Promise)( + 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).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)); + // Nothing supplies these yet, and an invented default would be worse than + // an honest null the day the delta guard starts reading them. + expect(row.configVersion).toBeNull(); + expect(row.configChangedAt).toBeNull(); + }); + + 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[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 + )( + 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); + }); +});