mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
merge: s5-export into release/v1.34.0
This commit is contained in:
commit
fbdadae0ce
20 files changed
+1573
-38
No files matched your search
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Export → wire schema → restore, driven end to end.
|
||||
*
|
||||
* Every other test around the backup proves ONE end. `full-backup-payload.test.ts`
|
||||
* proves the builder emits a key; the restore tests prove the route refuses a
|
||||
* bad file. Between them sits the gap that lost three domains in a row: a key
|
||||
* the builder emits, the schema drops or nobody reads, and the restore reports
|
||||
* success over.
|
||||
*
|
||||
* So this test does not assert on the payload. It builds a real payload from a
|
||||
* populated account, serialises it, parses it back through the REAL
|
||||
* `parseBackupPayload`, hands it to the REAL route, and asserts on the rows the
|
||||
* restore tried to write. A field that survives that survives a restore.
|
||||
*
|
||||
* The transaction stand-in is deliberately not inert: `cycleSymptom.findMany`
|
||||
* answers from what the restore actually upserted. If the account's own symptom
|
||||
* definitions do not ride the file, the link resolution finds nothing and the
|
||||
* restore throws — which is what a real instance does, and is exactly how the
|
||||
* inert v1.33.1 fix behaved in production.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
decrypt: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
HttpError: class extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
dataBackup: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: mocks.transaction,
|
||||
},
|
||||
}));
|
||||
|
||||
// Only `decrypt` is stubbed — it stands in for reading the stored backup blob.
|
||||
// The rest of the module stays real, so the ciphertext this test builds and the
|
||||
// ciphertext the restore writes are produced by the same code an instance runs.
|
||||
vi.mock("@/lib/crypto", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/crypto")>()),
|
||||
decrypt: mocks.decrypt,
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency: (fn: unknown) => fn,
|
||||
defaultUserIdResolver: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserData: vi.fn() }));
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeUserMoodRollups: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeUserMedicationCompliance: vi.fn(),
|
||||
MEDICATION_COMPLIANCE_BACKFILL_DAYS: 30,
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
||||
recomputeUserRollups: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
const OWNER = "user-A";
|
||||
|
||||
/* ── the account being backed up ────────────────────────────────────── */
|
||||
|
||||
function sourceClient() {
|
||||
const empty = { findMany: vi.fn().mockResolvedValue([]) };
|
||||
return {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
measurement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
medication: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
medicationIntakeEvent: empty,
|
||||
moodEntry: empty,
|
||||
moodTag: empty,
|
||||
nutrientIntakeDay: empty,
|
||||
// Cycle: one day-log whose symptom is one the ACCOUNT defined, which is
|
||||
// the case the payload assembly used to drop.
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "day-1",
|
||||
date: "2026-07-19",
|
||||
cycleId: null,
|
||||
flow: null,
|
||||
intermenstrualBleeding: false,
|
||||
basalBodyTempC: null,
|
||||
temperatureExcluded: false,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
sexualActivity: false,
|
||||
protectedSex: null,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
sensitiveEncrypted: null,
|
||||
notesEncrypted: null,
|
||||
source: "MANUAL",
|
||||
externalId: null,
|
||||
tz: null,
|
||||
syncVersion: 0,
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
symptomLinks: [{ symptom: { key: "custom:jaw_tension" } }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
cycleSymptom: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "sym-1",
|
||||
key: "custom:jaw_tension",
|
||||
labelKey: "custom",
|
||||
categoryId: "cat-1",
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
labelEncrypted: "envelope",
|
||||
},
|
||||
]),
|
||||
},
|
||||
// Records section: empty, exercised by its own suite.
|
||||
labResult: empty,
|
||||
biomarker: empty,
|
||||
illnessEpisode: empty,
|
||||
allergy: empty,
|
||||
familyHistoryEntry: empty,
|
||||
workout: empty,
|
||||
inboundDocument: empty,
|
||||
// The two domains this stream adds.
|
||||
userHealthProfile: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "profile-1",
|
||||
userId: OWNER,
|
||||
aboutMeEncrypted: encryptToBytes("desk job, two kids"),
|
||||
conditionsEncrypted: null,
|
||||
allergiesEncrypted: encryptToBytes("penicillin"),
|
||||
coachFocusEncrypted: null,
|
||||
pendingQuestionsEncrypted: encryptToBytes('["How is your sleep?"]'),
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
customMetric: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "metric-1",
|
||||
userId: OWNER,
|
||||
name: "Morning grip strength",
|
||||
unit: "kg",
|
||||
targetLow: 40,
|
||||
targetHigh: null,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-19T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
entries: [
|
||||
{
|
||||
id: "entry-1",
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-07-19T06:30:00.000Z"),
|
||||
note: "felt strong",
|
||||
createdAt: new Date("2026-07-19T06:31:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── the instance being restored into ───────────────────────────────── */
|
||||
|
||||
interface Written {
|
||||
model: string;
|
||||
op: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function recordingTx(written: Written[]) {
|
||||
// Symptom definitions the restore has written back, so link resolution can
|
||||
// only succeed if the definitions actually rode the file.
|
||||
const restoredSymptoms = new Map<string, string>();
|
||||
|
||||
const delegate = (model: string) =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, op: string) {
|
||||
return async (args: Record<string, unknown> = {}) => {
|
||||
if (op === "deleteMany") return { count: 0 };
|
||||
if (op === "findMany") {
|
||||
if (model === "cycleSymptom") {
|
||||
return [...restoredSymptoms].map(([key, id]) => ({ id, key }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (op === "findFirst" || op === "findUnique") return null;
|
||||
const data = (args.data ?? args.create ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (op === "create" || op === "upsert" || op === "createMany") {
|
||||
if (model === "cycleSymptom" && typeof data.key === "string") {
|
||||
restoredSymptoms.set(data.key, (data.id as string) ?? "new-id");
|
||||
}
|
||||
written.push({ model, op, data });
|
||||
}
|
||||
return { id: (data.id as string) ?? `${model}-created` };
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return new Proxy({} as Record<string, unknown>, {
|
||||
get: (_t, model: string) => delegate(model),
|
||||
});
|
||||
}
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/backups/b-1/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a real backup for the account above, then restore it. */
|
||||
async function roundTrip(): Promise<{ res: Response; written: Written[] }> {
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
sourceClient() as never,
|
||||
OWNER,
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
|
||||
// The file as it lands on disk: JSON, nothing else.
|
||||
mocks.decrypt.mockReturnValue(JSON.stringify(payload));
|
||||
|
||||
const written: Written[] = [];
|
||||
mocks.transaction.mockImplementation(
|
||||
async (fn: (tx: unknown) => Promise<unknown>) => fn(recordingTx(written)),
|
||||
);
|
||||
|
||||
const res = await (
|
||||
POST as unknown as (r: NextRequest, c: unknown) => Promise<Response>
|
||||
)(request(), { params: Promise.resolve({ id: "b-1" }) });
|
||||
|
||||
return { res, written };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.dataBackup.findUnique).mockResolvedValue({
|
||||
id: "b-1",
|
||||
userId: OWNER,
|
||||
data: "cipher",
|
||||
user: { id: OWNER, username: "owner" },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: OWNER,
|
||||
username: "owner",
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("backup round trip — export, wire schema, restore", () => {
|
||||
it("completes without the restore reporting a failure", async () => {
|
||||
const { res } = await roundTrip();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("writes the durable self-context back with its ciphertext intact", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
const profile = written.find((w) => w.model === "userHealthProfile");
|
||||
expect(
|
||||
profile,
|
||||
"the profile reached the file and nothing wrote it back",
|
||||
).toBeDefined();
|
||||
expect(profile!.data.userId).toBe(OWNER);
|
||||
// The envelope round-trips as bytes, not as a re-encryption of plaintext
|
||||
// the DR file never carried.
|
||||
expect(profile!.data.aboutMeEncrypted).toBeInstanceOf(Uint8Array);
|
||||
expect(profile!.data.conditionsEncrypted).toBeNull();
|
||||
expect(profile!.data.pendingQuestionsEncrypted).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("writes each custom metric back with its readings", async () => {
|
||||
const { written } = await roundTrip();
|
||||
|
||||
const metric = written.find((w) => w.model === "customMetric");
|
||||
expect(
|
||||
metric,
|
||||
"the metric reached the file and nothing wrote it back",
|
||||
).toBeDefined();
|
||||
expect(metric!.data).toMatchObject({
|
||||
userId: OWNER,
|
||||
name: "Morning grip strength",
|
||||
unit: "kg",
|
||||
targetLow: 40,
|
||||
decimals: 1,
|
||||
description: "Right hand, before breakfast",
|
||||
});
|
||||
|
||||
// The readings are the half that would go missing quietly: a metric with
|
||||
// no entries renders as an empty chart rather than as an error.
|
||||
const entries = (
|
||||
metric!.data.entries as { create: Array<Record<string, unknown>> }
|
||||
).create;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
note: "felt strong",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the account's own cycle symptom back before resolving its links", async () => {
|
||||
const { written, res } = await roundTrip();
|
||||
|
||||
// The definition has to land, or the link lookup below finds nothing.
|
||||
expect(
|
||||
written.find(
|
||||
(w) =>
|
||||
w.model === "cycleSymptom" && w.data.key === "custom:jaw_tension",
|
||||
),
|
||||
"the custom symptom definition never reached the restore",
|
||||
).toBeDefined();
|
||||
|
||||
// And the day-log that references it restores rather than throwing on an
|
||||
// unresolvable key — the failure a real account hit while the fix was inert.
|
||||
expect(res.status).toBe(200);
|
||||
expect(written.find((w) => w.model === "cycleDayLog")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { restoreCycleData } from "@/lib/cycle/backup";
|
||||
import { restoreProfileData } from "@/lib/export/profile-backup";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -64,6 +65,8 @@ interface RestoreResponse {
|
||||
familyHistory: number;
|
||||
workouts: number;
|
||||
documents: number;
|
||||
healthProfile: number;
|
||||
customMetrics: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,6 +565,32 @@ const handler = apiHandler(
|
||||
}
|
||||
|
||||
if (payload.intakeEvents.length > 0) {
|
||||
// An event whose medication did not come back used to be mapped to
|
||||
// null and filtered away: the dose history came back short and the
|
||||
// restore reported success. That is the same silent-drop shape the
|
||||
// cycle symptom links had, and it gets the same answer — name the
|
||||
// unresolved reference and stop, so the operator learns from the
|
||||
// error rather than from a compliance rate that quietly moved.
|
||||
const unresolvedMedications = [
|
||||
...new Set(
|
||||
payload.intakeEvents
|
||||
.filter(
|
||||
(e) =>
|
||||
!(
|
||||
e.medicationId &&
|
||||
restoredMedicationIds.has(e.medicationId)
|
||||
) && !medByName.has(e.medication),
|
||||
)
|
||||
.map((e) => e.medicationId ?? e.medication),
|
||||
),
|
||||
];
|
||||
if (unresolvedMedications.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown medication references in intake events: ${unresolvedMedications.join(
|
||||
", ",
|
||||
)}. The backup records doses against medications it does not carry.`,
|
||||
);
|
||||
}
|
||||
const rows = payload.intakeEvents
|
||||
.map((e) => {
|
||||
const medId =
|
||||
@@ -718,6 +747,12 @@ const handler = apiHandler(
|
||||
// above. `notesEncrypted` is restored as ciphertext verbatim.
|
||||
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
|
||||
|
||||
// Durable self-context + user-defined metrics. Both ends of this pair
|
||||
// live in `src/lib/export/profile-backup.ts` beside its builder — a
|
||||
// grep of THIS file alone will not find them, exactly as it does not
|
||||
// find the cycle restore one line above.
|
||||
const profileCleared = await restoreProfileData(tx, ownerId, payload);
|
||||
|
||||
const biomarkerByName = new Map<string, string>();
|
||||
const restoredBiomarkerIds = new Set<string>();
|
||||
for (const biomarker of payload.biomarkers) {
|
||||
@@ -1073,6 +1108,8 @@ const handler = apiHandler(
|
||||
familyHistory: familyHistory.count,
|
||||
workouts: workouts.count,
|
||||
documents: documents.count,
|
||||
healthProfile: profileCleared.healthProfile,
|
||||
customMetrics: profileCleared.customMetrics,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -1197,6 +1234,9 @@ const handler = apiHandler(
|
||||
familyHistory: summary.familyHistory,
|
||||
workouts: summary.workouts,
|
||||
documents: summary.documents,
|
||||
healthProfile: summary.healthProfile,
|
||||
customMetrics: summary.customMetrics,
|
||||
customMetricEntries: summary.customMetricEntries,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
moodTag: { findMany: vi.fn() },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// Durable self-context + user-defined metrics, read by
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.15.0 — cycle tables read by the full-backup helper.
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
@@ -73,9 +77,13 @@ function mkReq(url: string): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// `resetAllMocks` clears the inline default, and the full-backup payload
|
||||
// builder reads this unconditionally.
|
||||
// `resetAllMocks` clears the inline defaults, and the full-backup payload
|
||||
// builder reads these unconditionally.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodTag: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// Durable self-context + user-defined metrics, read by
|
||||
// `buildProfileBackupSection`.
|
||||
userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -117,6 +117,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
export_archive_bytes: archive.byteLength,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,6 +76,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export_workout_count: counts.workouts,
|
||||
export_document_count: counts.documents,
|
||||
export_nutrient_day_count: counts.nutrientDays,
|
||||
export_health_profile_count: counts.healthProfile,
|
||||
export_custom_metric_count: counts.customMetrics,
|
||||
export_custom_metric_entry_count: counts.customMetricEntries,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user