Merge branch 'v143/labs' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:06:51 +02:00
commit 74fa4e10b6
96 files changed
+6992 -626

No files matched your search

+8
View File
@@ -296,6 +296,14 @@ export const ADMITTED_MUTATING_HANDLERS = [
"manage",
"delete",
),
handler(
"app/api/biomarkers/[id]/merge/route.ts",
"/api/biomarkers/[id]/merge",
"POST",
"labs",
"manage",
"update",
),
handler(
"app/api/allergies/route.ts",
"/api/allergies",
@@ -244,6 +244,8 @@ const COUNT_BACK: Record<
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
LabResult: (p, userId) => p.labResult.count({ where: { userId } }),
Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }),
BiomarkerAlias: (p, userId) => p.biomarkerAlias.count({ where: { userId } }),
BiomarkerMerge: (p, userId) => p.biomarkerMerge.count({ where: { userId } }),
Allergy: (p, userId) => p.allergy.count({ where: { userId } }),
FamilyHistoryEntry: (p, userId) =>
p.familyHistoryEntry.count({ where: { userId } }),
@@ -738,6 +740,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
const biomarker = await prisma.biomarker.create({
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
});
// Another spelling of the marker, and the ledger row of a marker that was
// merged into it: both ride the marker (D1223).
await prisma.biomarkerAlias.create({
data: {
userId: OWNER_ID,
biomarkerId: biomarker.id,
name: "Ferr.",
nameFolded: "ferr.",
source: "IMPORT_CHOICE",
},
});
await prisma.biomarkerMerge.create({
data: {
userId: OWNER_ID,
targetBiomarkerId: biomarker.id,
sourceBiomarkerId: "merged-away-marker",
sourceName: "Ferritin (Serum)",
readingCount: 3,
},
});
const labResult = await prisma.labResult.create({
data: {
userId: OWNER_ID,
@@ -0,0 +1,282 @@
/**
* A marker's other spellings and the merge ledger survive a backup and a
* restore, in both kinds of file (D1223).
*
* Without the aliases a restored account is asked again at the next scan
* which marker each spelling belongs to; without the ledger a link to a
* merged marker reads as a plain 404. Every column of both tables is compared
* after a disaster-recovery round trip, and the portable file, which carries
* no ids, is checked for what the resolver and the 404 actually use: the
* spelling, its fold, where it came from and which marker it belongs to, and
* which marker a merged id went into.
*/
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 { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { loadMarkerIndex, resolveInIndex } from "@/lib/labs/biomarker-resolve";
import { mergedInto } from "@/lib/labs/biomarker-merge";
import { parseBackupPayload } from "@/lib/validations/backup";
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
import { cookieJar } from "./mock-next-headers";
import { POST } from "./restore-job-driver";
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", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/cache/invalidate")>()),
invalidateUserData: vi.fn(),
}));
const OWNER = "biomarker-backup-owner";
async function createOwner() {
await getPrismaClient().user.create({
data: {
id: OWNER,
username: "biomarker-backup-owner",
email: "biomarker-backup-owner@example.test",
},
});
}
async function seedAdmin() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "biomarker-backup-admin",
email: "biomarker-backup-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);
}
/** Two markers, an alias from each source, and a two-step merge ledger. */
async function seed() {
const prisma = getPrismaClient();
const alp = await prisma.biomarker.create({
data: { userId: OWNER, name: "Alkaline Phosphatase", unit: "U/L" },
});
const bili = await prisma.biomarker.create({
data: { userId: OWNER, name: "Bilirubin (Total)", unit: "mg/dL" },
});
await prisma.labResult.create({
data: {
userId: OWNER,
biomarkerId: alp.id,
analyte: "Alk.P",
value: 80,
unit: "U/L",
takenAt: new Date("2026-05-01T08:00:00.000Z"),
},
});
await prisma.biomarkerAlias.createMany({
data: [
{
userId: OWNER,
biomarkerId: alp.id,
name: "Alk.P",
nameFolded: "alk.p",
source: "MERGE",
createdAt: new Date("2026-05-02T10:00:00.000Z"),
},
{
userId: OWNER,
biomarkerId: alp.id,
name: "AP",
nameFolded: "ap",
source: "MANUAL",
createdAt: new Date("2026-05-03T10:00:00.000Z"),
},
{
userId: OWNER,
biomarkerId: bili.id,
name: "Bilirubin ges.",
nameFolded: "bilirubin ges.",
source: "IMPORT_CHOICE",
createdAt: new Date("2026-05-04T10:00:00.000Z"),
},
],
});
await prisma.biomarkerMerge.createMany({
data: [
{
userId: OWNER,
targetBiomarkerId: alp.id,
sourceBiomarkerId: "merged-alk-p",
sourceName: "Alk.P",
readingCount: 12,
mergedAt: new Date("2026-05-02T10:00:00.000Z"),
},
{
userId: OWNER,
targetBiomarkerId: bili.id,
sourceBiomarkerId: "merged-t-bili",
sourceName: "T.Bili",
readingCount: 0,
mergedAt: new Date("2026-05-05T10:00:00.000Z"),
},
],
});
return { alp, bili };
}
async function snapshot() {
const prisma = getPrismaClient();
const markers = await prisma.biomarker.findMany({
where: { userId: OWNER },
select: { id: true, name: true },
});
const nameOf = new Map(markers.map((m) => [m.id, m.name]));
const aliases = (
await prisma.biomarkerAlias.findMany({ where: { userId: OWNER } })
)
.map((a) => ({ ...a, marker: nameOf.get(a.biomarkerId) }))
.sort((a, b) => a.nameFolded.localeCompare(b.nameFolded));
const merges = (
await prisma.biomarkerMerge.findMany({ where: { userId: OWNER } })
)
.map((m) => ({ ...m, target: nameOf.get(m.targetBiomarkerId) }))
.sort((a, b) => a.sourceBiomarkerId.localeCompare(b.sourceBiomarkerId));
return { aliases, merges };
}
/** Empty the account, store `data` as a backup row, and restore it. */
async function restore(
data: Awaited<ReturnType<typeof legacyStreamedBlobFrom>> | string,
type: string,
) {
const prisma = getPrismaClient();
await prisma.user.delete({ where: { id: OWNER } });
await createOwner();
const backup = await prisma.dataBackup.create({
data: { userId: OWNER, type, data },
});
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 never,
{ params: Promise.resolve({ id: backup.id }) },
);
const body = await response.json();
expect(response.status, JSON.stringify(body)).toBe(200);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await seedAdmin();
await createOwner();
});
describe("biomarker aliases and merges in a backup", () => {
it("come back column for column from a disaster-recovery file", async () => {
const prisma = getPrismaClient();
await seed();
const before = await snapshot();
expect(before.aliases).toHaveLength(3);
expect(before.merges).toHaveLength(2);
let json = "";
await streamFullBackupJson(
prisma,
OWNER,
(chunk) => {
json += chunk;
},
{ purpose: "disaster-recovery", exportedAt: new Date() },
);
await restore(
await legacyStreamedBlobFrom(async (write) => {
await write(json);
}),
"BIOMARKER_DR_ROUND_TRIP",
);
// Every column, ids and stamps included.
expect(await snapshot()).toEqual(before);
});
it("come back from a portable file with every value the resolver and the 404 read", async () => {
const prisma = getPrismaClient();
const { alp, bili } = await seed();
const before = await snapshot();
const { payload } = await buildFullBackupPayload(prisma, OWNER);
const parsed = parseBackupPayload(JSON.stringify(payload));
expect(
parsed.biomarkers.find((b) => b.name === "Alkaline Phosphatase")?.aliases,
).toHaveLength(2);
expect(parsed.biomarkerMerges).toHaveLength(2);
// Stored the way an uploaded portable file is: encrypted JSON.
await restore(
encrypt(JSON.stringify(parsed)),
"BIOMARKER_PORTABLE_ROUND_TRIP",
);
const after = await snapshot();
const portable = (s: typeof before) => ({
aliases: s.aliases.map((a) => ({
name: a.name,
nameFolded: a.nameFolded,
source: a.source,
marker: a.marker,
userId: a.userId,
})),
merges: s.merges.map((m) => ({
sourceBiomarkerId: m.sourceBiomarkerId,
sourceName: m.sourceName,
readingCount: m.readingCount,
mergedAt: m.mergedAt,
target: m.target,
userId: m.userId,
})),
});
expect(portable(after)).toEqual(portable(before));
// What the values are for: the spellings resolve again, and the merged
// ids still name the marker they went into.
const index = await loadMarkerIndex(prisma, OWNER);
const restoredAlp = index.markers.find((m) => m.name === alp.name)!;
const restoredBili = index.markers.find((m) => m.name === bili.name)!;
expect(resolveInIndex(index, "AP")).toEqual({
marker: restoredAlp,
matchKind: "alias",
});
expect(resolveInIndex(index, "bilirubin ges.")?.marker).toEqual(
restoredBili,
);
expect(await mergedInto(OWNER, "merged-t-bili")).toBe(restoredBili.id);
});
});
+728
View File
@@ -0,0 +1,728 @@
/**
* Merging markers, aliases and catalog shorthand against real SQL (D1223).
*
* "Alk.P" and "Alkaline Phosphatase" written by two labs used to be two
* markers with half a history each. These tests drive the shipped route
* exports and readers: a merge moves every reading and every reader then
* shows one series, a dry run answers the same and writes nothing, readings
* in another unit are converted with what the report printed kept, a pair
* that cannot be converted refuses the whole merge, and a spelling chosen once
* on a scan or a document resolves on its own the next time.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar } 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),
}));
const USER_ID = "user-biomarker-merge";
const OTHER_ID = "user-biomarker-merge-other";
const DAY_MS = 86_400_000;
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
await prisma.user.createMany({
data: [
{ id: USER_ID, username: "bm-merge", email: "bm-merge@example.test" },
{ id: OTHER_ID, username: "bm-other", email: "bm-other@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);
});
async function seedMarker(
name: string,
unit: string,
readings: number,
options: { userId?: string; firstDay?: number; value?: number } = {},
) {
const prisma = getPrismaClient();
const userId = options.userId ?? USER_ID;
const marker = await prisma.biomarker.create({
data: { userId, name, unit },
});
await prisma.labResult.createMany({
data: Array.from({ length: readings }, (_, i) => ({
userId,
biomarkerId: marker.id,
analyte: name,
value: (options.value ?? 80) + (i % 7),
unit,
takenAt: daysAgo((options.firstDay ?? 10) + i * 3),
})),
});
return marker;
}
function json(body: unknown, url: string, method = "POST") {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function merge(
targetId: string,
body: { sourceIds: string[]; dryRun?: boolean },
) {
const { POST } = await import("@/app/api/biomarkers/[id]/merge/route");
return POST(json(body, `http://localhost/api/biomarkers/${targetId}/merge`), {
params: Promise.resolve({ id: targetId }),
});
}
async function tableCounts() {
const prisma = getPrismaClient();
return {
markers: await prisma.biomarker.count(),
readings: await prisma.labResult.count(),
linked: await prisma.labResult.groupBy({
by: ["biomarkerId"],
_count: true,
orderBy: { biomarkerId: "asc" },
}),
aliases: await prisma.biomarkerAlias.count(),
merges: await prisma.biomarkerMerge.count(),
values: await prisma.labResult.findMany({
select: { id: true, value: true, unit: true, sourceValue: true },
orderBy: { id: "asc" },
}),
};
}
describe("POST /api/biomarkers/{id}/merge", () => {
it("merges 30 and 12 readings into one series every reader shows as 42", async () => {
const target = await seedMarker("Alkaline Phosphatase", "U/L", 30);
const source = await seedMarker("Alk.P", "U/L", 12, { firstDay: 400 });
const res = await merge(target.id, { sourceIds: [source.id] });
expect(res.status).toBe(200);
const { data } = await res.json();
expect(data).toMatchObject({
dryRun: false,
movedReadings: 12,
convertedReadings: 0,
aliasesAdded: ["Alk.P"],
sources: [
{
id: source.id,
name: "Alk.P",
unit: "U/L",
readingCount: 12,
conversion: "none",
refusal: null,
},
],
});
expect(data.mergeId).toEqual(expect.any(String));
expect(data.target.aliases).toEqual(["Alk.P"]);
// The overview: one marker, 42 readings.
const { GET: overview } = await import("@/app/api/labs/overview/route");
const ov = await (
await overview(new NextRequest("http://localhost/api/labs/overview"))
).json();
expect(ov.data.markers).toHaveLength(1);
expect(ov.data.markers[0]).toMatchObject({
key: `bm:${target.id}`,
count: 42,
});
// The marker detail's own list.
const { GET: list } = await import("@/app/api/labs/route");
const detail = await (
await list(
new NextRequest(
`http://localhost/api/labs?biomarkerId=${target.id}&limit=100`,
),
)
).json();
expect(detail.data.meta.total).toBe(42);
// The doctor report: one line, counted 42, under the marker's name.
const { loadLabResults } =
await import("@/lib/doctor-report/clinical-records");
const report = await loadLabResults(USER_ID, daysAgo(1000), new Date());
expect(report).toHaveLength(1);
expect(report![0]).toMatchObject({
analyte: "Alkaline Phosphatase",
count: 42,
});
// FHIR: one Observation for the marker, named after it.
const { labObservations } = await import("@/lib/fhir/resources/labs");
let n = 0;
const observations = labObservations(
{ labResults: report } as Parameters<typeof labObservations>[0],
() => `obs-${++n}`,
);
expect(observations).toHaveLength(1);
expect(observations[0].code.text).toBe("Alkaline Phosphatase");
// MCP history: the printed name, the marker's name and the alias all read
// the whole series.
const { getLabHistory } = await import("@/lib/mcp/rich-reads");
for (const analyte of ["Alk.P", "alkaline phosphatase", "ALP"]) {
const total: number[] = [];
let offset = 0;
for (;;) {
const page = await getLabHistory(USER_ID, { analyte, offset });
expect(page.present, analyte).toBe(true);
if (!page.present) break;
total.push(page.readings!.length);
if (!page.nextCursor) break;
offset += page.readings!.length;
}
expect(
total.reduce((a, b) => a + b, 0),
analyte,
).toBe(42);
}
// The source is gone, its old id leads to the target, and the reading's
// printed name survived.
const prisma = getPrismaClient();
expect(
await prisma.biomarker.findUnique({ where: { id: source.id } }),
).toBeNull();
const { GET: getMarker } = await import("@/app/api/biomarkers/[id]/route");
const stale = await getMarker(
new NextRequest(`http://localhost/api/biomarkers/${source.id}`),
{ params: Promise.resolve({ id: source.id }) },
);
expect(stale.status).toBe(404);
const staleBody = await stale.json();
expect(staleBody.meta).toMatchObject({
errorCode: "labs.biomarker.notFound",
mergedInto: target.id,
});
expect(
await prisma.labResult.count({
where: { biomarkerId: target.id, analyte: "Alk.P" },
}),
).toBe(12);
const ledger = await prisma.biomarkerMerge.findMany({});
expect(ledger).toEqual([
expect.objectContaining({
userId: USER_ID,
targetBiomarkerId: target.id,
sourceBiomarkerId: source.id,
sourceName: "Alk.P",
readingCount: 12,
}),
]);
const audit = await prisma.auditLog.findFirstOrThrow({
where: { action: "labs.biomarker.merge" },
});
expect(audit.details).not.toContain("Alk.P");
expect(audit.details).toContain(source.id);
});
it("answers a dry run exactly like the merge and writes nothing", async () => {
const target = await seedMarker("Glucose", "mg/dL", 5, { value: 90 });
const sameUnit = await seedMarker("Blutzucker", "mg/dL", 3);
const molar = await seedMarker("Glukose", "mmol/L", 4, { value: 5 });
const prisma = getPrismaClient();
await prisma.biomarkerAlias.create({
data: {
userId: USER_ID,
biomarkerId: molar.id,
name: "GLU",
nameFolded: "glu",
source: "MANUAL",
},
});
const before = await tableCounts();
const preview = await merge(target.id, {
sourceIds: [sameUnit.id, molar.id],
dryRun: true,
});
expect(preview.status).toBe(200);
const dry = (await preview.json()).data;
expect(await tableCounts()).toEqual(before);
expect(dry.dryRun).toBe(true);
expect(dry.mergeId).toBeNull();
expect(dry.target.aliases).toEqual([]);
const applied = (
await (
await merge(target.id, { sourceIds: [sameUnit.id, molar.id] })
).json()
).data;
const comparable = (r: Record<string, unknown>) => {
const rest = { ...r };
delete rest.dryRun;
delete rest.mergeId;
delete rest.target;
return rest;
};
expect(comparable(applied)).toEqual(comparable(dry));
expect(applied).toMatchObject({
movedReadings: 7,
convertedReadings: 4,
aliasesAdded: ["Blutzucker", "Glukose", "GLU"],
});
expect(applied.sources[1]).toMatchObject({
id: molar.id,
conversion: "converted",
});
expect([...applied.target.aliases].sort()).toEqual(
["Blutzucker", "GLU", "Glukose"].sort(),
);
});
it("converts readings in another unit and keeps what the report printed", async () => {
const target = await seedMarker("Glucose", "mg/dL", 1, { value: 90 });
const molar = await seedMarker("Glukose", "mmol/L", 0);
const prisma = getPrismaClient();
const fresh = await prisma.labResult.create({
data: {
userId: USER_ID,
biomarkerId: molar.id,
analyte: "Glukose",
value: 5.5,
unit: "mmol/L",
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.6,
takenAt: daysAgo(40),
},
});
// A reading converted on its way in already: its original pair stays.
const twice = await prisma.labResult.create({
data: {
userId: USER_ID,
biomarkerId: molar.id,
analyte: "Glukose",
value: 6,
unit: "mmol/L",
sourceValue: 6000,
sourceUnit: "µmol/L",
takenAt: daysAgo(50),
},
});
const res = await merge(target.id, { sourceIds: [molar.id] });
expect(res.status).toBe(200);
const moved = await prisma.labResult.findUniqueOrThrow({
where: { id: fresh.id },
});
expect(moved.biomarkerId).toBe(target.id);
expect(moved.unit).toBe("mg/dL");
expect(moved.value).toBeCloseTo(99.09, 1);
expect(moved.sourceValue).toBe(5.5);
expect(moved.sourceUnit).toBe("mmol/L");
expect(moved.sourceReferenceLow).toBeCloseTo(70.26, 1);
expect(moved.sourceReferenceHigh).toBeCloseTo(100.89, 1);
expect(moved.analyte).toBe("Glukose");
const kept = await prisma.labResult.findUniqueOrThrow({
where: { id: twice.id },
});
expect(kept.value).toBeCloseTo(108.09, 1);
expect(kept.sourceValue).toBe(6000);
expect(kept.sourceUnit).toBe("µmol/L");
});
it("refuses the whole merge when one source cannot be converted", async () => {
const target = await seedMarker("ALT", "U/L", 3);
const convertible = await seedMarker("GPT", "U/L", 2);
const enzymeSi = await seedMarker("ALAT", "µkat/L", 2, { value: 1 });
const before = await tableCounts();
const res = await merge(target.id, {
sourceIds: [convertible.id, enzymeSi.id],
});
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta).toMatchObject({
errorCode: "labs.biomarker.merge.unitMismatch",
sources: [
{
id: enzymeSi.id,
unit: "µkat/L",
targetUnit: "U/L",
reason: expect.any(String),
},
],
});
// Nothing moved, not even the source that would have converted.
expect(await tableCounts()).toEqual(before);
// The preview names the refusal instead of failing.
const preview = await merge(target.id, {
sourceIds: [convertible.id, enzymeSi.id],
dryRun: true,
});
expect(preview.status).toBe(200);
const dry = (await preview.json()).data;
expect(dry.sources[0].conversion).toBe("none");
expect(dry.sources[1]).toMatchObject({
conversion: "refused",
refusal: expect.stringMatching(/unknown_(analyte|unit)/),
});
});
it("names every missing id, refuses a target among the sources, and never reaches another account", async () => {
const target = await seedMarker("TSH", "mIU/L", 1);
const foreign = await seedMarker("TSH basal", "mIU/L", 1, {
userId: OTHER_ID,
});
const missing = await merge(target.id, {
sourceIds: [foreign.id, "nope"],
});
expect(missing.status).toBe(404);
expect((await missing.json()).meta).toMatchObject({
errorCode: "labs.biomarker.notFound",
ids: [foreign.id, "nope"],
});
const self = await merge(target.id, { sourceIds: [target.id] });
expect(self.status).toBe(422);
expect((await self.json()).meta.errorCode).toBe(
"labs.biomarker.merge.invalid",
);
expect(
await getPrismaClient().biomarker.count({ where: { userId: OTHER_ID } }),
).toBe(1);
});
it("follows a marker merged twice to where it ended up", async () => {
const a = await seedMarker("Ferritin (Serum)", "ng/mL", 1);
const b = await seedMarker("Ferr", "ng/mL", 1);
const c = await seedMarker("Ferritin", "ng/mL", 1);
expect((await merge(b.id, { sourceIds: [a.id] })).status).toBe(200);
expect((await merge(c.id, { sourceIds: [b.id] })).status).toBe(200);
const { GET } = await import("@/app/api/biomarkers/[id]/route");
for (const id of [a.id, b.id]) {
const res = await GET(
new NextRequest(`http://localhost/api/biomarkers/${id}`),
{ params: Promise.resolve({ id }) },
);
expect((await res.json()).meta.mergedInto).toBe(c.id);
}
const aliases = await getPrismaClient().biomarkerAlias.findMany({
where: { biomarkerId: c.id },
orderBy: { name: "asc" },
});
expect(aliases.map((alias) => alias.name)).toEqual([
"Ferr",
"Ferritin (Serum)",
]);
expect(
await getPrismaClient().labResult.count({
where: { biomarkerId: c.id },
}),
).toBe(3);
});
});
describe("aliases on the marker", () => {
async function put(id: string, body: unknown) {
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
return PUT(json(body, `http://localhost/api/biomarkers/${id}`, "PUT"), {
params: Promise.resolve({ id }),
});
}
it("replaces the set, keeps where a kept spelling came from, and leaves it alone when omitted", async () => {
const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0);
const prisma = getPrismaClient();
await prisma.biomarkerAlias.create({
data: {
userId: USER_ID,
biomarkerId: alp.id,
name: "Alk.P",
nameFolded: "alk.p",
source: "IMPORT_CHOICE",
},
});
const res = await put(alp.id, {
aliases: ["Alk.P", "AP", "alkaline phosphatase"],
});
expect(res.status).toBe(200);
expect((await res.json()).data.aliases).toEqual(["Alk.P", "AP"]);
const rows = await prisma.biomarkerAlias.findMany({
where: { biomarkerId: alp.id },
orderBy: { nameFolded: "asc" },
});
expect(rows.map((r) => [r.name, r.source])).toEqual([
["Alk.P", "IMPORT_CHOICE"],
["AP", "MANUAL"],
]);
// An older client's PUT without the field keeps them.
const untouched = await put(alp.id, { lowerBound: 40 });
expect((await untouched.json()).data.aliases).toEqual(["Alk.P", "AP"]);
const cleared = await put(alp.id, { aliases: [] });
expect((await cleared.json()).data.aliases).toEqual([]);
});
it("refuses a spelling another marker already answers to", async () => {
const alt = await seedMarker("ALT", "U/L", 0);
const ast = await seedMarker("AST", "U/L", 0);
await getPrismaClient().biomarkerAlias.create({
data: {
userId: USER_ID,
biomarkerId: ast.id,
name: "GOT",
nameFolded: "got",
source: "MANUAL",
},
});
for (const [alias, owner] of [
["ast", ast.id],
["got", ast.id],
] as const) {
const res = await put(alt.id, { aliases: ["GPT", alias] });
expect(res.status).toBe(409);
expect((await res.json()).meta).toMatchObject({
errorCode: "labs.biomarker.alias.taken",
alias,
biomarkerId: owner,
});
}
expect(
await getPrismaClient().biomarkerAlias.count({
where: { biomarkerId: alt.id },
}),
).toBe(0);
});
});
describe("a spelling resolves on its own once chosen", () => {
it("maps T.Bili to the chosen marker on the second scan and on manual entry", async () => {
const bili = await seedMarker("Bilirubin (Total)", "mg/dL", 2, {
value: 1,
});
const { annotateRow } = await import("@/lib/labs/ocr-extract");
const scanned = {
analyte: "Bilirubin ges.",
value: 0.8,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: "2026-06-01",
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
};
// First scan: a name nothing resolves; the review offers the marker.
const first = await annotateRow(USER_ID, scanned, null, "UTC");
expect(first.biomarkerId).toBeNull();
expect(first.biomarkerMatch).toBe("new");
expect(first.suggestions[0]).toMatchObject({
biomarkerId: bili.id,
matchKind: "similar",
});
// The catalog's shorthand resolves without a choice.
const shorthand = await annotateRow(
USER_ID,
{ ...scanned, analyte: "T.Bili" },
null,
"UTC",
);
expect(shorthand).toMatchObject({
biomarkerId: bili.id,
matchKind: "catalog",
biomarkerMatch: "existing",
suggestions: [],
});
// The person picks the marker for "Bilirubin ges." and commits.
const { POST: commit } = await import("@/app/api/labs/ocr/commit/route");
const committed = await commit(
json(
{
rows: [
{
analyte: "Bilirubin ges.",
value: 0.8,
unit: "mg/dL",
takenAt: "2026-06-01T08:00:00.000Z",
biomarkerId: bili.id,
},
],
},
"http://localhost/api/labs/ocr/commit",
),
);
expect(committed.status).toBe(200);
const prisma = getPrismaClient();
expect(
await prisma.labResult.count({ where: { biomarkerId: bili.id } }),
).toBe(3);
expect(
await prisma.biomarkerAlias.findFirst({
where: { biomarkerId: bili.id },
select: { name: true, source: true },
}),
).toEqual({ name: "Bilirubin ges.", source: "IMPORT_CHOICE" });
// The second scan of the same lab matches without asking.
const second = await annotateRow(
USER_ID,
{ ...scanned, takenAt: "2026-07-01" },
null,
"UTC",
);
expect(second).toMatchObject({
biomarkerId: bili.id,
matchKind: "alias",
suggestions: [],
});
// Manual entry with the alias lands on the marker, no new marker.
const { POST: create } = await import("@/app/api/labs/route");
const typed = await create(
json(
{
analyte: "bilirubin ges.",
value: 0.9,
unit: "mg/dL",
takenAt: new Date().toISOString(),
},
"http://localhost/api/labs",
),
);
expect(typed.status).toBe(201);
expect((await typed.json()).data.biomarkerId).toBe(bili.id);
expect(await prisma.biomarker.count({ where: { userId: USER_ID } })).toBe(
1,
);
});
it("skips a row naming a marker the record does not have", async () => {
const foreign = await seedMarker("Ferritin", "ng/mL", 0, {
userId: OTHER_ID,
});
const { POST: commit } = await import("@/app/api/labs/ocr/commit/route");
const res = await commit(
json(
{
rows: [
{
analyte: "Ferritin",
value: 50,
unit: "ng/mL",
takenAt: "2026-06-01T08:00:00.000Z",
biomarkerId: foreign.id,
},
],
},
"http://localhost/api/labs/ocr/commit",
),
);
expect(res.status).toBe(200);
expect((await res.json()).data.skipped).toEqual([
{ analyte: "Ferritin", reason: "unknown_biomarker" },
]);
expect(await getPrismaClient().labResult.count()).toBe(0);
});
it("files a confirmed document value under the chosen marker and remembers the label", async () => {
const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0);
const prisma = getPrismaClient();
const { encryptFactData, encryptFactProvenance, serialiseFact } =
await import("@/lib/documents/store");
const document = await prisma.inboundDocument.create({
data: {
userId: USER_ID,
kind: "LAB_RESULT",
filename: "report.pdf",
mimeType: "application/pdf",
byteSize: 10,
contentEncrypted: new Uint8Array([1, 2, 3]),
},
});
const fact = await prisma.extractedFact.create({
data: {
userId: USER_ID,
documentId: document.id,
factType: "OBSERVATION",
status: "PENDING",
confidence: 0.95,
needsReview: false,
dataEncrypted: encryptFactData({
label: "Alk. Phosph.",
code: null,
codeSystem: null,
value: 70,
valueText: null,
unit: "U/L",
referenceLow: null,
referenceHigh: null,
referenceText: null,
effectiveDate: "2026-05-02",
}),
provenanceEncrypted: encryptFactProvenance({
sourceText: "Alk. Phosph. 70 U/L",
anchored: true,
sourceOffset: 0,
page: 1,
confidence: 0.95,
}),
},
});
const { loadMarkerIndex } = await import("@/lib/labs/biomarker-resolve");
const dto = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID));
expect(dto.biomarkerId).toBeNull();
expect(dto.suggestions?.[0]?.biomarkerId).toBe(alp.id);
const { commitApprovedFact } = await import("@/lib/documents/commit");
const ref = await commitApprovedFact(USER_ID, fact, {
biomarkerId: alp.id,
});
const reading = await prisma.labResult.findUniqueOrThrow({
where: { id: ref.recordId },
});
expect(reading.biomarkerId).toBe(alp.id);
expect((await prisma.biomarkerAlias.findFirstOrThrow({})).name).toBe(
"Alk. Phosph.",
);
const again = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID));
expect(again).toMatchObject({ biomarkerId: alp.id, matchKind: "alias" });
});
});
@@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
describe("the complete MANAGE handler matrix", () => {
it("starts with a non-empty, exact handler inventory", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89);
});
});
@@ -1124,6 +1124,51 @@ manageContract("DELETE /api/biomarkers/[id]", {
})) === null,
});
manageContract("POST /api/biomarkers/[id]/merge", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) => entry.route === "/api/biomarkers/[id]/merge",
)!,
prepare: async (userId) => {
const prisma = getPrismaClient();
const target = await prisma.biomarker.create({
data: { userId, name: "Alkaline Phosphatase", unit: "U/L" },
});
const source = await prisma.biomarker.create({
data: { userId, name: "Alk.P", unit: "U/L" },
});
await prisma.labResult.create({
data: {
userId,
biomarkerId: source.id,
analyte: "Alk.P",
value: 81,
unit: "U/L",
takenAt: new Date("2026-05-01T08:00:00.000Z"),
},
});
return { id: target.id, sourceId: source.id };
},
act: async (row) => {
const { POST } = await import("@/app/api/biomarkers/[id]/merge/route");
return call(
POST as Handler,
"POST",
`/api/biomarkers/${row.id}/merge`,
{ sourceIds: [row.sourceId] },
{ id: row.id },
);
},
ok: 200,
auditAction: "labs.biomarker.merge",
applied: async (row) =>
(await getPrismaClient().biomarker.findUnique({
where: { id: row.sourceId },
})) === null &&
(await getPrismaClient().labResult.count({
where: { biomarkerId: row.id },
})) === 1,
});
manageContract("POST /api/allergies", {
contract: ADMITTED_MUTATING_HANDLERS.find(
(entry) => entry.route === "/api/allergies" && entry.action === "POST",
@@ -2878,13 +2923,13 @@ describe("the conditions the admissions were granted on", () => {
describe("the complete MANAGE handler matrix", () => {
it("registers one strict actor-and-effect driver for every admission", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(STRICT_DRIVER_KEYS.size).toBe(88);
expect(STRICT_DRIVER_KEYS.size).toBe(89);
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
});
it("executes every registered driver through its owned effect and actor audit", () => {
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
expect(REAL_EFFECT_KEYS.size).toBe(88);
expect(REAL_EFFECT_KEYS.size).toBe(89);
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
});
});
@@ -66,6 +66,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
const VISIT_AT = "2019-04-03T09:30:00.000Z";
const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z");
const RESULT_COUNT = 1_200;
/** What the hook adds to every request: the UI locale and the person's zone. */
const CTX = { locale: "en", timeZone: "Europe/Berlin" } as const;
async function seedOwner() {
const prisma = getPrismaClient();
@@ -161,6 +163,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot,
term: "",
cursor: null,
...CTX,
});
expect(first.results.some((row) => row.id === ferritinId)).toBe(true);
// Nothing after the window's split is on it.
@@ -182,6 +185,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot,
term: "",
cursor,
...CTX,
});
for (const row of page.results) {
expect(seen.has(row.id)).toBe(false);
@@ -207,6 +211,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot: null,
term,
cursor: null,
...CTX,
});
expect(page.results.map((row) => row.id)).toEqual([ferritinId]);
}
@@ -240,6 +245,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot: pickerPivot(VISIT_AT),
term: "",
cursor: null,
...CTX,
}),
);
expect(list.past).toHaveLength(1);
@@ -276,7 +282,13 @@ describe("the condition picker's window and search", () => {
const pivot = pickerPivot(VISIT_AT);
const around = await get<Array<{ id: string; onsetAt: string }>>(
listEpisodes as never,
episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }),
episodePickerUrl({
direction: "older",
pivot,
term: "",
cursor: null,
...CTX,
}),
);
expect(around[0]!.id).toBe(old.id);
@@ -287,6 +299,7 @@ describe("the condition picker's window and search", () => {
pivot: null,
term: "tendin",
cursor: null,
...CTX,
}),
);
expect(found.map((row) => row.id)).toEqual([old.id]);
@@ -297,7 +310,13 @@ describe("the condition picker's window and search", () => {
for (let guard = 0; guard < 5; guard++) {
const page: Array<{ id: string }> = await get(
listEpisodes as never,
episodePickerUrl({ direction: "newer", pivot, term: "", cursor }),
episodePickerUrl({
direction: "newer",
pivot,
term: "",
cursor,
...CTX,
}),
);
for (const row of page) {
expect(ids.has(row.id)).toBe(false);
@@ -0,0 +1,399 @@
/**
* A picker search for a year or a month returns exactly that period.
*
* Typing "2019" into a visit's link picker used to send `q=2019`, which the
* list routes match against names and titles, so filtering by a year found
* nothing. A term that names a period is now sent as the route's own date
* window. This drives every family's list route with the requests the
* pickers build (`picker-requests.ts`), against three years of records, and
* walks every page: the answer must be every record in the period and
* nothing else, including the records far beyond the first page.
*
* The expected sets are derived independently of the builders: an instant
* belongs to the period its day in the person's zone falls in
* (`userDayKey`), a document to the period its stored calendar date is in.
* The seeds put records just either side of each boundary in that zone.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { NextRequest } from "next/server";
import { GET as listEncounters } from "@/app/api/encounters/route";
import { GET as listDocuments } from "@/app/api/documents/inbound/route";
import { GET as listEpisodes } from "@/app/api/illness/episodes/route";
import { GET as listLabs } from "@/app/api/labs/route";
import {
documentPickerUrl,
encounterPickerUrl,
episodePickerUrl,
labPickerUrl,
} from "@/components/links/picker-requests";
import {
PICKER_PAGE_SIZE,
nextOffset,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { userDayKey } from "@/lib/tz/format";
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),
}));
const OWNER_ID = "period-search-owner";
const DAY_MS = 24 * 60 * 60 * 1000;
const TZ = "Europe/Berlin";
/** Every day from 2018-01-01 to 2020-12-31, as UTC-midnight instants. */
const DAYS = Array.from(
{ length: 1096 },
(_, i) => new Date(Date.UTC(2018, 0, 1) + i * DAY_MS),
);
/**
* Instants just either side of the period boundaries in Berlin: 23:30 and
* 00:30 local around New Year 2019 (UTC+1), the start of March 2019 (UTC+1)
* and the end of March 2019 (UTC+2, after the DST change), and New Year 2020.
*/
const EDGES = [
"2018-12-31T22:30:00.000Z",
"2018-12-31T23:30:00.000Z",
"2019-02-28T22:30:00.000Z",
"2019-02-28T23:30:00.000Z",
"2019-03-31T21:30:00.000Z",
"2019-03-31T22:30:00.000Z",
"2019-12-31T22:30:00.000Z",
"2019-12-31T23:30:00.000Z",
].map((iso) => new Date(iso));
const search = (term: string, locale: string): PickerPageRequest => ({
direction: "search",
pivot: null,
term,
cursor: null,
locale,
timeZone: TZ,
});
async function seedOwner() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: OWNER_ID,
username: "period-search",
email: "period-search@example.test",
timezone: TZ,
modulePreferencesJson: {
labs: true,
illness: true,
inboundDocuments: true,
},
},
});
const session = await prisma.session.create({
data: {
userId: OWNER_ID,
expiresAt: new Date(Date.now() + 7 * DAY_MS),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
async function get<T>(
handler: (request: NextRequest) => Promise<Response>,
url: string,
): Promise<T> {
const response = await handler(new NextRequest(`http://localhost${url}`));
expect(response.status, url).toBe(200);
return ((await response.json()) as { data: T }).data;
}
/** Ids of the instants whose day in the person's zone starts with `prefix`. */
function expectedIds(
rows: ReadonlyArray<{ id: string; at: Date }>,
prefix: string,
): Set<string> {
return new Set(
rows
.filter((row) => userDayKey(row.at, TZ).startsWith(prefix))
.map((r) => r.id),
);
}
/** Walk an offset-paged search to its end, newest first, without repeats. */
async function walkOffsets<T extends { id: string }>(
fetchPage: (cursor: string | null) => Promise<T[]>,
at: (row: T) => string,
): Promise<{ ids: Set<string>; pages: number }> {
const ids = new Set<string>();
let cursor: string | null = null;
let pages = 0;
let previous = Number.POSITIVE_INFINITY;
for (let guard = 0; guard < 30; guard++) {
const rows = await fetchPage(cursor);
pages += 1;
for (const row of rows) {
expect(ids.has(row.id), row.id).toBe(false);
ids.add(row.id);
const when = new Date(at(row)).getTime();
expect(when).toBeLessThanOrEqual(previous);
previous = when;
}
cursor = nextOffset(cursor, rows.length);
if (cursor === null) break;
}
return { ids, pages };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedOwner();
});
describe("lab results: a period search walks exactly the period", () => {
async function seed() {
const prisma = getPrismaClient();
// A date-only reading is stored at noon UTC; the edges carry a time.
const instants = [
...DAYS.map((day) => new Date(day.getTime() + 12 * 60 * 60 * 1000)),
...EDGES,
];
await prisma.labResult.createMany({
data: instants.map((takenAt, i) => ({
userId: OWNER_ID,
panel: "Metabolic",
analyte: `Glucose ${i}`,
value: 90,
unit: "mg/dL",
takenAt,
})),
});
const rows = await prisma.labResult.findMany({
where: { userId: OWNER_ID },
select: { id: true, takenAt: true },
});
return rows.map((row) => ({ id: row.id, at: row.takenAt }));
}
const walk = (request: PickerPageRequest) =>
walkOffsets(
async (cursor) => {
const page = await get<{
results: Array<{ id: string; takenAt: string }>;
}>(listLabs as never, labPickerUrl({ ...request, cursor }));
return page.results;
},
(row) => row.takenAt,
);
it("a year", async () => {
const rows = await seed();
const expected = expectedIds(rows, "2019-");
expect(expected.size).toBe(365 + 6);
const { ids, pages } = await walk(search("2019", "en"));
expect(pages).toBeGreaterThan(1);
expect(ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
expect(ids).toEqual(expected);
});
it.each([
["de", "März 2019"],
["de", "03.2019"],
["en", "Mar 2019"],
["pl", "marzec 2019"],
["ko", "2019년 3월"],
])("a month (%s %j)", async (locale, term) => {
const rows = await seed();
const expected = expectedIds(rows, "2019-03-");
expect(expected.size).toBe(31 + 2);
const { ids } = await walk(search(term, locale));
expect(ids).toEqual(expected);
});
it("a plain term is still a text search", async () => {
await seed();
const { ids } = await walk(search("Glucose 7", "en"));
// "Glucose 7", "Glucose 70" … "Glucose 799", "Glucose 7xx": names only.
expect(ids.size).toBeGreaterThan(0);
expect(ids.size).toBeLessThan(200);
});
});
describe("conditions: a period search walks exactly the period", () => {
it("a year and a month", async () => {
const prisma = getPrismaClient();
const onsets = [
...DAYS.filter((_, i) => i % 2 === 0).map(
(day) => new Date(day.getTime() + 5 * 60 * 60 * 1000),
),
...EDGES,
];
await prisma.illnessEpisode.createMany({
data: onsets.map((onsetAt, i) => ({
userId: OWNER_ID,
label: `Cold ${i}`,
type: "INFECTION" as const,
onsetAt,
resolvedAt: new Date(onsetAt.getTime() + 3 * DAY_MS),
})),
});
const rows = (
await prisma.illnessEpisode.findMany({
where: { userId: OWNER_ID },
select: { id: true, onsetAt: true },
})
).map((row) => ({ id: row.id, at: row.onsetAt }));
const walk = (request: PickerPageRequest) =>
walkOffsets(
async (cursor) => {
const page = await get<Array<{ id: string; onsetAt: string }>>(
listEpisodes as never,
episodePickerUrl({ ...request, cursor }),
);
return page;
},
(row) => row.onsetAt,
);
const year = await walk(search("2019", "fr"));
expect(year.ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
expect(year.ids).toEqual(expectedIds(rows, "2019-"));
const month = await walk(search("mars 2019", "fr"));
expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
});
});
describe("visits: the route without text search still searches a period", () => {
it("a year and a month", async () => {
const prisma = getPrismaClient();
const instants = [
...DAYS.map((day) => new Date(day.getTime() + 9 * 60 * 60 * 1000)),
...EDGES,
];
await prisma.encounter.createMany({
data: instants.map((occurredAt) => ({
userId: OWNER_ID,
occurredAt,
status: "DONE" as const,
kind: "ROUTINE" as const,
})),
});
const rows = (
await prisma.encounter.findMany({
where: { userId: OWNER_ID },
select: { id: true, occurredAt: true },
})
).map((row) => ({ id: row.id, at: row.occurredAt }));
const walk = (request: PickerPageRequest) =>
walkOffsets(
async (cursor) => {
const list = await get<{
upcoming: Array<{ id: string; occurredAt: string }>;
past: Array<{ id: string; occurredAt: string }>;
}>(
listEncounters as never,
encounterPickerUrl({ ...request, cursor }),
);
expect(list.upcoming).toEqual([]);
return list.past;
},
(row) => row.occurredAt,
);
const year = await walk(search("2019", "it"));
expect(year.pages).toBeGreaterThan(1);
expect(year.ids).toEqual(expectedIds(rows, "2019-"));
const month = await walk(search("marzo 2019", "it"));
expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
});
});
describe("documents: the day-granular window is the period's calendar days", () => {
it("a year and a month, across keyset pages", async () => {
const prisma = getPrismaClient();
await prisma.inboundDocument.createMany({
data: DAYS.map((day, i) => ({
userId: OWNER_ID,
kind: "OTHER" as const,
filename: `doc-${i}.pdf`,
mimeType: "application/pdf",
byteSize: 4,
status: "STORED" as const,
contentEncrypted: Buffer.from("test"),
contentCodec: "v1",
documentDate: day,
})),
});
const rows = await prisma.inboundDocument.findMany({
where: { userId: OWNER_ID },
select: { id: true, documentDate: true },
});
const expected = (prefix: string) =>
new Set(
rows
.filter((row) => row.documentDate!.toISOString().startsWith(prefix))
.map((row) => row.id),
);
async function walk(request: PickerPageRequest) {
const ids = new Set<string>();
let cursor: string | null = null;
let pages = 0;
for (let guard = 0; guard < 30; guard++) {
const page: {
documents: Array<{ id: string }>;
nextCursor: string | null;
} = await get(
listDocuments as never,
documentPickerUrl({ ...request, cursor }),
);
pages += 1;
for (const doc of page.documents) {
expect(ids.has(doc.id)).toBe(false);
ids.add(doc.id);
}
cursor = page.nextCursor;
if (cursor === null) break;
}
return { ids, pages };
}
const year = await walk(search("2019", "es"));
expect(year.pages).toBeGreaterThan(1);
expect(year.ids).toEqual(expected("2019-"));
expect(year.ids.size).toBe(365);
const month = await walk(search("marzo de 2019", "es"));
expect(month.ids).toEqual(expected("2019-03-"));
expect(month.ids.size).toBe(31);
});
});