Carry the recorded score days through wipe, backup and restore

A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.

The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 15:13:47 +02:00
1 parent d89b90da4b
commit ab8e9a164d
8 files changed
+692 -2

No files matched your search

@@ -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<string, string>();
const restoredBiomarkerIds = new Set<string>();
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,
};
},
{
@@ -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,
+6
View File
@@ -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 ──────────────────────────────────────────────────────────
+9
View File
@@ -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",
+19 -1
View File
@@ -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),
},
};
}
+206
View File
@@ -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<string, number>;
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<string, number> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
const out: Record<string, number> = {};
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<PrismaClient, "healthScoreRecord">,
userId: string,
options: HealthScoreBackupOptions = {},
): Promise<HealthScoreBackupSection> {
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<HealthScoreRestoreCleared> {
const cleared = await tx.healthScoreRecord.deleteMany({
where: { userId: ownerId },
});
const seen = new Set<string>();
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 };
}
+31
View File
@@ -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,
};
}
@@ -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<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));
// 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<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);
});
});