Files
HealthLog/src/lib/validations/__tests__/backup.test.ts
T
Marc-André Bombeck 210becb28b feat(coach): lay down the reasoning, trail and memory contract
Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.

- Schema: operator reasoning controls on app_settings (0376), the
  encrypted trail text on coach_messages (0377), and source,
  last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
  CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
  src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
  meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
  done.withheldResults, result.interim, a compare chart kind, eight
  table refs, three more clarification kinds with an assumed choice,
  the change_assumption chip, and assumptions, activity, stop,
  memoryNote and planProposal on the provenance. Zod mirror, type
  parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
  acted on.
- Stub contracts for the memory functions and the activity recorder,
  inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
  reference is resolved against the restored messages like its
  conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
  interim tables, comparison charts and reasoning settings in all seven
  locales, each reachable from a catalog and checked both ways.
2026-10-06 18:39:50 +02:00

719 lines
23 KiB
TypeScript

/**
* v1.4.16 H2 — moodEntrySchema.tags must reject malformed tag blobs
* before the restore transaction inserts them. The DB column stores
* tags as a JSON-array-as-string ("[\"work\",\"sleep\"]"), so an
* arbitrary string slipping through Zod is a foot-gun:
* - downstream readers `JSON.parse` blindly and crash on a
* non-array
* - the iOS native client renders tags one-per-row and would
* either render garbage or throw
*
* We accept three shapes:
* - `null` (and omitted/undefined): "no tags on this entry"
* - empty string `""`: legacy wire format (handled as null)
* - a JSON-string that parses to an array of strings.
*
* Anything else (object, JSON-with-numbers, garbage) is rejected at
* schema time with a 422 response from the restore endpoint instead
* of bubbling out of `prisma.createMany` with a useless message.
*/
import { describe, expect, it } from "vitest";
import { backupPayloadSchema, parseBackupPayload } from "../backup";
import { isCompatibleSchemaVersion, summarizeBackup } from "../backup-summary";
const baseEntry = {
date: "2026-05-08",
mood: "GUT",
score: 4,
source: "MOODLOG",
loggedAt: "2026-05-08T20:00:00.000Z",
};
function parseWithMoodTags(tags: unknown) {
return backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [{ ...baseEntry, tags }],
});
}
describe("moodEntrySchema.tags strict validation", () => {
it("accepts null", () => {
const r = parseWithMoodTags(null);
expect(r.success).toBe(true);
});
it("accepts an empty string (legacy wire format)", () => {
const r = parseWithMoodTags("");
expect(r.success).toBe(true);
});
it("accepts a JSON-string that parses to an array of strings", () => {
const r = parseWithMoodTags('["work","sleep"]');
expect(r.success).toBe(true);
});
it("rejects a non-JSON garbage string", () => {
const r = parseWithMoodTags("not-json{");
expect(r.success).toBe(false);
});
it("rejects a JSON object that is not an array", () => {
const r = parseWithMoodTags('{"a":1}');
expect(r.success).toBe(false);
});
it("rejects a JSON array containing non-strings", () => {
const r = parseWithMoodTags("[1,2,3]");
expect(r.success).toBe(false);
});
it("accepts an omitted tags field (optional)", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [{ ...baseEntry }],
});
expect(r.success).toBe(true);
});
});
describe("backupPayloadSchema — v1.15.0 cycle round-trip", () => {
it("defaults the cycle fields when a pre-v1.15 blob omits them", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.cycleProfile).toBeNull();
expect(r.data.cycles).toEqual([]);
expect(r.data.cycleDayLogs).toEqual([]);
}
});
it("round-trips a cycle profile, observed span, and day-log with notes ciphertext", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
cycleProfile: {
goal: "TRYING_TO_CONCEIVE",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
lutealPhaseLength: 14,
sensitiveCategoryEncryption: true,
},
cycles: [
{
startDate: "2026-04-20",
endDate: "2026-05-17",
periodEndDate: "2026-04-24",
lengthDays: 28,
ovulationDate: "2026-05-04",
ovulationConfirmed: true,
tz: "Europe/Berlin",
},
],
cycleDayLogs: [
{
date: "2026-04-20",
flow: "MEDIUM",
intermenstrualBleeding: false,
sexualActivity: true,
protectedSex: false,
contraceptive: "NONE",
// The note must round-trip as the opaque ciphertext envelope.
notesEncrypted: "v1:deadbeef:cipher",
source: "APPLE_HEALTH",
externalId: "hkcycle:2026-04-20",
symptomKeys: ["cramps", "fatigue"],
},
],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.cycleProfile?.goal).toBe("TRYING_TO_CONCEIVE");
expect(r.data.cycles[0].lengthDays).toBe(28);
expect(r.data.cycleDayLogs[0].notesEncrypted).toBe("v1:deadbeef:cipher");
expect(r.data.cycleDayLogs[0].symptomKeys).toEqual(["cramps", "fatigue"]);
}
});
});
describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
it("defaults every records field when a pre-v1.28 blob omits them", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.labResults).toEqual([]);
expect(r.data.biomarkers).toEqual([]);
expect(r.data.illnessEpisodes).toEqual([]);
expect(r.data.allergies).toEqual([]);
expect(r.data.familyHistory).toEqual([]);
expect(r.data.workouts).toEqual([]);
expect(r.data.documents).toEqual([]);
expect(r.data.manifest).toBeNull();
}
});
it("round-trips a lab result cross-referencing a biomarker by name", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
biomarkers: [
{
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: null,
upperBound: 130,
panel: "Lipid panel",
hidden: false,
context: "Low-density lipoprotein.",
},
],
labResults: [
{
panel: "Lipid panel",
analyte: "LDL Cholesterol",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: "2026-04-01T09:00:00.000Z",
source: "MANUAL",
biomarkerName: "LDL Cholesterol",
note: "Fasted draw.",
},
],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.biomarkers[0].name).toBe("LDL Cholesterol");
expect(r.data.labResults[0].biomarkerName).toBe("LDL Cholesterol");
expect(r.data.labResults[0].note).toBe("Fasted draw.");
}
});
it("round-trips an illness episode with a flare referencing its parent + nested day-logs", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
illnessEpisodes: [
{
id: "ep-1",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: "2026-01-01T00:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: null,
dayLogs: [],
},
{
id: "ep-2",
label: "Migraine flare",
type: "CHRONIC",
lifecycle: "FLARE",
onsetAt: "2026-04-10T00:00:00.000Z",
resolvedAt: "2026-04-12T00:00:00.000Z",
parentConditionId: "ep-1",
note: "Triggered by travel.",
dayLogs: [
{
date: "2026-04-10",
functionalImpact: 2,
feverC: null,
symptoms: [{ key: "headache", severity: 3 }],
note: "Bad day.",
},
],
},
],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.illnessEpisodes).toHaveLength(2);
expect(r.data.illnessEpisodes[1].parentConditionId).toBe("ep-1");
expect(r.data.illnessEpisodes[1].dayLogs[0].symptoms[0].key).toBe(
"headache",
);
}
});
it("round-trips allergies, family history, workouts, documents, and the manifest", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
allergies: [
{
id: "al-1",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
onsetAt: null,
reaction: "Hives",
note: null,
},
],
familyHistory: [
{
id: "fh-1",
relationship: "MOTHER",
condition: "Type 2 diabetes",
ageAtOnset: 52,
note: null,
},
],
workouts: [
{
sportType: "running",
startedAt: "2026-04-01T07:00:00.000Z",
endedAt: "2026-04-01T08:00:00.000Z",
durationSec: 3600,
totalEnergyKcal: 600,
totalDistanceM: 10000,
avgHeartRate: 150,
maxHeartRate: 175,
minHeartRate: 110,
stepCount: 9000,
elevationM: 80,
pauseDurationSec: 0,
source: "APPLE_HEALTH",
externalId: "hk-1",
},
],
documents: [
{
id: "doc-1",
kind: "LAB_RESULT",
title: "Blood panel",
filename: "panel.pdf",
mimeType: "application/pdf",
byteSize: 12345,
status: "STORED",
reportDate: "2026-03-30",
documentDate: "2026-03-30",
summary: "Routine panel.",
},
],
manifest: {
documents: { included: "metadata-only", note: "Files excluded." },
workouts: { included: "summary-only", note: "Routes excluded." },
},
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.allergies[0].substance).toBe("Penicillin");
expect(r.data.familyHistory[0].relationship).toBe("MOTHER");
expect(r.data.workouts[0].sportType).toBe("running");
expect(r.data.documents[0].id).toBe("doc-1");
// A document manifest entry never carries the raw file bytes — the
// schema simply has no field for it, so an attacker-supplied
// `contentEncrypted` key on upload is dropped as passthrough noise,
// never parsed into a typed field a restore path could act on.
expect(r.data.documents[0]).not.toHaveProperty("contentEncrypted");
expect(r.data.manifest?.documents.included).toBe("metadata-only");
expect(r.data.manifest?.workouts.included).toBe("summary-only");
}
});
it("summarizeBackup reports counts for every v1.28 domain, incl. nested day-logs", () => {
const parsed = backupPayloadSchema.parse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
labResults: [
{
analyte: "HbA1c",
unit: "%",
takenAt: "2026-04-01T09:00:00.000Z",
source: "MANUAL",
},
],
illnessEpisodes: [
{
id: "ep-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-04-01T00:00:00.000Z",
dayLogs: [
{ date: "2026-04-01", symptoms: [] },
{ date: "2026-04-02", symptoms: [] },
],
},
],
workouts: [
{
sportType: "cycling",
startedAt: "2026-04-01T07:00:00.000Z",
endedAt: "2026-04-01T08:00:00.000Z",
durationSec: 3600,
source: "MANUAL",
},
],
});
const summary = summarizeBackup(parsed);
expect(summary.labResults).toBe(1);
expect(summary.illnessEpisodes).toBe(1);
expect(summary.illnessDayLogs).toBe(2);
expect(summary.workouts).toBe(1);
expect(summary.allergies).toBe(0);
expect(summary.familyHistory).toBe(0);
expect(summary.documents).toBe(0);
});
// Watched red: with the v1.37.19 section counts removed from
// `summarizeBackup` (the pre-fix shape stopped at healthScoreRecords),
// every assertion below fails — a file carrying visits or an Impfpass
// was restored in full but reported as if those sections were empty.
it("summarizeBackup counts practitioners, encounters, links, and vaccinations", () => {
const ts = "2026-05-08T07:00:00.000Z";
const parsed = backupPayloadSchema.parse({
schemaVersion: "1",
exportedAt: ts,
userId: "u1",
practitioners: [
{ id: "pr-1", name: "Praxis", createdAt: ts, updatedAt: ts },
],
encounters: [
{
id: "en-1",
occurredAt: ts,
status: "DONE",
kind: "ROUTINE",
createdAt: ts,
updatedAt: ts,
},
],
encounterDocumentLinks: [
{ encounterId: "en-1", targetId: "doc-1", createdAt: ts },
],
encounterLabLinks: [
{ encounterId: "en-1", targetId: "lab-1", createdAt: ts },
],
encounterConditionLinks: [],
vaccinations: [
{ id: "va-1", occurredAt: ts, createdAt: ts, updatedAt: ts },
],
vaccinationDocumentLinks: [
{ vaccinationId: "va-1", targetId: "doc-1", createdAt: ts },
],
});
const summary = summarizeBackup(parsed);
expect(summary.practitioners).toBe(1);
expect(summary.encounters).toBe(1);
expect(summary.encounterLinks).toBe(2);
expect(summary.vaccinations).toBe(1);
expect(summary.vaccinationLinks).toBe(1);
});
});
describe("backupPayloadSchema — canonical v2 identity compatibility", () => {
const base = {
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "u1",
};
it("treats an unversioned payload as legacy v1 and accepts measurements without ids", () => {
const payload = parseBackupPayload({
...base,
measurements: [
{
type: "WEIGHT",
value: 75,
unit: "kg",
measuredAt: "2026-07-19T07:00:00.000Z",
},
],
});
expect(payload.schemaVersion).toBe("1");
expect(isCompatibleSchemaVersion(payload.schemaVersion)).toBe(true);
});
it("requires stable measurement ids in canonical v2 payloads", () => {
const result = backupPayloadSchema.safeParse({
...base,
schemaVersion: "2",
measurements: [
{
type: "SLEEP_DURATION",
value: 120,
unit: "minutes",
measuredAt: "2026-07-19T22:00:00.000Z",
source: "IMPORT",
sleepStage: "DEEP",
},
],
});
expect(result.success).toBe(false);
expect(isCompatibleSchemaVersion("1")).toBe(true);
expect(isCompatibleSchemaVersion("2")).toBe(true);
expect(isCompatibleSchemaVersion("3")).toBe(false);
});
});
describe("backupPayloadSchema — database enum boundaries", () => {
const enumPayload = {
schemaVersion: "1",
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "u1",
measurements: [
{
id: "measurement-1",
type: "WEIGHT",
value: 75,
unit: "kg",
measuredAt: "2026-07-19T07:00:00.000Z",
source: "MANUAL",
},
],
intakeEvents: [
{
medication: "Example",
scheduledFor: "2026-07-19T08:00:00.000Z",
source: "WEB",
},
],
cycleProfile: {
goal: "GENERAL_HEALTH",
secondarySymptom: "MUCUS",
},
cycleDayLogs: [
{
date: "2026-07-19",
flow: "MEDIUM",
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
pregnancyTest: "NEGATIVE",
progesteroneTest: "INDETERMINATE",
contraceptive: "NONE",
source: "MANUAL",
},
],
illnessEpisodes: [
{
id: "illness-1",
label: "Cold",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-07-01T00:00:00.000Z",
},
],
allergies: [
{
id: "allergy-1",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "ACTIVE",
},
],
familyHistory: [
{
id: "family-1",
relationship: "MOTHER",
condition: "Diabetes",
},
],
workouts: [
{
id: "workout-1",
sportType: "RUNNING",
startedAt: "2026-07-19T06:00:00.000Z",
endedAt: "2026-07-19T06:30:00.000Z",
durationSec: 1800,
source: "APPLE_HEALTH",
},
],
documents: [
{
id: "document-1",
kind: "LAB_RESULT",
mimeType: "application/pdf",
byteSize: 42,
status: "STORED",
summaryState: "NONE",
},
],
};
const enumPaths: Array<[string, Array<string | number>]> = [
["measurement type", ["measurements", 0, "type"]],
["measurement source", ["measurements", 0, "source"]],
["intake source", ["intakeEvents", 0, "source"]],
["cycle goal", ["cycleProfile", "goal"]],
["cycle secondary symptom", ["cycleProfile", "secondarySymptom"]],
["cycle flow", ["cycleDayLogs", 0, "flow"]],
["cycle ovulation test", ["cycleDayLogs", 0, "ovulationTest"]],
["cycle cervical mucus", ["cycleDayLogs", 0, "cervicalMucus"]],
["cycle cervix position", ["cycleDayLogs", 0, "cervixPosition"]],
["cycle cervix firmness", ["cycleDayLogs", 0, "cervixFirmness"]],
["cycle cervix opening", ["cycleDayLogs", 0, "cervixOpening"]],
["cycle pregnancy test", ["cycleDayLogs", 0, "pregnancyTest"]],
["cycle progesterone test", ["cycleDayLogs", 0, "progesteroneTest"]],
["cycle contraceptive", ["cycleDayLogs", 0, "contraceptive"]],
["cycle source", ["cycleDayLogs", 0, "source"]],
["illness type", ["illnessEpisodes", 0, "type"]],
["illness lifecycle", ["illnessEpisodes", 0, "lifecycle"]],
["allergy category", ["allergies", 0, "category"]],
["allergy type", ["allergies", 0, "type"]],
["allergy severity", ["allergies", 0, "severity"]],
["allergy status", ["allergies", 0, "status"]],
["family relationship", ["familyHistory", 0, "relationship"]],
["workout source", ["workouts", 0, "source"]],
["document kind", ["documents", 0, "kind"]],
["document status", ["documents", 0, "status"]],
["document summary state", ["documents", 0, "summaryState"]],
];
it.each(enumPaths)("rejects an unsupported %s", (_label, path) => {
const candidate = structuredClone(enumPayload);
let cursor: Record<PropertyKey, unknown> = candidate;
for (const segment of path.slice(0, -1)) {
cursor = cursor[segment] as Record<PropertyKey, unknown>;
}
cursor[path.at(-1)!] = "UNSUPPORTED_BACKUP_ENUM";
const parsed = backupPayloadSchema.safeParse(candidate);
expect(parsed.success).toBe(false);
});
});
// #1034 — a restore applies the same units-per-dose rule as the write
// routes, on the medication and on each schedule, so a hand-edited file
// cannot put a value into the database that no route would accept.
describe("backupPayloadSchema — unitsPerDose", () => {
function withMedication(med: Record<string, unknown>) {
return backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-09-25T07:00:00.000Z",
userId: "u1",
measurements: [],
intakeEvents: [],
medications: [{ name: "Example", dose: "5 mg", ...med }],
});
}
it("accepts the Decimal strings a backup carries, mixed values included", () => {
for (const v of ["1", "0.5", "1.5", "2.25", "1.3333", "1.2"]) {
expect(withMedication({ unitsPerDose: v }).success).toBe(true);
}
});
it("refuses a medication value the write routes would refuse", () => {
for (const v of ["0", "-1", "101", "abc", "1.23456"]) {
expect(withMedication({ unitsPerDose: v }).success).toBe(false);
}
});
it("applies the rule to the per-schedule override, string or number", () => {
const schedule = (unitsPerDose: unknown) => ({
schedules: [{ windowStart: "08:00", windowEnd: "08:00", unitsPerDose }],
});
expect(withMedication(schedule("1.5")).success).toBe(true);
expect(withMedication(schedule(2.25)).success).toBe(true);
expect(withMedication(schedule(null)).success).toBe(true);
expect(withMedication(schedule("0")).success).toBe(false);
expect(withMedication(schedule(150)).success).toBe(false);
expect(withMedication(schedule("x")).success).toBe(false);
});
});
describe("a Coach turn's stored tables are bounded on restore", () => {
function parseWithResults(results: Record<string, unknown>) {
return backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-09-27T07:00:00.000Z",
userId: "u1",
measurements: [],
medications: [],
intakeEvents: [],
moodEntries: [],
coachConversations: [
{
id: "conv-1",
title: "Sleep",
documentScoped: false,
createdAt: "2026-09-27T07:00:00.000Z",
updatedAt: "2026-09-27T07:00:00.000Z",
messages: [
{
id: "msg-1",
role: "assistant",
content: "Here is the table.",
createdAt: "2026-09-27T07:00:00.000Z",
...results,
},
],
},
],
});
}
it("accepts tables the size a turn can store", () => {
const json = JSON.stringify([{ rows: "x".repeat(100 * 1024) }]);
expect(parseWithResults({ resultsJson: json }).success).toBe(true);
const sealed = "A".repeat(200 * 1024);
expect(parseWithResults({ resultsEncrypted: sealed }).success).toBe(true);
});
it("refuses readable tables larger than a turn can store", () => {
const json = JSON.stringify([{ rows: "x".repeat(129 * 1024) }]);
expect(parseWithResults({ resultsJson: json }).success).toBe(false);
// The cap is in bytes: multi-byte text that fits by length still counts.
const wide = JSON.stringify([{ rows: "é".repeat(70 * 1024) }]);
expect(parseWithResults({ resultsJson: wide }).success).toBe(false);
});
it("refuses sealed tables larger than a turn can store", () => {
const sealed = "A".repeat(300 * 1024);
expect(parseWithResults({ resultsEncrypted: sealed }).success).toBe(false);
});
it("bounds the trail's text the same way, at its own 16 KB", () => {
const fits = JSON.stringify({
entries: [{ id: "a1", text: "x".repeat(15 * 1024) }],
});
expect(parseWithResults({ trailJson: fits }).success).toBe(true);
expect(
parseWithResults({ trailEncrypted: "A".repeat(30 * 1024) }).success,
).toBe(true);
const tooLong = JSON.stringify({
entries: [{ id: "a1", text: "x".repeat(17 * 1024) }],
});
expect(parseWithResults({ trailJson: tooLong }).success).toBe(false);
expect(
parseWithResults({ trailEncrypted: "A".repeat(40 * 1024) }).success,
).toBe(false);
});
});