Files
HealthLog/tests/integration/relation-links-portable-restore.test.ts
T
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

226 lines
7.8 KiB
TypeScript

/**
* The five relation link tables survive a PORTABLE export and restore.
*
* A portable file carries no row id for a medication or a lab result: the
* restore mints fresh ones, because the file may land on another instance or
* another account where the original ids mean nothing. The link tables name
* both of their ends by id, so a link that rode the file under the OLD id of
* either end used to point at nothing after the restore, and the restore's
* own placement check dropped it as "end is gone". Four of the five tables
* have a medication or a lab result at one end; only the visit to dose link
* came back.
*
* Driven through the real restore route. A portable file with documents is
* refused (no document bytes, see `backup-portable-file-restore.test.ts`), so
* the file restored here is the portable payload with the document entries
* taken from the disaster-recovery build of the same account: everything else
* (medications, lab results, links) is the portable bytes exactly as the
* export writes them.
*
* Mutation check: drop the `exportRef` from the portable medication or lab
* result entry and the matching link count goes to zero.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { linkTargets } from "@/lib/links";
import { parseBackupPayload } from "@/lib/validations/backup";
import { POST } from "./restore-job-driver";
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/cache/invalidate", () => ({
invalidateUserData: vi.fn(),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seed() {
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "portable-links-owner",
email: "portable-links-owner@example.test",
role: "ADMIN",
},
});
const userId = owner.id;
const session = await prisma.session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
const encounter = await prisma.encounter.create({
data: {
userId,
occurredAt: new Date("2026-07-01T09:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
},
});
const document = await prisma.inboundDocument.create({
data: {
userId,
kind: "LAB_RESULT",
title: "Thyroid panel",
mimeType: "application/pdf",
byteSize: 12,
contentEncrypted: new Uint8Array([1, 2, 3]),
contentCodec: "binary2",
documentDate: new Date("2026-06-30T00:00:00.000Z"),
},
});
const biomarker = await prisma.biomarker.create({
data: { userId, name: "TSH", unit: "mU/L" },
});
const labResult = await prisma.labResult.create({
data: {
userId,
biomarkerId: biomarker.id,
analyte: "TSH",
value: 2.1,
unit: "mU/L",
takenAt: new Date("2026-06-30T09:00:00.000Z"),
},
});
const episode = await prisma.illnessEpisode.create({
data: {
userId,
label: "Thyroid",
type: "CHRONIC",
onsetAt: new Date("2026-01-20T00:00:00.000Z"),
},
});
const medication = await prisma.medication.create({
data: { userId, name: "Levothyroxine", dose: "50 µg" },
});
// A second drug with no links, so a restore that bound every link to the
// first medication it found would be told apart from one that maps by key.
await prisma.medication.create({
data: { userId, name: "Vitamin D", dose: "1000 IU" },
});
const vaccination = await prisma.vaccinationRecord.create({
data: {
userId,
occurredAt: new Date("2026-05-14T00:00:00.000Z"),
antigenSlug: "tdap",
vaccineName: "Tdap",
},
});
const link = (
sourceKind: Parameters<typeof linkTargets>[1]["sourceKind"],
sourceId: string,
targetKind: Parameters<typeof linkTargets>[1]["targetKind"],
targetId: string,
) =>
linkTargets(prisma, {
userId,
sourceKind,
sourceId,
targetKind,
targetIds: [targetId],
});
await link("labResult", labResult.id, "document", document.id);
await link("encounter", encounter.id, "medication", medication.id);
await link("encounter", encounter.id, "vaccination", vaccination.id);
await link("medication", medication.id, "document", document.id);
await link("medication", medication.id, "conditionEpisode", episode.id);
return { userId, medication, labResult };
}
describe("a portable restore of the five relation link tables", () => {
it("brings every link back, bound to the records the restore wrote", async () => {
const prisma = getPrismaClient();
const { userId, medication, labResult } = await seed();
const portable = parseBackupPayload(
JSON.stringify((await buildFullBackupPayload(prisma, userId)).payload),
);
const dr = parseBackupPayload(
JSON.stringify(
(
await buildFullBackupPayload(prisma, userId, {
purpose: "disaster-recovery",
})
).payload,
),
);
// The premise: the portable file names no medication or lab result id.
expect(portable.medications.every((m) => m.id === undefined)).toBe(true);
expect(portable.labResults.every((l) => l.id === undefined)).toBe(true);
const file = parseBackupPayload(
JSON.stringify({ ...portable, documents: dr.documents }),
);
const backup = await prisma.dataBackup.create({
data: {
userId,
type: "PORTABLE_LINKS_RESTORE",
data: encrypt(JSON.stringify(file)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, await response.clone().text()).toBe(200);
// The ends were re-minted, so the old ids are gone.
const restoredMed = await prisma.medication.findFirstOrThrow({
where: { userId, name: "Levothyroxine" },
});
const restoredLab = await prisma.labResult.findFirstOrThrow({
where: { userId, analyte: "TSH" },
});
expect(restoredMed.id).not.toBe(medication.id);
expect(restoredLab.id).not.toBe(labResult.id);
const where = { userId };
const [labDocs, encMeds, encDoses, medDocs, medConds] = await Promise.all([
prisma.labResultDocumentLink.findMany({ where }),
prisma.encounterMedicationLink.findMany({ where }),
prisma.encounterVaccinationLink.findMany({ where }),
prisma.medicationDocumentLink.findMany({ where }),
prisma.medicationConditionLink.findMany({ where }),
]);
expect(labDocs.map((r) => r.labResultId)).toEqual([restoredLab.id]);
expect(encMeds.map((r) => r.medicationId)).toEqual([restoredMed.id]);
expect(encDoses).toHaveLength(1);
expect(medDocs.map((r) => r.medicationId)).toEqual([restoredMed.id]);
expect(medConds.map((r) => r.medicationId)).toEqual([restoredMed.id]);
});
});