Merge branch 'main' into fix/wipe-geo-truths

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 03:29:51 +02:00
commit cbf949d43a
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);
+9
View File
@@ -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" } }]);
});
});