mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge branch 'main' into fix/wipe-geo-truths
This commit is contained in:
93 files changed
+5417
-236
No files matched your search
@@ -70,6 +70,14 @@ beforeEach(async () => {
|
||||
username: "cycle-flow",
|
||||
email: "cycle-flow@example.test",
|
||||
gender: "FEMALE",
|
||||
// The suite runs under TZ=UTC and computes its relative day keys with
|
||||
// local Date arithmetic, i.e. in UTC. The route resolves "today" in the
|
||||
// USER's zone and falls back to Europe/Berlin when none is set - one
|
||||
// hour past 22:00 UTC the two clocks disagree about the date and every
|
||||
// day-of-cycle assertion is off by one. Pinning the fixture zone to UTC
|
||||
// makes the test's clock and the route's clock the same clock at any
|
||||
// hour of the day.
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
await loginAs(USER_ID);
|
||||
|
||||
@@ -69,6 +69,14 @@ beforeEach(async () => {
|
||||
username: "cycle-female",
|
||||
email: "cycle-female@example.test",
|
||||
gender: "FEMALE",
|
||||
// The suite runs under TZ=UTC and computes its relative day keys with
|
||||
// local Date arithmetic, i.e. in UTC. The route resolves "today" in the
|
||||
// USER's zone and falls back to Europe/Berlin when none is set - one
|
||||
// hour past 22:00 UTC the two clocks disagree about the date and every
|
||||
// day-of-cycle assertion is off by one. Pinning the fixture zone to UTC
|
||||
// makes the test's clock and the route's clock the same clock at any
|
||||
// hour of the day.
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
@@ -77,6 +85,7 @@ beforeEach(async () => {
|
||||
username: "cycle-male",
|
||||
email: "cycle-male@example.test",
|
||||
gender: "MALE",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* The whole path a printed reference range travels, against a real Postgres.
|
||||
*
|
||||
* The failure this pins is a two-ended one: until now the document reading
|
||||
* pulled the value off a lab report and dropped the range printed beside it,
|
||||
* and every end of that pipe looked healthy on its own. So the assertions here
|
||||
* follow ONE range through the real assembly rather than checking each end in
|
||||
* isolation:
|
||||
*
|
||||
* extract → the staged fact carries the printed string
|
||||
* commit → the written row carries the derived bounds AND the string
|
||||
* read → the API DTO serves both windows and says which is in force
|
||||
* verdict → the reading is judged against the REPORT's window, not the
|
||||
* catalog band — the case where the two disagree is the point
|
||||
* report → the doctor-report payload and the FHIR bundle agree with it
|
||||
*
|
||||
* The catalog band is deliberately set so that the same number reads
|
||||
* "in-range" against the report and "above" against the catalog. A test whose
|
||||
* two windows agree cannot tell whether the precedence rule is wired at all.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER_ID = "user-lab-source-range";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "lab-source-range",
|
||||
email: "lab-source-range@example.test",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
/** The catalog band the marker carries. 5.2 sits ABOVE this window. */
|
||||
const CATALOG = { lowerBound: 3.5, upperBound: 5.0 };
|
||||
/** The window the report printed. 5.2 sits INSIDE this one. */
|
||||
const PRINTED = "3,9 - 5,4";
|
||||
const VALUE = 5.2;
|
||||
|
||||
async function seedMarker() {
|
||||
return getPrismaClient().biomarker.create({
|
||||
data: { userId: USER_ID, name: "Kalium", unit: "mmol/L", ...CATALOG },
|
||||
});
|
||||
}
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/labs", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function readDto(analyte = "Kalium") {
|
||||
const { GET } = await import("@/app/api/labs/route");
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/labs?analyte=${analyte}`),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
return body.data.results[0];
|
||||
}
|
||||
|
||||
describe("a printed reference range, end to end", () => {
|
||||
it("survives the document path onto the row and governs its verdict", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const marker = await seedMarker();
|
||||
|
||||
// The document path, through the REAL commit helper the confirm route
|
||||
// calls. The fact carries what a report prints: a value, a unit, and a
|
||||
// window stated in German notation beside it.
|
||||
const { commitApprovedFact } = await import("@/lib/documents/commit");
|
||||
const { encryptFactData, encryptFactProvenance } =
|
||||
await import("@/lib/documents/store");
|
||||
const doc = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
filename: "befund.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1024,
|
||||
contentEncrypted: Buffer.from("stand-in for the stored bytes"),
|
||||
status: "EXTRACTED",
|
||||
},
|
||||
});
|
||||
const fact = await prisma.extractedFact.create({
|
||||
data: {
|
||||
documentId: doc.id,
|
||||
userId: USER_ID,
|
||||
factType: "OBSERVATION",
|
||||
status: "APPROVED",
|
||||
confidence: 0.9,
|
||||
needsReview: false,
|
||||
dataEncrypted: encryptFactData({
|
||||
label: "Kalium",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: VALUE,
|
||||
valueText: null,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: PRINTED,
|
||||
effectiveDate: "2026-07-01",
|
||||
}),
|
||||
provenanceEncrypted: encryptFactProvenance({
|
||||
sourceText: "Kalium 5,2 mmol/l 3,9 - 5,4",
|
||||
anchored: true,
|
||||
sourceOffset: 0,
|
||||
page: 0,
|
||||
confidence: 0.9,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const ref = await commitApprovedFact(USER_ID, fact);
|
||||
expect(ref.recordType).toBe("labResult");
|
||||
|
||||
// The row carries BOTH readings of the printed window: the bounds derived
|
||||
// from it and the string itself.
|
||||
const row = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: ref.recordId },
|
||||
});
|
||||
expect(row.sourceReferenceLow).toBe(3.9);
|
||||
expect(row.sourceReferenceHigh).toBe(5.4);
|
||||
expect(row.sourceReferenceText).toBe(PRINTED);
|
||||
// The catalog stamp is untouched — the marker already existed, so the
|
||||
// document's window did not rewrite the user's own band.
|
||||
expect(row.referenceLow).toBe(CATALOG.lowerBound);
|
||||
expect(row.referenceHigh).toBe(CATALOG.upperBound);
|
||||
const markerAfter = await prisma.biomarker.findUniqueOrThrow({
|
||||
where: { id: marker.id },
|
||||
});
|
||||
expect(markerAfter.lowerBound).toBe(CATALOG.lowerBound);
|
||||
expect(markerAfter.upperBound).toBe(CATALOG.upperBound);
|
||||
|
||||
// The read side serves both windows, names the winner, and flags that the
|
||||
// two disagree.
|
||||
const dto = await readDto();
|
||||
expect(dto).toMatchObject({
|
||||
value: VALUE,
|
||||
referenceLow: 3.9,
|
||||
referenceHigh: 5.4,
|
||||
catalogReferenceLow: CATALOG.lowerBound,
|
||||
catalogReferenceHigh: CATALOG.upperBound,
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: PRINTED,
|
||||
referenceOrigin: "source",
|
||||
referenceDivergesFromCatalog: true,
|
||||
});
|
||||
|
||||
// The verdict is the point. Against the catalog band 5.2 is above range;
|
||||
// against the window the lab printed it is inside it, and the lab's window
|
||||
// is the one the physician read the value against.
|
||||
expect(dto.rangeStatus).toBe("in-range");
|
||||
|
||||
// The doctor report reads the same window, and prints the report's own
|
||||
// string rather than a reformatted version of it.
|
||||
const { loadLabResults } =
|
||||
await import("@/lib/doctor-report/clinical-records");
|
||||
const labs = await loadLabResults(
|
||||
USER_ID,
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
new Date("2026-12-31T23:59:59.000Z"),
|
||||
);
|
||||
expect(labs?.[0]).toMatchObject({
|
||||
referenceLow: 3.9,
|
||||
referenceHigh: 5.4,
|
||||
catalogReferenceLow: CATALOG.lowerBound,
|
||||
catalogReferenceHigh: CATALOG.upperBound,
|
||||
sourceReferenceText: PRINTED,
|
||||
referenceOrigin: "source",
|
||||
referenceDivergesFromCatalog: true,
|
||||
});
|
||||
|
||||
// FHIR exports the same window — a receiver that trusts referenceRange
|
||||
// must not be handed the catalog band while the app shows another.
|
||||
const { labObservations } = await import("@/lib/fhir/resources/labs");
|
||||
let seq = 0;
|
||||
const observations = labObservations(
|
||||
{ labResults: labs } as Parameters<typeof labObservations>[0],
|
||||
() => `obs-${++seq}`,
|
||||
);
|
||||
expect(observations[0].referenceRange?.[0]).toMatchObject({
|
||||
low: { value: 3.9 },
|
||||
high: { value: 5.4 },
|
||||
text: PRINTED,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a window it cannot read as text and leaves the catalog in force", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedMarker();
|
||||
|
||||
const { POST } = await import("@/app/api/labs/route");
|
||||
const marker = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
});
|
||||
const res = await POST(
|
||||
postReq({
|
||||
biomarkerId: marker.id,
|
||||
value: VALUE,
|
||||
sourceReferenceText: "siehe Befund",
|
||||
takenAt: "2026-07-02T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId: USER_ID },
|
||||
});
|
||||
// The string is on the record — absence would be the old failure, and a
|
||||
// fabricated bound would be worse than either.
|
||||
expect(row.sourceReferenceText).toBe("siehe Befund");
|
||||
expect(row.sourceReferenceLow).toBeNull();
|
||||
expect(row.sourceReferenceHigh).toBeNull();
|
||||
|
||||
const dto = await readDto();
|
||||
expect(dto).toMatchObject({
|
||||
sourceReferenceText: "siehe Befund",
|
||||
referenceOrigin: "catalog",
|
||||
referenceDivergesFromCatalog: false,
|
||||
referenceLow: CATALOG.lowerBound,
|
||||
referenceHigh: CATALOG.upperBound,
|
||||
});
|
||||
// No derivable window means the catalog stays the net, so the catalog's
|
||||
// verdict stands.
|
||||
expect(dto.rangeStatus).toBe("above");
|
||||
});
|
||||
|
||||
it("carries the window through a backup round trip", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const marker = await seedMarker();
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: marker.id,
|
||||
analyte: "Kalium",
|
||||
unit: "mmol/L",
|
||||
value: VALUE,
|
||||
referenceLow: CATALOG.lowerBound,
|
||||
referenceHigh: CATALOG.upperBound,
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: PRINTED,
|
||||
takenAt: new Date("2026-07-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const { buildRecordsBackupSection } =
|
||||
await import("@/lib/export/records-backup");
|
||||
const backup = await buildRecordsBackupSection(prisma, USER_ID, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
expect(backup.labResults[0]).toMatchObject({
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: PRINTED,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* A mood entry, out through the real backup and back in through the real
|
||||
* restore, asserted on the rows.
|
||||
*
|
||||
* Deliberately not a classification test. Asserting that the payload has a
|
||||
* `moodEntries` key proves nothing this repository has not already been burned
|
||||
* by: excise the restore call entirely and a shape check still reports green.
|
||||
* So this seeds an account, exports through `buildFullBackupPayload`, parses
|
||||
* through `parseBackupPayload`, restores through the real
|
||||
* `POST /api/admin/backups/[id]/restore`, and then reads the row back out of
|
||||
* Postgres and compares values.
|
||||
*
|
||||
* The fixture is the entry that used to come back wrong in four ways at once:
|
||||
* a ticked BINARY tag and a rated factor on the same entry, a note in the
|
||||
* encrypted column, a timezone that is not the legacy Europe/Berlin default,
|
||||
* and a sync counter above zero. The old payload carried none of them.
|
||||
*
|
||||
* The second case is the compatibility floor: a hand-written payload in the
|
||||
* shape files were written in BEFORE this change, restored through the same
|
||||
* route. Every field this adds is optional or defaulted precisely so that file
|
||||
* keeps working, and the only way to know is to send one.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "mood-round-trip-owner";
|
||||
const NOTE = "slept badly, long walk in the afternoon";
|
||||
const LOGGED_AT = new Date("2026-07-19T23:40:00.000Z");
|
||||
const CREATED_AT = new Date("2026-07-19T23:41:00.000Z");
|
||||
const UPDATED_AT = new Date("2026-07-20T06:05:00.000Z");
|
||||
const DELETED_AT = new Date("2026-07-20T07:00:00.000Z");
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
async function seedAdminSession(prisma: PrismaClient) {
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "mood-round-trip-admin",
|
||||
email: "mood-round-trip-admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return admin;
|
||||
}
|
||||
|
||||
async function seedOwner(prisma: PrismaClient) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "mood-round-trip-owner",
|
||||
email: "mood-round-trip-owner@example.test",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The taxonomy the entry links to: one seeded BINARY tag (`userId: null`, the
|
||||
* reference data every instance has), one BINARY tag the account made itself,
|
||||
* and one RATED factor. The account's own tag matters — it travels in the file
|
||||
* as a `customMoodTags` row, and a restore that could not re-create it would
|
||||
* lose the link even with everything else correct.
|
||||
*/
|
||||
async function seedTaxonomy(prisma: PrismaClient) {
|
||||
const category = await prisma.moodTagCategory.create({
|
||||
data: {
|
||||
id: "mood-rt-category",
|
||||
key: "mood_round_trip",
|
||||
labelKey: "mood.tagCategory.roundTrip",
|
||||
},
|
||||
});
|
||||
const seededTag = await prisma.moodTag.create({
|
||||
data: {
|
||||
id: "mood-rt-seeded-tag",
|
||||
categoryId: category.id,
|
||||
key: "rt_headache",
|
||||
labelKey: "mood.tag.rtHeadache",
|
||||
kind: "BINARY",
|
||||
},
|
||||
});
|
||||
const ownTag = await prisma.moodTag.create({
|
||||
data: {
|
||||
id: "mood-rt-own-tag",
|
||||
userId: OWNER_ID,
|
||||
categoryId: category.id,
|
||||
key: "custom:rt_long_walk",
|
||||
labelKey: "mood.tag.custom",
|
||||
kind: "BINARY",
|
||||
},
|
||||
});
|
||||
const factor = await prisma.moodTag.create({
|
||||
data: {
|
||||
id: "mood-rt-factor",
|
||||
categoryId: category.id,
|
||||
key: "rt_sleep_quality",
|
||||
labelKey: "mood.tag.rtSleepQuality",
|
||||
kind: "RATED",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
},
|
||||
});
|
||||
return { category, seededTag, ownTag, factor };
|
||||
}
|
||||
|
||||
function makeRestoreRequest(id: string) {
|
||||
return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreFromPayload(
|
||||
prisma: PrismaClient,
|
||||
payload: unknown,
|
||||
type: string,
|
||||
) {
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type,
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
return POST(makeRestoreRequest(backup.id) as never, {
|
||||
params: Promise.resolve({ id: backup.id }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("mood backup round trip", () => {
|
||||
it("brings the note, the timezone, the sync counter and both tag arms back", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await seedOwner(prisma);
|
||||
const { seededTag, ownTag, factor } = await seedTaxonomy(prisma);
|
||||
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
id: "mood-rt-entry",
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: JSON.stringify(["free-text-tag"]),
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(NOTE),
|
||||
source: "MANUAL",
|
||||
externalId: null,
|
||||
moodLoggedAt: LOGGED_AT,
|
||||
// Not Europe/Berlin: a restore that drops `tz` moves this entry's day
|
||||
// boundary by six hours and nothing else in the row shows it.
|
||||
tz: "America/New_York",
|
||||
syncVersion: 3,
|
||||
createdAt: CREATED_AT,
|
||||
updatedAt: UPDATED_AT,
|
||||
tagLinks: {
|
||||
create: [
|
||||
{ moodTagId: factor.id, rating: 4 },
|
||||
{ moodTagId: seededTag.id, rating: null },
|
||||
{ moodTagId: ownTag.id, rating: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
id: "mood-rt-tombstone",
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-18",
|
||||
mood: "SCHLECHT",
|
||||
score: 2,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: new Date("2026-07-18T22:00:00.000Z"),
|
||||
tz: "America/New_York",
|
||||
syncVersion: 5,
|
||||
deletedAt: DELETED_AT,
|
||||
},
|
||||
});
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const parsed = parseBackupPayload(payload);
|
||||
|
||||
// Wipe what the restore is supposed to bring back. The tag rows stay: the
|
||||
// instance's catalogue is reference data, and the account's own tag rides
|
||||
// in the file and is re-created by the restore either way.
|
||||
await prisma.moodEntry.deleteMany({ where: { userId: OWNER_ID } });
|
||||
await prisma.moodTag.deleteMany({ where: { userId: OWNER_ID } });
|
||||
expect(await prisma.moodEntry.count({ where: { userId: OWNER_ID } })).toBe(
|
||||
0,
|
||||
);
|
||||
|
||||
const response = await restoreFromPayload(
|
||||
prisma,
|
||||
parsed,
|
||||
"MOOD_ROUND_TRIP",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: { skipped: { links: number } };
|
||||
};
|
||||
// Nothing was dropped. A restore that lost a link and still answered 200
|
||||
// is exactly what the skip report exists to make visible.
|
||||
expect(body.data.skipped.links).toBe(0);
|
||||
|
||||
const restored = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: "mood-rt-entry" },
|
||||
});
|
||||
// Asserted as "there is a note" before it is decrypted, so a payload that
|
||||
// stopped carrying the ciphertext fails by name rather than as a TypeError
|
||||
// three frames inside the cipher.
|
||||
expect(
|
||||
restored.noteEncrypted,
|
||||
"the restored entry has no note ciphertext — the backup dropped it",
|
||||
).not.toBeNull();
|
||||
expect(decryptFromBytes(restored.noteEncrypted!)).toBe(NOTE);
|
||||
expect(restored.note).toBeNull();
|
||||
expect(restored.tz).toBe("America/New_York");
|
||||
expect(restored.syncVersion).toBe(3);
|
||||
expect(restored.moodLoggedAt).toEqual(LOGGED_AT);
|
||||
expect(restored.createdAt).toEqual(CREATED_AT);
|
||||
expect(restored.updatedAt).toEqual(UPDATED_AT);
|
||||
expect(restored.date).toBe("2026-07-19");
|
||||
expect(restored.score).toBe(3);
|
||||
expect(restored.tags).toBe(JSON.stringify(["free-text-tag"]));
|
||||
expect(restored.deletedAt).toBeNull();
|
||||
|
||||
const tombstone = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: "mood-rt-tombstone" },
|
||||
});
|
||||
expect(tombstone.deletedAt).toEqual(DELETED_AT);
|
||||
expect(tombstone.syncVersion).toBe(5);
|
||||
|
||||
const links = await prisma.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: "mood-rt-entry" },
|
||||
select: { rating: true, moodTag: { select: { key: true, kind: true } } },
|
||||
orderBy: { moodTagId: "asc" },
|
||||
});
|
||||
const byKey = new Map(links.map((l) => [l.moodTag.key, l]));
|
||||
expect([...byKey.keys()].sort()).toEqual([
|
||||
"custom:rt_long_walk",
|
||||
"rt_headache",
|
||||
"rt_sleep_quality",
|
||||
]);
|
||||
// The rating is what tells the two arms apart in the row: a ticked tag
|
||||
// carries NULL, and that NULL is what the old backup filter read as
|
||||
// "nothing to carry".
|
||||
expect(byKey.get("rt_headache")!.rating).toBeNull();
|
||||
expect(byKey.get("custom:rt_long_walk")!.rating).toBeNull();
|
||||
expect(byKey.get("rt_sleep_quality")!.rating).toBe(4);
|
||||
});
|
||||
|
||||
it("restores a file written in the shape that predates these fields", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await seedOwner(prisma);
|
||||
const { factor } = await seedTaxonomy(prisma);
|
||||
|
||||
// Verbatim the shape the builder emitted before this change: no `note`, no
|
||||
// `noteEncrypted`, no `tz`, no `syncedAt`, no `syncVersion`, no
|
||||
// `createdAt`/`updatedAt`, no `structuredTags`. Hand-written rather than
|
||||
// generated, because a fixture built from today's builder cannot describe
|
||||
// yesterday's file.
|
||||
const legacyPayload = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
userId: OWNER_ID,
|
||||
appSettings: null,
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [
|
||||
{
|
||||
id: "mood-rt-legacy",
|
||||
date: "2026-07-19",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: null,
|
||||
source: "MOODLOG",
|
||||
loggedAt: LOGGED_AT.toISOString(),
|
||||
externalId: "moodlog-legacy",
|
||||
deletedAt: null,
|
||||
factors: [{ key: factor.key, rating: 5 }],
|
||||
},
|
||||
],
|
||||
customMoodTags: [],
|
||||
nutrientDays: [],
|
||||
};
|
||||
|
||||
const parsed = parseBackupPayload(legacyPayload);
|
||||
// Defaults, not values: absence has to stay absence through the parse.
|
||||
expect(parsed.moodEntries[0].structuredTags).toEqual([]);
|
||||
expect(parsed.moodEntries[0].tz).toBeUndefined();
|
||||
expect(parsed.moodEntries[0].syncVersion).toBeUndefined();
|
||||
|
||||
const response = await restoreFromPayload(
|
||||
prisma,
|
||||
parsed,
|
||||
"MOOD_LEGACY_SHAPE",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const restored = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: "mood-rt-legacy" },
|
||||
});
|
||||
// Identical to what the old restore wrote for the same file: the note
|
||||
// columns empty, `tz` NULL — which is what such a row meant, the legacy
|
||||
// Europe/Berlin reading — and the counter at the schema default.
|
||||
expect(restored.note).toBeNull();
|
||||
expect(restored.noteEncrypted).toBeNull();
|
||||
expect(restored.tz).toBeNull();
|
||||
expect(restored.syncVersion).toBe(0);
|
||||
expect(restored.mood).toBe("GUT");
|
||||
expect(restored.score).toBe(4);
|
||||
expect(restored.externalId).toBe("moodlog-legacy");
|
||||
expect(restored.deletedAt).toBeNull();
|
||||
|
||||
const links = await prisma.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: "mood-rt-legacy" },
|
||||
select: { rating: true, moodTagId: true },
|
||||
});
|
||||
expect(links).toEqual([{ moodTagId: factor.id, rating: 5 }]);
|
||||
});
|
||||
|
||||
it("names a ticked tag this instance cannot resolve instead of losing it quietly", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await seedOwner(prisma);
|
||||
await seedTaxonomy(prisma);
|
||||
|
||||
const payload = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: "2026-07-20T00:00:00.000Z",
|
||||
userId: OWNER_ID,
|
||||
appSettings: null,
|
||||
measurements: [],
|
||||
medications: [],
|
||||
intakeEvents: [],
|
||||
moodEntries: [
|
||||
{
|
||||
id: "mood-rt-unknown-tag",
|
||||
date: "2026-07-19",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: null,
|
||||
source: "MANUAL",
|
||||
loggedAt: LOGGED_AT.toISOString(),
|
||||
externalId: null,
|
||||
deletedAt: null,
|
||||
factors: [],
|
||||
structuredTags: ["rt_headache", "rt_retired_tag"],
|
||||
},
|
||||
],
|
||||
customMoodTags: [],
|
||||
nutrientDays: [],
|
||||
};
|
||||
|
||||
const response = await restoreFromPayload(
|
||||
prisma,
|
||||
parseBackupPayload(payload),
|
||||
"MOOD_UNKNOWN_TAG",
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
skipped: {
|
||||
links: number;
|
||||
catalogueKeys: Array<{
|
||||
catalogue: string;
|
||||
key: string;
|
||||
links: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.skipped.catalogueKeys).toEqual([
|
||||
{ catalogue: "moodTag", key: "rt_retired_tag", links: 1 },
|
||||
]);
|
||||
expect(body.data.skipped.links).toBe(1);
|
||||
|
||||
// The entry itself came back whole. One key that has nowhere to attach
|
||||
// costs the link, never the record.
|
||||
const restored = await prisma.moodEntry.findUniqueOrThrow({
|
||||
where: { id: "mood-rt-unknown-tag" },
|
||||
});
|
||||
expect(restored.score).toBe(3);
|
||||
const links = await prisma.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: restored.id },
|
||||
select: { moodTag: { select: { key: true } } },
|
||||
});
|
||||
expect(links).toEqual([{ moodTag: { key: "rt_headache" } }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user