mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Seed one account as far as the schema allows (a row of every two-ended model, every column of those rows and of the account row, the instance settings, a sharing grant), export it both ways and restore each file into a fresh account, over the live account, and from a version-1 file in the old shapes. The files, counts, parsed payload, preview, restore outcome, reported phases, audit rows, wide-event fields and every row the account holds afterwards are compared with golden copies after ids, ciphertext and run-time timestamps are normalised. Refusals, skip reports and the schema's issue list for malformed files are pinned the same way. The seed moves out of the round-trip test into a shared fixture module so both files fill the same account.
1437 lines
44 KiB
TypeScript
1437 lines
44 KiB
TypeScript
/**
|
|
* The backup file and the restored account, pinned against golden copies.
|
|
*
|
|
* The other backup tests each prove one property: every model comes back
|
|
* (`backup-round-trip.test.ts`), every column does, a refusal happens before
|
|
* the first delete. None of them says the whole file and the whole restored
|
|
* account are what they were yesterday, and that is the claim a change to the
|
|
* backup code has to keep when it means to change nothing. This file makes it:
|
|
*
|
|
* 1. Fill one account as far as the schema allows: a row of every
|
|
* two-ended model, every column of those rows, every column of the
|
|
* account row, the instance settings, and a sharing grant beside it.
|
|
* 2. Export it both ways (disaster recovery, portable) and pin the files,
|
|
* their counts, the parsed payload and the preview.
|
|
* 3. Restore each file into a fresh account, and the disaster-recovery file
|
|
* once more over the live account, and pin what the restore answered,
|
|
* the phases it reported, the audit rows and wide-event fields it wrote,
|
|
* every row the account holds afterwards, and the file it exports then.
|
|
* 4. Restore a version-1 file in the old shapes (no ids, references by
|
|
* name, the retired heart-rate variability filing, the Coach switch in
|
|
* module availability) over an account that already holds rows.
|
|
* 5. Hand the restore malformed and unresolvable files and pin each refusal
|
|
* and each skip report, and the schema's issue list for a bad file.
|
|
*
|
|
* Values that legitimately differ between two runs are normalised, never
|
|
* dropped: ids become `<id:N>` in order of first appearance, ciphertext
|
|
* becomes the text it opens to (or its length when no codec opens it), and a
|
|
* timestamp written during the run becomes `<now>`. Key order is kept, so a
|
|
* file that carries the same values in another order differs.
|
|
*
|
|
* `BACKUP_PARITY_WRITE=1` rewrites the golden copies. Only do that for a
|
|
* change that means to alter the backup, and review the diff of
|
|
* `tests/integration/__golden__/backup-parity/` as part of it.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { PrismaClient } from "@/generated/prisma/client";
|
|
import { decrypt, decryptBytes, encrypt } from "@/lib/crypto";
|
|
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import { openDayCountry } from "@/lib/dawarich/connection";
|
|
import { openLocation } from "@/lib/environment/location-cipher";
|
|
import { decryptFactData, decryptFactProvenance } from "@/lib/documents/store";
|
|
import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec";
|
|
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
|
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
|
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { createBackupPreviewCollector } from "@/lib/export/backup-preview";
|
|
import { TWO_ENDED_MODELS } from "@/lib/export/backup-plan";
|
|
import {
|
|
restoreBackup,
|
|
type RestoreBackupOutcome,
|
|
} from "@/lib/export/restore-backup";
|
|
import { STORED_BACKUP_SELECT } from "@/lib/export/stored-backup";
|
|
import { parseBackupPayload } from "@/lib/validations/backup";
|
|
import { summarizeBackup } from "@/lib/validations/backup-summary";
|
|
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
|
import {
|
|
type AuditModel,
|
|
enumValuesFromSchema,
|
|
isDefaultValue,
|
|
ownerWhere,
|
|
readAuditModels,
|
|
syntheticValue,
|
|
} from "./backup-column-audit";
|
|
import {
|
|
COLUMN_PRESENCE_ONLY,
|
|
FILL_OVERRIDES,
|
|
OWNER_ID,
|
|
TOMBSTONE_DELETED_AT,
|
|
USER_FILL_OVERRIDES,
|
|
comparison,
|
|
createOwner,
|
|
delegateOf,
|
|
primaryKeyWhere,
|
|
seedAdminSession,
|
|
seedColumnShapes,
|
|
seedEveryTwoEndedModel,
|
|
} from "./backup-fixture";
|
|
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),
|
|
}));
|
|
|
|
/** What the restore asked of the world outside its transaction. */
|
|
const effects = vi.hoisted(() => ({
|
|
invalidated: [] as string[],
|
|
unitRefresh: [] as string[],
|
|
foldRepair: [] as string[],
|
|
annotations: [] as unknown[],
|
|
}));
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserData: vi.fn((userId: string) => {
|
|
effects.invalidated.push(userId);
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/lib/insights/unit-change-refresh", () => ({
|
|
refreshTextsAfterUnitChange: vi.fn(async (userId: string) => {
|
|
effects.unitRefresh.push(userId);
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/lib/jobs/measurement-fold-repair", async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
enqueueMeasurementFoldRepair: vi.fn(async (userId: string) => {
|
|
effects.foldRepair.push(userId);
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
|
return {
|
|
...actual,
|
|
annotate: vi.fn((...args: Parameters<typeof actual.annotate>) => {
|
|
effects.annotations.push(args[0]);
|
|
return actual.annotate(...args);
|
|
}),
|
|
};
|
|
});
|
|
|
|
const GOLDEN_DIR = resolve(__dirname, "__golden__/backup-parity");
|
|
const WRITE = process.env.BACKUP_PARITY_WRITE === "1";
|
|
const EXPORTED_AT = new Date("2026-08-01T00:00:00.000Z");
|
|
const RUN_STARTED = Date.now();
|
|
const DELEGATE_ID = "parity-delegate";
|
|
const SLOW = 240_000;
|
|
|
|
// ── Normalisation ────────────────────────────────────────────────────────────
|
|
|
|
const CUID = /\bc[a-z0-9]{24}\b/g;
|
|
const ISO = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z\b/g;
|
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
const VERSIONED = /^[A-Za-z0-9_-]{1,32}\.[A-Za-z0-9+/=]{24,}$/;
|
|
const SEALED_KEY = /(Encrypted|Sealed)$/;
|
|
|
|
/** Every codec a sealed byte value is written with, tried in order. */
|
|
const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
|
|
decryptFromBytes,
|
|
decryptNoteFromBytes,
|
|
decryptContextFromBytes,
|
|
(value) => JSON.stringify(openLocation(value)),
|
|
(value) => {
|
|
const code = openDayCountry(value);
|
|
if (code === null) throw new Error("not a sealed day country");
|
|
return `country:${code}`;
|
|
},
|
|
(value) => JSON.stringify(decryptWaveformFromBytes(value)),
|
|
(value) => JSON.stringify(decryptFactData(Buffer.from(value))),
|
|
(value) => JSON.stringify(decryptFactProvenance(Buffer.from(value))),
|
|
(value) => `bytes:${decryptBytes(Buffer.from(value)).toString("base64")}`,
|
|
];
|
|
|
|
function openBytes(value: Uint8Array): string | null {
|
|
for (const open of BYTE_OPENERS) {
|
|
try {
|
|
const text = open(value);
|
|
if (text !== undefined && text !== "null") return text;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function openString(value: string): string | null {
|
|
try {
|
|
return decrypt(value);
|
|
} catch {
|
|
// not the string codec; try it as base64 of a sealed byte value
|
|
}
|
|
if (value.length % 4 === 0 && BASE64.test(value)) {
|
|
return openBytes(Buffer.from(value, "base64"));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* One normalisation pass. Ids are numbered in the order this pass first meets
|
|
* them, so two documents normalised by two passes compare by shape.
|
|
*/
|
|
class Normaliser {
|
|
private readonly ids = new Map<string, string>();
|
|
private readonly windowFrom = RUN_STARTED - 5 * 60_000;
|
|
private readonly days: ReadonlySet<string>;
|
|
|
|
constructor() {
|
|
const today = (t: number) => new Date(t).toISOString().slice(0, 10);
|
|
this.days = new Set([today(RUN_STARTED), today(Date.now())]);
|
|
}
|
|
|
|
private alias(id: string): string {
|
|
let alias = this.ids.get(id);
|
|
if (!alias) {
|
|
alias = `<id:${this.ids.size + 1}>`;
|
|
this.ids.set(id, alias);
|
|
}
|
|
return alias;
|
|
}
|
|
|
|
text(value: string): string {
|
|
const windowTo = Date.now() + 5 * 60_000;
|
|
const out = value
|
|
.replace(CUID, (id) => this.alias(id))
|
|
.replace(ISO, (iso) => {
|
|
if (iso === TOMBSTONE_DELETED_AT) return "<tombstone>";
|
|
const at = Date.parse(iso);
|
|
return at >= this.windowFrom && at <= windowTo ? "<now>" : iso;
|
|
});
|
|
return DAY.test(out) && this.days.has(out) ? "<today>" : out;
|
|
}
|
|
|
|
sealed(value: string): string {
|
|
const opened = openString(value);
|
|
return opened === null
|
|
? `<sealed:${value.length}>`
|
|
: `<sealed:${this.text(opened)}>`;
|
|
}
|
|
|
|
/** A JSON value, key order kept. */
|
|
json(value: unknown, key = ""): unknown {
|
|
if (typeof value === "string") {
|
|
if (SEALED_KEY.test(key) || VERSIONED.test(value)) {
|
|
const opened = openString(value);
|
|
if (opened !== null || SEALED_KEY.test(key)) return this.sealed(value);
|
|
}
|
|
return this.text(value);
|
|
}
|
|
if (Array.isArray(value)) return value.map((item) => this.json(item, key));
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([k, v]) => [k, this.json(v, k)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** A database value: dates, bytes, decimals and big integers made JSON. */
|
|
db(value: unknown, key = ""): unknown {
|
|
if (value === null || value === undefined) return null;
|
|
if (value instanceof Date) return this.text(value.toISOString());
|
|
if (value instanceof Uint8Array) {
|
|
const opened = openBytes(value);
|
|
return opened === null
|
|
? `<bytes:${Buffer.from(value).toString("base64")}>`
|
|
: `<sealed:${this.text(opened)}>`;
|
|
}
|
|
if (typeof value === "bigint") return `bigint:${value}`;
|
|
if (
|
|
typeof value === "object" &&
|
|
!Array.isArray(value) &&
|
|
"toFixed" in value
|
|
) {
|
|
return `decimal:${String(value)}`;
|
|
}
|
|
if (Array.isArray(value)) return value.map((item) => this.db(item, key));
|
|
if (typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value as object).map(([k, v]) => [k, this.db(v, k)]),
|
|
);
|
|
}
|
|
return this.json(value, key);
|
|
}
|
|
}
|
|
|
|
/** Rows in an order that does not depend on their ids or on the heap. */
|
|
function sortRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
|
|
const keyOf = (row: Record<string, unknown>) =>
|
|
JSON.stringify(row, (_k, v) => {
|
|
if (typeof v === "bigint") return `bigint:${v}`;
|
|
if (v instanceof Uint8Array) return `bytes:${v.byteLength}`;
|
|
if (typeof v === "string") return v.replace(CUID, "<id>");
|
|
return v;
|
|
});
|
|
return [...rows].sort((a, b) => keyOf(a).localeCompare(keyOf(b)));
|
|
}
|
|
|
|
// ── Golden copies ────────────────────────────────────────────────────────────
|
|
|
|
/** Paths where two JSON values differ, key order included, first 40. */
|
|
function differences(expected: unknown, actual: unknown, path = "$"): string[] {
|
|
const out: string[] = [];
|
|
const walk = (e: unknown, a: unknown, at: string) => {
|
|
if (out.length >= 40) return;
|
|
if (Array.isArray(e) && Array.isArray(a)) {
|
|
if (e.length !== a.length) {
|
|
out.push(`${at}: length ${e.length} -> ${a.length}`);
|
|
}
|
|
for (let i = 0; i < Math.min(e.length, a.length); i++) {
|
|
walk(e[i], a[i], `${at}[${i}]`);
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
e &&
|
|
a &&
|
|
typeof e === "object" &&
|
|
typeof a === "object" &&
|
|
!Array.isArray(e) &&
|
|
!Array.isArray(a)
|
|
) {
|
|
const ek = Object.keys(e);
|
|
const ak = Object.keys(a);
|
|
for (const k of ek) if (!ak.includes(k)) out.push(`${at}.${k}: removed`);
|
|
for (const k of ak) if (!ek.includes(k)) out.push(`${at}.${k}: added`);
|
|
const shared = ek.filter((k) => ak.includes(k));
|
|
const order = ak.filter((k) => ek.includes(k));
|
|
if (shared.join() !== order.join()) out.push(`${at}: key order changed`);
|
|
for (const k of shared) {
|
|
walk(
|
|
(e as Record<string, unknown>)[k],
|
|
(a as Record<string, unknown>)[k],
|
|
`${at}.${k}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (JSON.stringify(e) !== JSON.stringify(a)) {
|
|
out.push(
|
|
`${at}: ${JSON.stringify(e)?.slice(0, 120)} -> ${JSON.stringify(a)?.slice(0, 120)}`,
|
|
);
|
|
}
|
|
};
|
|
walk(expected, actual, path);
|
|
return out;
|
|
}
|
|
|
|
function matchGolden(name: string, actual: unknown): void {
|
|
const text = `${JSON.stringify(actual, null, 2)}\n`;
|
|
const path = join(GOLDEN_DIR, `${name}.json`);
|
|
if (WRITE) {
|
|
mkdirSync(GOLDEN_DIR, { recursive: true });
|
|
writeFileSync(path, text);
|
|
return;
|
|
}
|
|
expect(
|
|
existsSync(path),
|
|
`${path} is missing; write it with BACKUP_PARITY_WRITE=1`,
|
|
).toBe(true);
|
|
const expected = readFileSync(path, "utf8");
|
|
if (expected === text) return;
|
|
expect(
|
|
differences(JSON.parse(expected), actual),
|
|
`${name} differs from its golden copy`,
|
|
).toEqual([]);
|
|
expect(text, `${name} differs from its golden copy`).toBe(expected);
|
|
}
|
|
|
|
/** How many leaf values a document holds, so a near-empty one cannot pass. */
|
|
function leafCount(value: unknown): number {
|
|
if (Array.isArray(value)) return value.reduce((n, v) => n + leafCount(v), 0);
|
|
if (value && typeof value === "object") {
|
|
return Object.values(value).reduce<number>((n, v) => n + leafCount(v), 0);
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// ── The account ──────────────────────────────────────────────────────────────
|
|
|
|
const SNAPSHOT_EXTRA_MODELS = ["AccountGrant"] as const;
|
|
|
|
function auditModels(prisma: PrismaClient): AuditModel[] {
|
|
return readAuditModels(prisma, TWO_ENDED_MODELS);
|
|
}
|
|
|
|
/**
|
|
* Give every column of the fixture's rows a value that is not its default,
|
|
* the way the column round trip does, and say which ones would not take one.
|
|
*/
|
|
async function fillDefaultedColumns(prisma: PrismaClient): Promise<string[]> {
|
|
const models = auditModels(prisma);
|
|
const byName = new Map(models.map((m) => [m.name, m]));
|
|
const enumValues = enumValuesFromSchema();
|
|
const unfillable: string[] = [];
|
|
for (const model of models) {
|
|
const rows = await delegateOf(prisma, model).findMany({
|
|
where: ownerWhere(byName, prisma, model.name, OWNER_ID),
|
|
});
|
|
if (rows.length === 0) continue;
|
|
for (const column of model.columns) {
|
|
if (
|
|
comparison(column) !== "value" &&
|
|
!(`${model.name}.${column.name}` in COLUMN_PRESENCE_ONLY)
|
|
) {
|
|
continue;
|
|
}
|
|
if (column.name === "deletedAt") continue;
|
|
if (rows.some((row) => !isDefaultValue(column, row[column.name]))) {
|
|
continue;
|
|
}
|
|
try {
|
|
await delegateOf(prisma, model).update({
|
|
where: primaryKeyWhere(model, rows[0]),
|
|
data: {
|
|
[column.name]: syntheticValue(column, enumValues, FILL_OVERRIDES),
|
|
},
|
|
});
|
|
} catch {
|
|
unfillable.push(`${model.name}.${column.name}`);
|
|
}
|
|
}
|
|
}
|
|
return unfillable;
|
|
}
|
|
|
|
/** Every column of one row, through its delegate, with the given overrides. */
|
|
async function fillRow(
|
|
prisma: PrismaClient,
|
|
modelName: string,
|
|
where: Record<string, unknown>,
|
|
overrides: Readonly<Record<string, unknown>>,
|
|
skip: ReadonlySet<string>,
|
|
): Promise<string[]> {
|
|
const [model] = readAuditModels(prisma, [modelName]);
|
|
const enumValues = enumValuesFromSchema();
|
|
const unfillable: string[] = [];
|
|
for (const column of model.columns) {
|
|
if (column.isId || column.isUpdatedAt || skip.has(column.name)) continue;
|
|
try {
|
|
await delegateOf(prisma, model).update({
|
|
where,
|
|
data: { [column.name]: syntheticValue(column, enumValues, overrides) },
|
|
});
|
|
} catch {
|
|
unfillable.push(`${modelName}.${column.name}`);
|
|
}
|
|
}
|
|
return unfillable;
|
|
}
|
|
|
|
const APP_SETTINGS_OVERRIDES: Readonly<Record<string, unknown>> = {
|
|
"AppSettings.defaultLocale": "fr",
|
|
"AppSettings.defaultUserTimezone": "America/Chicago",
|
|
"AppSettings.moduleAvailabilityJson": { mood: "off" },
|
|
"AppSettings.adminAiInsightsFeedbackSummary": { helpful: 3 },
|
|
"AppSettings.aiReasoningMaxEffort": "low",
|
|
};
|
|
|
|
/** The whole account, and the host around it. */
|
|
async function seedMaximalAccount(prisma: PrismaClient) {
|
|
const admin = await seedAdminSession(prisma);
|
|
await createOwner(prisma);
|
|
await prisma.illnessSymptom.deleteMany({
|
|
where: { key: "round_trip_cough" },
|
|
});
|
|
await seedEveryTwoEndedModel(prisma);
|
|
await seedColumnShapes(prisma);
|
|
const unfillable = await fillDefaultedColumns(prisma);
|
|
unfillable.push(
|
|
...(await fillRow(
|
|
prisma,
|
|
"User",
|
|
{ id: OWNER_ID },
|
|
USER_FILL_OVERRIDES,
|
|
new Set(["username", "role", "email"]),
|
|
)),
|
|
);
|
|
await prisma.appSettings.upsert({
|
|
where: { id: "singleton" },
|
|
create: { id: "singleton" },
|
|
update: {},
|
|
});
|
|
unfillable.push(
|
|
...(await fillRow(
|
|
prisma,
|
|
"AppSettings",
|
|
{ id: "singleton" },
|
|
APP_SETTINGS_OVERRIDES,
|
|
new Set(),
|
|
)),
|
|
);
|
|
// A grant the owner gave, which the file never carries and a restore into
|
|
// the same account never touches.
|
|
await prisma.user.create({
|
|
data: {
|
|
id: DELEGATE_ID,
|
|
username: "parity-delegate",
|
|
email: "parity-delegate@example.test",
|
|
},
|
|
});
|
|
await prisma.accountGrant.create({
|
|
data: {
|
|
grantorId: OWNER_ID,
|
|
granteeId: DELEGATE_ID,
|
|
access: "WRITE",
|
|
scopeJson: { sections: ["measurements"] },
|
|
acceptedAt: new Date("2026-07-02T09:00:00.000Z"),
|
|
},
|
|
});
|
|
return { admin, unfillable: unfillable.sort() };
|
|
}
|
|
|
|
/** Every row the account holds, model by model, normalised. */
|
|
async function snapshotAccount(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
): Promise<Record<string, unknown>> {
|
|
const n = new Normaliser();
|
|
const models = auditModels(prisma);
|
|
const byName = new Map(models.map((m) => [m.name, m]));
|
|
const out: Record<string, unknown> = {};
|
|
for (const model of models) {
|
|
const rows = await delegateOf(prisma, model).findMany({
|
|
where: ownerWhere(byName, prisma, model.name, userId),
|
|
});
|
|
out[model.name] = sortRows(rows).map((row) => n.db(row));
|
|
}
|
|
for (const name of SNAPSHOT_EXTRA_MODELS) {
|
|
const rows = await prisma.accountGrant.findMany({
|
|
where: { OR: [{ grantorId: userId }, { granteeId: userId }] },
|
|
});
|
|
out[name] = sortRows(rows as Record<string, unknown>[]).map((row) =>
|
|
n.db(row),
|
|
);
|
|
}
|
|
out.User = n.db(await prisma.user.findUnique({ where: { id: userId } }));
|
|
out.AppSettings = n.db(
|
|
await prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
|
);
|
|
return out;
|
|
}
|
|
|
|
async function exportFile(
|
|
prisma: PrismaClient,
|
|
purpose: "disaster-recovery" | "portable-export",
|
|
) {
|
|
const built = await buildFullBackupPayload(prisma, OWNER_ID, {
|
|
purpose,
|
|
exportedAt: EXPORTED_AT,
|
|
});
|
|
let json = "";
|
|
const preview = await createBackupPreviewCollector();
|
|
const counts = await streamFullBackupJson(
|
|
prisma,
|
|
OWNER_ID,
|
|
(chunk) => {
|
|
json += chunk;
|
|
},
|
|
{ purpose, exportedAt: EXPORTED_AT, observe: preview.observe },
|
|
);
|
|
// The two writers must write the same bytes; everything below is then
|
|
// about both.
|
|
expect(json).toBe(JSON.stringify(built.payload));
|
|
return {
|
|
json,
|
|
builtCounts: built.counts,
|
|
streamedCounts: counts,
|
|
preview: preview.finish(counts),
|
|
};
|
|
}
|
|
|
|
function normalisedExport(file: Awaited<ReturnType<typeof exportFile>>) {
|
|
const n = new Normaliser();
|
|
const payload = JSON.parse(file.json);
|
|
const parsed = parseBackupPayload(payload);
|
|
return {
|
|
payload: n.json(payload),
|
|
builtCounts: file.builtCounts,
|
|
streamedCounts: file.streamedCounts,
|
|
parsed: n.json(JSON.parse(JSON.stringify(parsed))),
|
|
summary: summarizeBackup(parsed),
|
|
preview: n.json(JSON.parse(JSON.stringify(file.preview))),
|
|
};
|
|
}
|
|
|
|
async function storeCopy(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
type: string,
|
|
json: string,
|
|
) {
|
|
const row = await prisma.dataBackup.create({
|
|
data: {
|
|
userId,
|
|
type,
|
|
data: await legacyStreamedBlobFrom(async (write) => {
|
|
await write(json);
|
|
}),
|
|
},
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
return row;
|
|
}
|
|
|
|
interface RestoreRun {
|
|
outcome: unknown;
|
|
progress: unknown[];
|
|
audit: unknown[];
|
|
effects: unknown;
|
|
}
|
|
|
|
/** Restore one stored copy and record everything it reported. */
|
|
async function runRestore(
|
|
prisma: PrismaClient,
|
|
backup: Awaited<ReturnType<typeof storeCopy>>,
|
|
actorUserId: string,
|
|
restoreInstanceSettings: boolean,
|
|
): Promise<{ run: RestoreRun; raw: RestoreBackupOutcome }> {
|
|
effects.invalidated.length = 0;
|
|
effects.unitRefresh.length = 0;
|
|
effects.foldRepair.length = 0;
|
|
effects.annotations.length = 0;
|
|
const auditFrom = new Date();
|
|
const progress: Array<Record<string, unknown>> = [];
|
|
const raw = await restoreBackup({
|
|
backup,
|
|
actorUserId,
|
|
ipAddress: null,
|
|
restoreInstanceSettings,
|
|
progress: (phase, p) => {
|
|
const entry = {
|
|
phase,
|
|
sectionsDone: p.sectionsDone,
|
|
sectionsTotal: p.sectionsTotal,
|
|
measurementsTotal: p.measurementsTotal,
|
|
};
|
|
const last = progress[progress.length - 1];
|
|
if (!last || JSON.stringify(last) !== JSON.stringify(entry)) {
|
|
progress.push(entry);
|
|
}
|
|
},
|
|
});
|
|
const audit = await prisma.auditLog.findMany({
|
|
where: { createdAt: { gte: auditFrom }, action: { startsWith: "admin." } },
|
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
|
select: { action: true, userId: true, details: true },
|
|
});
|
|
const n = new Normaliser();
|
|
return {
|
|
raw,
|
|
run: {
|
|
outcome: n.json(JSON.parse(JSON.stringify(raw))),
|
|
progress,
|
|
audit: audit.map((row) => ({
|
|
action: row.action,
|
|
userId: row.userId === actorUserId ? "<actor>" : row.userId,
|
|
details: n.json(row.details ? JSON.parse(row.details) : null),
|
|
})),
|
|
effects: n.json({
|
|
invalidated: [...effects.invalidated],
|
|
unitRefresh: [...effects.unitRefresh],
|
|
foldRepair: [...effects.foldRepair],
|
|
annotations: JSON.parse(JSON.stringify(effects.annotations)),
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function recreateOwner(prisma: PrismaClient) {
|
|
await prisma.user.delete({ where: { id: OWNER_ID } });
|
|
await createOwner(prisma);
|
|
await prisma.accountGrant.create({
|
|
data: {
|
|
grantorId: OWNER_ID,
|
|
granteeId: DELEGATE_ID,
|
|
access: "READ",
|
|
acceptedAt: new Date("2026-07-03T09:00:00.000Z"),
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── The cases ────────────────────────────────────────────────────────────────
|
|
|
|
describe("backup parity", () => {
|
|
const prisma = getPrismaClient();
|
|
let adminId = "";
|
|
let drJson = "";
|
|
let portableJson = "";
|
|
let drRestoredSnapshot: Record<string, unknown> = {};
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
}, SLOW);
|
|
|
|
afterAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
}, SLOW);
|
|
|
|
it(
|
|
"exports the maximal account as it did",
|
|
async () => {
|
|
const { admin, unfillable } = await seedMaximalAccount(prisma);
|
|
adminId = admin.id;
|
|
const before = await snapshotAccount(prisma, OWNER_ID);
|
|
const dr = await exportFile(prisma, "disaster-recovery");
|
|
const portable = await exportFile(prisma, "portable-export");
|
|
drJson = dr.json;
|
|
portableJson = portable.json;
|
|
|
|
const drNormalised = normalisedExport(dr);
|
|
// A near-empty file would agree with a near-empty golden copy.
|
|
expect(leafCount(drNormalised.payload)).toBeGreaterThan(1200);
|
|
expect(
|
|
Object.values(before).filter(
|
|
(rows) => Array.isArray(rows) && rows.length > 0,
|
|
).length,
|
|
).toBeGreaterThanOrEqual(TWO_ENDED_MODELS.length);
|
|
|
|
matchGolden("seeded-account", { unfillable, snapshot: before });
|
|
matchGolden("export-disaster-recovery", drNormalised);
|
|
matchGolden("export-portable", normalisedExport(portable));
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
it(
|
|
"restores the disaster-recovery file into a fresh account as it did",
|
|
async () => {
|
|
expect(drJson).not.toBe("");
|
|
await recreateOwner(prisma);
|
|
const backup = await storeCopy(prisma, OWNER_ID, "PARITY_DR", drJson);
|
|
const { run, raw } = await runRestore(prisma, backup, adminId, true);
|
|
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
|
|
drRestoredSnapshot = await snapshotAccount(prisma, OWNER_ID);
|
|
const reexport = await exportFile(prisma, "disaster-recovery");
|
|
matchGolden("restore-disaster-recovery", {
|
|
...run,
|
|
snapshot: drRestoredSnapshot,
|
|
reexport: new Normaliser().json(JSON.parse(reexport.json)),
|
|
});
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
it(
|
|
"restores the disaster-recovery file over the live account as it did",
|
|
async () => {
|
|
expect(drJson).not.toBe("");
|
|
const backup = await storeCopy(
|
|
prisma,
|
|
OWNER_ID,
|
|
"PARITY_DR_LIVE",
|
|
drJson,
|
|
);
|
|
const { run, raw } = await runRestore(prisma, backup, adminId, false);
|
|
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
|
|
const snapshot = await snapshotAccount(prisma, OWNER_ID);
|
|
matchGolden("restore-disaster-recovery-over-live", {
|
|
...run,
|
|
snapshotChanges: differences(drRestoredSnapshot, snapshot),
|
|
});
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
it(
|
|
"restores the portable file into a fresh account as it did",
|
|
async () => {
|
|
expect(portableJson).not.toBe("");
|
|
await recreateOwner(prisma);
|
|
// A portable file carries document metadata only, which the restore
|
|
// refuses to turn into documents; the file of an account without any
|
|
// is the portable file a restore takes.
|
|
const backup = await storeCopy(
|
|
prisma,
|
|
OWNER_ID,
|
|
"PARITY_PORTABLE",
|
|
JSON.stringify({ ...JSON.parse(portableJson), documents: [] }),
|
|
);
|
|
const { run, raw } = await runRestore(prisma, backup, adminId, false);
|
|
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
|
|
const snapshot = await snapshotAccount(prisma, OWNER_ID);
|
|
const reexport = await exportFile(prisma, "portable-export");
|
|
matchGolden("restore-portable", {
|
|
...run,
|
|
snapshot,
|
|
reexport: new Normaliser().json(JSON.parse(reexport.json)),
|
|
});
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
it(
|
|
"restores a version-1 file in the old shapes over a live account as it did",
|
|
async () => {
|
|
await recreateOwner(prisma);
|
|
await seedLiveRowsForLegacyRestore(prisma);
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "PARITY_V1",
|
|
data: encrypt(JSON.stringify(legacyVersionOneFile())),
|
|
},
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
const { run, raw } = await runRestore(prisma, backup, adminId, true);
|
|
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
|
|
const snapshot = await snapshotAccount(prisma, OWNER_ID);
|
|
const reexport = await exportFile(prisma, "disaster-recovery");
|
|
matchGolden("restore-version-1", {
|
|
...run,
|
|
snapshot,
|
|
reexport: new Normaliser().json(JSON.parse(reexport.json)),
|
|
});
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
it(
|
|
"names what it skips in a file it can only partly place, as it did",
|
|
async () => {
|
|
await recreateOwner(prisma);
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "PARITY_SKIPS",
|
|
data: encrypt(JSON.stringify(partlyPlaceableFile())),
|
|
},
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
const { run, raw } = await runRestore(prisma, backup, adminId, false);
|
|
expect(raw.ok, JSON.stringify([raw, run.audit])).toBe(true);
|
|
const snapshot = await snapshotAccount(prisma, OWNER_ID);
|
|
matchGolden("restore-skips", { ...run, snapshot });
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
it(
|
|
"refuses each malformed or unplaceable file as it did",
|
|
async () => {
|
|
await recreateOwner(prisma);
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "WEIGHT",
|
|
value: 70,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-07-05T07:00:00.000Z"),
|
|
},
|
|
});
|
|
const before = await snapshotAccount(prisma, OWNER_ID);
|
|
const results: Record<string, unknown> = {};
|
|
for (const [name, body] of Object.entries(refusedFiles())) {
|
|
const backup = await prisma.dataBackup.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: `PARITY_REFUSE_${name}`,
|
|
data:
|
|
typeof body === "string" ? body : encrypt(JSON.stringify(body)),
|
|
},
|
|
select: STORED_BACKUP_SELECT,
|
|
});
|
|
const { run, raw } = await runRestore(prisma, backup, adminId, false);
|
|
expect(raw.ok, `${name} was restored`).toBe(false);
|
|
results[name] = run;
|
|
}
|
|
// Every refusal leaves the account exactly as it was.
|
|
expect(
|
|
differences(before, await snapshotAccount(prisma, OWNER_ID)),
|
|
).toEqual([]);
|
|
matchGolden("refusals", results);
|
|
},
|
|
SLOW,
|
|
);
|
|
|
|
it("reports the schema's issues for a malformed file as it did", () => {
|
|
const issues: Record<string, unknown> = {};
|
|
for (const [name, body] of Object.entries(schemaInvalidFiles())) {
|
|
try {
|
|
parseBackupPayload(body);
|
|
issues[name] = "parsed";
|
|
} catch (error) {
|
|
issues[name] = JSON.parse(
|
|
JSON.stringify((error as { issues?: unknown }).issues ?? null),
|
|
);
|
|
}
|
|
}
|
|
expect(Object.values(issues)).not.toContain("parsed");
|
|
matchGolden("schema-issues", new Normaliser().json(issues));
|
|
});
|
|
});
|
|
|
|
// ── Hand-built files ─────────────────────────────────────────────────────────
|
|
|
|
/** Rows the version-1 file meets: a reading it reconciles by natural key. */
|
|
async function seedLiveRowsForLegacyRestore(prisma: PrismaClient) {
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
type: "WEIGHT",
|
|
value: 99,
|
|
unit: "kg",
|
|
source: "MANUAL",
|
|
measuredAt: new Date("2026-07-01T07:00:00.000Z"),
|
|
},
|
|
});
|
|
await prisma.moodEntry.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
date: "2026-07-02",
|
|
mood: "BAD",
|
|
score: 2,
|
|
moodLoggedAt: new Date("2026-07-02T20:00:00.000Z"),
|
|
},
|
|
});
|
|
}
|
|
|
|
function legacyVersionOneFile(): Record<string, unknown> {
|
|
return {
|
|
schemaVersion: "1",
|
|
exportedAt: "2026-07-20T08:00:00.000Z",
|
|
userId: OWNER_ID,
|
|
appSettings: {
|
|
id: "singleton",
|
|
registrationEnabled: false,
|
|
mfaRequired: true,
|
|
defaultLocale: "de",
|
|
telegramGlobal: true,
|
|
ntfyGlobal: false,
|
|
webPushGlobal: true,
|
|
webPushVapidPublicKey: null,
|
|
webPushVapidPrivateKeyEncrypted: null,
|
|
webPushVapidSubject: null,
|
|
apiGlobal: true,
|
|
umamiEnabled: false,
|
|
umamiScriptUrl: null,
|
|
umamiWebsiteId: null,
|
|
glitchtipEnabled: false,
|
|
glitchtipDsn: null,
|
|
glitchtipEnvironment: null,
|
|
reminderLateMinutes: 30,
|
|
reminderMissedMinutes: 120,
|
|
adminAiKeyEncrypted: null,
|
|
adminAiModel: "gpt-legacy",
|
|
adminAiBaseUrl: "https://api.example.com/v1",
|
|
adminCodexAccessTokenEncrypted: null,
|
|
adminCodexRefreshTokenEncrypted: null,
|
|
adminCodexAccountIdEncrypted: null,
|
|
adminCodexTokenExpiresAt: null,
|
|
adminCodexConnectedAt: null,
|
|
adminCodexConnectionStatus: "disconnected",
|
|
adminAiInsightsFeedbackSummary: null,
|
|
defaultUserTimezone: "Europe/Berlin",
|
|
assistantEnabled: true,
|
|
assistantCoachEnabled: true,
|
|
assistantBriefingEnabled: true,
|
|
assistantInsightStatusEnabled: true,
|
|
moduleAvailabilityJson: { coach: "off", mood: "on" },
|
|
documentMaxFileBytes: 10_000_000,
|
|
documentQuotaBytes: "500000000",
|
|
},
|
|
measurements: [
|
|
// Reconciled onto the live reading of the same type, source and instant.
|
|
{
|
|
type: "WEIGHT",
|
|
value: 74.5,
|
|
unit: "kg",
|
|
measuredAt: "2026-07-01T07:00:00.000Z",
|
|
source: "MANUAL",
|
|
notes: "after the long ride",
|
|
},
|
|
{
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: 128,
|
|
unit: "mmHg",
|
|
measuredAt: "2026-07-01T07:05:00.000Z",
|
|
},
|
|
// The retired filing: Fitbit RMSSD written as the SDNN series.
|
|
{
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 41,
|
|
unit: "ms",
|
|
measuredAt: "2026-07-02T04:00:00.000Z",
|
|
source: "FITBIT",
|
|
externalId: "fitbit-hrv-2026-07-02",
|
|
},
|
|
{
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 39,
|
|
unit: "ms",
|
|
measuredAt: "2026-07-03T04:00:00.000Z",
|
|
source: "FITBIT",
|
|
externalId: "fitbit-hrv-2026-07-03",
|
|
},
|
|
{
|
|
type: "HRV_RMSSD",
|
|
value: 40,
|
|
unit: "ms",
|
|
measuredAt: "2026-07-03T04:00:00.000Z",
|
|
source: "FITBIT",
|
|
externalId: "fitbit-hrv-2026-07-03",
|
|
},
|
|
],
|
|
medications: [
|
|
{
|
|
name: "Ramipril",
|
|
dose: "5 mg",
|
|
active: true,
|
|
schedules: [{ windowStart: "07:00", windowEnd: "09:00", label: null }],
|
|
},
|
|
{
|
|
name: "Vitamin D",
|
|
dose: "1000 IU",
|
|
endsOn: "2026-08-01T00:00:00.000Z",
|
|
schedules: [],
|
|
},
|
|
],
|
|
intakeEvents: [
|
|
{
|
|
medication: "Ramipril",
|
|
scheduledFor: "2026-07-01T07:00:00.000Z",
|
|
takenAt: "2026-07-01T07:10:00.000Z",
|
|
skipped: false,
|
|
},
|
|
{
|
|
medication: "Vitamin D",
|
|
scheduledFor: "2026-07-01T08:00:00.000Z",
|
|
takenAt: null,
|
|
skipped: true,
|
|
},
|
|
],
|
|
moodEntries: [
|
|
// Upserted onto the live entry of the same day and instant.
|
|
{
|
|
date: "2026-07-02",
|
|
mood: "GOOD",
|
|
score: 4,
|
|
tags: null,
|
|
note: "slept well",
|
|
loggedAt: "2026-07-02T20:00:00.000Z",
|
|
factors: [],
|
|
},
|
|
{
|
|
date: "2026-07-03",
|
|
mood: "OKAY",
|
|
score: 3,
|
|
tags: null,
|
|
loggedAt: "2026-07-03T20:00:00.000Z",
|
|
source: "MOODLOG",
|
|
externalId: "moodlog-2026-07-03",
|
|
factors: [],
|
|
},
|
|
],
|
|
biomarkers: [
|
|
{
|
|
name: "Ferritin",
|
|
unit: "ng/mL",
|
|
lowerBound: 30,
|
|
upperBound: 400,
|
|
panel: "Iron",
|
|
hidden: false,
|
|
context: "fasted",
|
|
},
|
|
],
|
|
labResults: [
|
|
{
|
|
panel: "Iron",
|
|
analyte: "Ferritin",
|
|
value: 91,
|
|
valueText: null,
|
|
unit: "ng/mL",
|
|
referenceLow: 30,
|
|
referenceHigh: 400,
|
|
sourceReferenceLow: null,
|
|
sourceReferenceHigh: null,
|
|
sourceReferenceText: null,
|
|
takenAt: "2026-07-05T08:00:00.000Z",
|
|
source: "MANUAL",
|
|
biomarkerName: "Ferritin",
|
|
note: "morning draw",
|
|
},
|
|
],
|
|
illnessEpisodes: [
|
|
{
|
|
id: "legacy-episode-parent",
|
|
label: "Hypertension",
|
|
type: "CHRONIC",
|
|
lifecycle: "CHRONIC_ONGOING",
|
|
onsetAt: "2025-01-01T00:00:00.000Z",
|
|
resolvedAt: null,
|
|
parentConditionId: null,
|
|
note: "since last year",
|
|
dayLogs: [],
|
|
},
|
|
{
|
|
id: "legacy-episode-child",
|
|
label: "Headache",
|
|
type: "OTHER",
|
|
lifecycle: "ACUTE",
|
|
onsetAt: "2026-07-01T00:00:00.000Z",
|
|
resolvedAt: "2026-07-02T00:00:00.000Z",
|
|
parentConditionId: "legacy-episode-parent",
|
|
note: null,
|
|
bodySite: "forehead",
|
|
dayLogs: [
|
|
{
|
|
date: "2026-07-01",
|
|
functionalImpact: 2,
|
|
feverC: null,
|
|
note: "afternoon",
|
|
symptoms: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
allergies: [
|
|
{
|
|
id: "legacy-allergy",
|
|
substance: "Penicillin",
|
|
category: "MEDICATION",
|
|
type: "ALLERGY",
|
|
severity: "SEVERE",
|
|
status: "ACTIVE",
|
|
onsetAt: null,
|
|
reaction: "rash",
|
|
note: "childhood",
|
|
},
|
|
],
|
|
familyHistory: [
|
|
{
|
|
id: "legacy-family",
|
|
relationship: "FATHER",
|
|
condition: "Diabetes",
|
|
ageAtOnset: 55,
|
|
note: "type 2",
|
|
},
|
|
],
|
|
workouts: [
|
|
{
|
|
sportType: "RUNNING",
|
|
startedAt: "2026-07-04T06:00:00.000Z",
|
|
endedAt: "2026-07-04T06:40:00.000Z",
|
|
durationSec: 2400,
|
|
totalEnergyKcal: 410,
|
|
totalDistanceM: 6500,
|
|
avgHeartRate: 148,
|
|
maxHeartRate: 171,
|
|
minHeartRate: 98,
|
|
stepCount: 6200,
|
|
elevationM: 40,
|
|
pauseDurationSec: null,
|
|
source: "MANUAL",
|
|
externalId: null,
|
|
},
|
|
],
|
|
nutrientDays: [
|
|
{
|
|
day: "2026-07-04",
|
|
nutrient: "WATER",
|
|
amount: 2100,
|
|
unit: "mL",
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** A current-format file whose references this instance can only partly place. */
|
|
function partlyPlaceableFile(): Record<string, unknown> {
|
|
return {
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-20T08:00:00.000Z",
|
|
userId: OWNER_ID,
|
|
accountSettings: {
|
|
locale: "xx-unknown",
|
|
timezone: "Not/AZone",
|
|
unitPreference: "metric",
|
|
thresholdsJson: {
|
|
WEIGHT: { min: 60, max: 80 },
|
|
NOT_A_METRIC: { min: 1, max: 2 },
|
|
},
|
|
globalExcludedInjectionSites: ["THIGH_LEFT", "NOWHERE"],
|
|
aiProvider: "OPENAI",
|
|
aiBaseUrl: "https://llm.example.com/v1",
|
|
},
|
|
measurements: [
|
|
{
|
|
id: "skips-measurement",
|
|
type: "WEIGHT",
|
|
value: 72,
|
|
unit: "kg",
|
|
measuredAt: "2026-07-01T07:00:00.000Z",
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
medications: [
|
|
{
|
|
id: "skips-medication",
|
|
name: "Metformin",
|
|
dose: "500 mg",
|
|
schedules: [],
|
|
scheduleRevisions: [
|
|
{
|
|
validFrom: "2026-06-01T00:00:00.000Z",
|
|
validUntil: "2026-06-15T00:00:00.000Z",
|
|
payload: { schedules: [] },
|
|
supersededByIndex: 7,
|
|
},
|
|
{
|
|
validFrom: "2026-06-15T00:00:00.000Z",
|
|
validUntil: "2026-07-01T00:00:00.000Z",
|
|
payload: { schedules: [] },
|
|
supersededByIndex: 1,
|
|
},
|
|
],
|
|
efficacyTargets: [
|
|
{ biomarkerName: "HbA1c that is not in the file", primary: true },
|
|
{ measurementType: "BLOOD_GLUCOSE", primary: false },
|
|
],
|
|
},
|
|
],
|
|
hiddenMoodTags: [{ key: "a_tag_nobody_has" }],
|
|
customMoodTagCategories: [
|
|
{
|
|
id: "skips-mood-category",
|
|
key: "custom:skips-mood-category",
|
|
labelKey: "custom",
|
|
sortOrder: 0,
|
|
isActive: true,
|
|
label: "Evenings",
|
|
},
|
|
],
|
|
customMoodTags: [
|
|
{
|
|
key: "custom:skips-mood-tag",
|
|
labelKey: "custom",
|
|
categoryId: "skips-mood-category",
|
|
kind: "RATED",
|
|
isActive: true,
|
|
sortOrder: 0,
|
|
label: "Quiet",
|
|
scaleMin: 1,
|
|
scaleMax: 5,
|
|
inverse: false,
|
|
},
|
|
],
|
|
moodEntries: [
|
|
{
|
|
id: "skips-mood",
|
|
date: "2026-07-02",
|
|
mood: "GOOD",
|
|
score: 4,
|
|
tags: null,
|
|
loggedAt: "2026-07-02T20:00:00.000Z",
|
|
factors: [{ key: "a_factor_nobody_has", rating: 3 }],
|
|
structuredTags: ["a_binary_tag_nobody_has"],
|
|
},
|
|
],
|
|
illnessEpisodes: [
|
|
{
|
|
id: "skips-episode",
|
|
label: "Cold",
|
|
type: "INFECTION",
|
|
lifecycle: "ACUTE",
|
|
onsetAt: "2026-07-01T00:00:00.000Z",
|
|
resolvedAt: null,
|
|
parentConditionId: null,
|
|
note: null,
|
|
dayLogs: [
|
|
{
|
|
date: "2026-07-01",
|
|
functionalImpact: 1,
|
|
feverC: 38.1,
|
|
note: null,
|
|
symptoms: [{ key: "a_symptom_nobody_has", severity: 2 }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** Files every one of which the restore refuses before or by rolling back. */
|
|
function refusedFiles(): Record<string, unknown> {
|
|
const base = (extra: Record<string, unknown>) => ({
|
|
schemaVersion: "2",
|
|
exportedAt: "2026-07-20T08:00:00.000Z",
|
|
userId: OWNER_ID,
|
|
...extra,
|
|
});
|
|
return {
|
|
undecryptable: "v1.bm90IGEgYmFja3VwIGF0IGFsbA==",
|
|
schemaInvalid: base({
|
|
measurements: [
|
|
{
|
|
id: "bad-measurement",
|
|
type: "NOT_A_TYPE",
|
|
value: "high",
|
|
unit: "",
|
|
measuredAt: "yesterday",
|
|
},
|
|
],
|
|
}),
|
|
stableIdMissing: base({
|
|
measurements: [
|
|
{
|
|
type: "WEIGHT",
|
|
value: 70,
|
|
unit: "kg",
|
|
measuredAt: "2026-07-01T07:00:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
qualitativeMarkerWithNumbers: base({
|
|
biomarkers: [
|
|
{ name: "Urine colour", unit: "", lowerBound: null, upperBound: null },
|
|
],
|
|
labResults: [
|
|
{
|
|
analyte: "Urine colour",
|
|
value: 3,
|
|
unit: "",
|
|
takenAt: "2026-07-01T07:00:00.000Z",
|
|
source: "MANUAL",
|
|
biomarkerName: "Urine colour",
|
|
},
|
|
],
|
|
}),
|
|
incompatibleVersion: { ...base({}), schemaVersion: "99" },
|
|
ownerMismatch: { ...base({}), userId: "someone-else" },
|
|
sectionMissing: base({
|
|
manifest: {
|
|
documents: { included: "encrypted-content", note: "carried" },
|
|
workouts: { included: "summary-only", note: "summaries" },
|
|
},
|
|
workouts: [],
|
|
}),
|
|
metadataOnlyDocument: base({
|
|
documents: [
|
|
{
|
|
id: "metadata-only-document",
|
|
kind: "OTHER",
|
|
title: null,
|
|
filename: "scan.pdf",
|
|
mimeType: "application/pdf",
|
|
byteSize: 12,
|
|
status: "STORED",
|
|
reportDate: null,
|
|
documentDate: null,
|
|
summary: null,
|
|
createdAt: "2026-07-03T08:00:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
keyMissing: base({
|
|
measurements: [
|
|
{
|
|
id: "sealed-under-a-key-this-host-lacks",
|
|
type: "WEIGHT",
|
|
value: 70,
|
|
unit: "kg",
|
|
measuredAt: "2026-07-01T07:00:00.000Z",
|
|
notesEncrypted: Buffer.from(
|
|
`v9.${Buffer.from("x".repeat(48)).toString("base64")}`,
|
|
).toString("base64"),
|
|
},
|
|
],
|
|
}),
|
|
intakeForUnknownMedication: base({
|
|
intakeEvents: [
|
|
{
|
|
id: "orphan-intake",
|
|
medication: "a medication this file never carried",
|
|
scheduledFor: "2026-07-04T08:00:00.000Z",
|
|
takenAt: "2026-07-04T08:05:00.000Z",
|
|
},
|
|
],
|
|
}),
|
|
labForUnknownMarker: base({
|
|
labResults: [
|
|
{
|
|
id: "orphan-lab",
|
|
analyte: "Ferritin",
|
|
value: 91,
|
|
unit: "ng/mL",
|
|
takenAt: "2026-07-01T07:00:00.000Z",
|
|
source: "MANUAL",
|
|
biomarkerId: "a-marker-the-file-never-carried",
|
|
biomarkerName: "Ferritin",
|
|
},
|
|
],
|
|
}),
|
|
illnessWithUnknownParent: base({
|
|
illnessEpisodes: [
|
|
{
|
|
id: "orphan-episode",
|
|
label: "Cough",
|
|
type: "INFECTION",
|
|
lifecycle: "ACUTE",
|
|
onsetAt: "2026-07-01T00:00:00.000Z",
|
|
resolvedAt: null,
|
|
parentConditionId: "an-episode-the-file-never-carried",
|
|
note: null,
|
|
dayLogs: [],
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Files the schema refuses, for the issue list an upload is answered with. */
|
|
function schemaInvalidFiles(): Record<string, unknown> {
|
|
return {
|
|
everySectionWrong: {
|
|
schemaVersion: "2",
|
|
exportedAt: "not a date",
|
|
userId: "",
|
|
measurements: [{ type: "WEIGHT" }],
|
|
medications: [{ name: 3, schedules: "none" }],
|
|
moodEntries: [{ date: "2026-07-01", mood: "GOOD", score: "four" }],
|
|
labResults: [{ analyte: null }],
|
|
documents: [{ id: "d" }],
|
|
accountSettings: { heightCm: "tall", hasDiabetes: "yes" },
|
|
coachConversations: [{ id: "c", messages: [{ role: 1 }] }],
|
|
environmentContexts: [{ day: "July" }],
|
|
ecgRecordings: [{ id: "e" }],
|
|
vaccinations: [{ id: "v" }],
|
|
},
|
|
};
|
|
}
|