mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
1000 lines
35 KiB
TypeScript
1000 lines
35 KiB
TypeScript
/**
|
|
* Document vault — end-to-end contract against a real Postgres.
|
|
*
|
|
* Covers the Wave-1 hardenings the unit mocks cannot pin:
|
|
*
|
|
* 1. Upload → binary2-encrypted row + plaintext sha256; /original
|
|
* round-trips the exact bytes with the class-correct headers.
|
|
* 2. Policy error contract: 413 fileTooLarge / quotaExceeded (with limits
|
|
* in meta), 415 unsupportedType.
|
|
* 3. sha256 dedupe: a same-user re-upload returns 200 + meta.duplicate and
|
|
* the existing row id; the partial unique index holds at the DB level.
|
|
* 4. Idempotency-Key replay returns the cached response without a second
|
|
* row.
|
|
* 5. Quota accounting is tombstone-inclusive (a soft-deleted row still
|
|
* counts against usage until purged).
|
|
* 6. episodeIds pre-link + PATCH replace-set + list filters (kind[],
|
|
* episodeId, year) — and the list never leaks the blob.
|
|
* 7. Restore: clears the tombstone; 409 after purge; 409 on a live
|
|
* duplicate of the same bytes.
|
|
* 8. Bulk endpoint per-id semantics.
|
|
* 9. Usage endpoint reflects overrides + tombstone-inclusive usage.
|
|
* 10. The purge job hard-deletes only past-grace tombstones.
|
|
* 11. The procedure filter: the usage read lists the record's linked
|
|
* procedures (and nothing else, and nobody else's), and the list's
|
|
* `encounterId` filter narrows, combines and pages.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
|
|
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);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
const PNG_1X1 = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
const PDF_SMALL = Buffer.from("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n");
|
|
|
|
async function seedVaultUser(username: string) {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
role: "USER",
|
|
timezone: "Europe/Berlin",
|
|
// Opt-in module: the vault is dark until the user flips it on.
|
|
modulePreferencesJson: { inboundDocuments: true },
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return user;
|
|
}
|
|
|
|
function uploadRequest(
|
|
bytes: Buffer,
|
|
filename: string,
|
|
fields: Record<string, string | string[]> = {},
|
|
headers: Record<string, string> = {},
|
|
): Request {
|
|
const payload = new Uint8Array(bytes.byteLength);
|
|
payload.set(bytes);
|
|
const formData = new FormData();
|
|
formData.append("file", new Blob([payload]), filename);
|
|
for (const [k, v] of Object.entries(fields)) {
|
|
for (const item of Array.isArray(v) ? v : [v]) formData.append(k, item);
|
|
}
|
|
return new Request("http://localhost/api/documents/inbound", {
|
|
method: "POST",
|
|
body: formData,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
type RouteCtx = { params: Promise<{ id: string }> };
|
|
const ctx = (id: string): RouteCtx => ({ params: Promise.resolve({ id }) });
|
|
|
|
async function routes() {
|
|
const list = await import("@/app/api/documents/inbound/route");
|
|
const byId = await import("@/app/api/documents/inbound/[id]/route");
|
|
const original =
|
|
await import("@/app/api/documents/inbound/[id]/original/route");
|
|
const restore =
|
|
await import("@/app/api/documents/inbound/[id]/restore/route");
|
|
const bulk = await import("@/app/api/documents/inbound/bulk/route");
|
|
const usage = await import("@/app/api/documents/inbound/usage/route");
|
|
return {
|
|
post: list.POST as unknown as (r: Request) => Promise<Response>,
|
|
get: list.GET as unknown as (r: Request) => Promise<Response>,
|
|
patch: byId.PATCH as unknown as (
|
|
r: Request,
|
|
c: RouteCtx,
|
|
) => Promise<Response>,
|
|
del: byId.DELETE as unknown as (
|
|
r: Request,
|
|
c: RouteCtx,
|
|
) => Promise<Response>,
|
|
getOriginal: original.GET as unknown as (
|
|
r: Request,
|
|
c: RouteCtx,
|
|
) => Promise<Response>,
|
|
postRestore: restore.POST as unknown as (
|
|
r: Request,
|
|
c: RouteCtx,
|
|
) => Promise<Response>,
|
|
postBulk: bulk.POST as unknown as (r: Request) => Promise<Response>,
|
|
getUsage: usage.GET as unknown as (r: Request) => Promise<Response>,
|
|
};
|
|
}
|
|
|
|
function jsonRequest(url: string, method: string, body: unknown): Request {
|
|
return new Request(url, {
|
|
method,
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
describe("document vault — upload + serve round-trip", () => {
|
|
it("stores binary2-encrypted with sha256 and serves the exact bytes back", async () => {
|
|
await seedVaultUser("vault-roundtrip");
|
|
const { post, getOriginal } = await routes();
|
|
|
|
const res = await post(
|
|
uploadRequest(PNG_1X1, "scan.png", { title: "Röntgen Knie" }),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const { data } = await res.json();
|
|
expect(data.servingClass).toBe("inline");
|
|
|
|
// At rest: binary2 codec, plaintext sha256, ciphertext ≠ plaintext.
|
|
const row = await getPrismaClient().inboundDocument.findFirstOrThrow();
|
|
expect(row.contentCodec).toBe("binary2");
|
|
expect(row.contentSha256).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(Buffer.from(row.contentEncrypted).equals(PNG_1X1)).toBe(false);
|
|
|
|
// Round-trip through the serve route: exact bytes + Class A posture.
|
|
const orig = await getOriginal(
|
|
new Request(`http://localhost/api/documents/inbound/${data.id}/original`),
|
|
ctx(data.id),
|
|
);
|
|
expect(orig.status).toBe(200);
|
|
expect(orig.headers.get("Content-Type")).toBe("image/png");
|
|
expect(orig.headers.get("Content-Disposition")).toContain("inline");
|
|
expect(orig.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
// The response CSP is owned by the proxy's serve-route carve-out
|
|
// (`default-src 'none'; frame-ancestors 'self'` — middleware headers win
|
|
// over route headers), so the route itself sets none. Pinned by
|
|
// `src/__tests__/proxy-document-serve-framing.test.ts`.
|
|
expect(orig.headers.get("Content-Security-Policy")).toBeNull();
|
|
const served = Buffer.from(await orig.arrayBuffer());
|
|
expect(served.equals(PNG_1X1)).toBe(true);
|
|
});
|
|
|
|
it("serves a Class B upload attachment-only as octet-stream", async () => {
|
|
await seedVaultUser("vault-classb");
|
|
const { post, getOriginal } = await routes();
|
|
|
|
const res = await post(
|
|
uploadRequest(Buffer.from("Blutdruck 120/80 morgens\n"), "werte.csv"),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const { data } = await res.json();
|
|
expect(data.mimeType).toBe("text/csv");
|
|
expect(data.servingClass).toBe("attachment");
|
|
|
|
const orig = await getOriginal(
|
|
new Request(`http://localhost/api/documents/inbound/${data.id}/original`),
|
|
ctx(data.id),
|
|
);
|
|
expect(orig.status).toBe(200);
|
|
expect(orig.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
expect(
|
|
(orig.headers.get("Content-Disposition") ?? "").startsWith("attachment;"),
|
|
).toBe(true);
|
|
expect(orig.headers.get("Content-Security-Policy")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("document vault — policy error contract", () => {
|
|
it("415s an unsupported type with meta.reason", async () => {
|
|
await seedVaultUser("vault-415");
|
|
const { post } = await routes();
|
|
const res = await post(
|
|
uploadRequest(Buffer.from([0x01, 0x02, 0x03, 0x04]), "blob.bin"),
|
|
);
|
|
expect(res.status).toBe(415);
|
|
const body = await res.json();
|
|
expect(body.meta.reason).toBe("unsupportedType");
|
|
});
|
|
|
|
it("413s over the admin-set per-file cap with the limit in meta", async () => {
|
|
await seedVaultUser("vault-cap");
|
|
await getPrismaClient().appSettings.upsert({
|
|
where: { id: "singleton" },
|
|
update: { documentMaxFileBytes: 20 },
|
|
create: { id: "singleton", documentMaxFileBytes: 20 },
|
|
});
|
|
const { post } = await routes();
|
|
const res = await post(uploadRequest(PNG_1X1, "big.png"));
|
|
expect(res.status).toBe(413);
|
|
const body = await res.json();
|
|
expect(body.meta.reason).toBe("fileTooLarge");
|
|
expect(body.meta.maxFileBytes).toBe(20);
|
|
expect(await getPrismaClient().inboundDocument.count()).toBe(0);
|
|
});
|
|
|
|
it("413s past the quota — and tombstoned rows still count against it", async () => {
|
|
const user = await seedVaultUser("vault-quota");
|
|
// Quota fits exactly one PNG (70 B) but not two.
|
|
await getPrismaClient().user.update({
|
|
where: { id: user.id },
|
|
data: { documentQuotaBytes: BigInt(PNG_1X1.byteLength + 10) },
|
|
});
|
|
const { post, del } = await routes();
|
|
|
|
const first = await post(uploadRequest(PNG_1X1, "one.png"));
|
|
expect(first.status).toBe(201);
|
|
const firstId = (await first.json()).data.id as string;
|
|
|
|
// Soft-delete the first upload: the tombstone KEEPS holding quota.
|
|
const delRes = await del(
|
|
new Request(`http://localhost/api/documents/inbound/${firstId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
ctx(firstId),
|
|
);
|
|
expect(delRes.status).toBe(200);
|
|
|
|
const second = await post(uploadRequest(PDF_SMALL, "two.pdf"));
|
|
expect(second.status).toBe(413);
|
|
const body = await second.json();
|
|
expect(body.meta.reason).toBe("quotaExceeded");
|
|
expect(body.meta.usedBytes).toBe(PNG_1X1.byteLength);
|
|
expect(body.meta.quotaBytes).toBe(PNG_1X1.byteLength + 10);
|
|
});
|
|
|
|
it("cannot be overshot by concurrent uploads racing the quota gate", async () => {
|
|
// The production admission gate allows two same-user uploads in flight,
|
|
// so seed one file, then race two DISTINCT files for the one remaining
|
|
// quota slot. Without the per-user advisory lock both contenders read the
|
|
// same pre-insert SUM and land, overshooting the quota.
|
|
const user = await seedVaultUser("vault-quota-race");
|
|
const files = Array.from({ length: 4 }, (_, i) => {
|
|
const filler = Buffer.alloc(2048, i + 1);
|
|
return Buffer.concat([Buffer.from("%PDF-1.7\n"), filler]);
|
|
});
|
|
const size = files[0].byteLength;
|
|
await getPrismaClient().user.update({
|
|
where: { id: user.id },
|
|
data: { documentQuotaBytes: BigInt(size * 2 + 10) },
|
|
});
|
|
const { post } = await routes();
|
|
|
|
const first = await post(uploadRequest(files[0], "race-0.pdf"));
|
|
const contenders = await Promise.all(
|
|
files
|
|
.slice(1, 3)
|
|
.map((bytes, i) => post(uploadRequest(bytes, `race-${i + 1}.pdf`))),
|
|
);
|
|
const last = await post(uploadRequest(files[3], "race-3.pdf"));
|
|
const results = [first, ...contenders, last];
|
|
const statuses = results.map((r) => r.status).sort();
|
|
expect(statuses).toEqual([201, 201, 413, 413]);
|
|
const bodies = await Promise.all(results.map((result) => result.json()));
|
|
expect(
|
|
bodies
|
|
.filter((_, index) => results[index].status === 413)
|
|
.map((body) => body.meta.reason),
|
|
).toEqual(["quotaExceeded", "quotaExceeded"]);
|
|
|
|
const rows = await getPrismaClient().inboundDocument.findMany({
|
|
where: { userId: user.id },
|
|
select: { byteSize: true },
|
|
});
|
|
const stored = rows.reduce((sum, r) => sum + r.byteSize, 0);
|
|
expect(stored).toBeLessThanOrEqual(size * 2 + 10);
|
|
});
|
|
});
|
|
|
|
describe("document vault — dedupe + idempotency", () => {
|
|
it("returns the existing live row + meta.duplicate on a same-bytes re-upload", async () => {
|
|
await seedVaultUser("vault-dedupe");
|
|
const { post } = await routes();
|
|
|
|
const first = await post(uploadRequest(PNG_1X1, "scan.png"));
|
|
expect(first.status).toBe(201);
|
|
const firstId = (await first.json()).data.id as string;
|
|
|
|
const second = await post(uploadRequest(PNG_1X1, "scan-copy.png"));
|
|
expect(second.status).toBe(200);
|
|
const body = await second.json();
|
|
expect(body.meta.duplicate).toBe(true);
|
|
expect(body.data.id).toBe(firstId);
|
|
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
|
|
});
|
|
|
|
it("collapses two racing identical uploads onto one row", async () => {
|
|
// Same bytes fired twice in parallel: the fast-path dedupe check misses
|
|
// (neither row exists yet), so the partial unique index must close the
|
|
// race — one 201, one 200 + meta.duplicate pointing at the winner.
|
|
await seedVaultUser("vault-dedupe-race");
|
|
const { post } = await routes();
|
|
|
|
const [a, b] = await Promise.all([
|
|
post(uploadRequest(PNG_1X1, "race-a.png")),
|
|
post(uploadRequest(PNG_1X1, "race-b.png")),
|
|
]);
|
|
const statuses = [a.status, b.status].sort();
|
|
expect(statuses).toEqual([200, 201]);
|
|
const bodies = await Promise.all([a.json(), b.json()]);
|
|
const winner = bodies.find((x) => x.meta?.duplicate !== true);
|
|
const loser = bodies.find((x) => x.meta?.duplicate === true);
|
|
expect(winner).toBeDefined();
|
|
expect(loser).toBeDefined();
|
|
expect(loser!.data.id).toBe(winner!.data.id);
|
|
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
|
|
});
|
|
|
|
it("dedupes per user, not globally", async () => {
|
|
await seedVaultUser("vault-user-a");
|
|
const { post } = await routes();
|
|
expect((await post(uploadRequest(PNG_1X1, "a.png"))).status).toBe(201);
|
|
|
|
cookieJar.clear();
|
|
await seedVaultUser("vault-user-b");
|
|
expect((await post(uploadRequest(PNG_1X1, "b.png"))).status).toBe(201);
|
|
expect(await getPrismaClient().inboundDocument.count()).toBe(2);
|
|
});
|
|
|
|
it("replays an Idempotency-Key without creating a second row", async () => {
|
|
await seedVaultUser("vault-idem");
|
|
const { post } = await routes();
|
|
const key = "vault-upload-abc123";
|
|
|
|
const first = await post(
|
|
uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }),
|
|
);
|
|
expect(first.status).toBe(201);
|
|
const firstBody = await first.json();
|
|
|
|
const replay = await post(
|
|
uploadRequest(PDF_SMALL, "brief.pdf", {}, { "Idempotency-Key": key }),
|
|
);
|
|
expect(replay.status).toBe(201);
|
|
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
|
|
const replayBody = await replay.json();
|
|
expect(replayBody.data.id).toBe(firstBody.data.id);
|
|
expect(await getPrismaClient().inboundDocument.count()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("document vault — links + list filters", () => {
|
|
it("pre-links at upload, replace-sets via PATCH, filters by kind/episode/year", async () => {
|
|
const user = await seedVaultUser("vault-links");
|
|
const prisma = getPrismaClient();
|
|
const knie = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: user.id,
|
|
label: "Knie",
|
|
type: "OTHER",
|
|
onsetAt: new Date("2025-09-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
const ruecken = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: user.id,
|
|
label: "Rücken",
|
|
type: "OTHER",
|
|
onsetAt: new Date("2025-01-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
const { post, get, patch } = await routes();
|
|
|
|
const created = await post(
|
|
uploadRequest(PDF_SMALL, "mrt-knie.pdf", {
|
|
title: "MRT Knie",
|
|
kind: "IMAGING",
|
|
documentDate: "2025-10-04",
|
|
episodeIds: [knie.id],
|
|
}),
|
|
);
|
|
expect(created.status).toBe(201);
|
|
const doc = (await created.json()).data;
|
|
expect(doc.conditionLinks).toEqual([{ episodeId: knie.id, name: "Knie" }]);
|
|
|
|
// Second (unlinked, different year) document for filter contrast.
|
|
const other = await post(
|
|
uploadRequest(PNG_1X1, "impfpass.png", {
|
|
kind: "VACCINATION",
|
|
documentDate: "2024-03-01",
|
|
}),
|
|
);
|
|
expect(other.status).toBe(201);
|
|
|
|
// kind[] facet (OR inside), episode filter, year filter — AND across.
|
|
const filtered = await get(
|
|
new Request(
|
|
`http://localhost/api/documents/inbound?kind=IMAGING,LAB_RESULT&episodeId=${knie.id}&year=2025`,
|
|
),
|
|
);
|
|
expect(filtered.status).toBe(200);
|
|
const page = (await filtered.json()).data;
|
|
expect(page.documents).toHaveLength(1);
|
|
expect(page.documents[0].title).toBe("MRT Knie");
|
|
// The list DTO never carries blob material.
|
|
expect("contentEncrypted" in page.documents[0]).toBe(false);
|
|
|
|
// Year filter alone excludes the 2025 document.
|
|
const y2024 = await get(
|
|
new Request("http://localhost/api/documents/inbound?year=2024"),
|
|
);
|
|
expect((await y2024.json()).data.documents).toHaveLength(1);
|
|
expect(
|
|
(
|
|
await (
|
|
await get(new Request("http://localhost/api/documents/inbound"))
|
|
).json()
|
|
).data.documents,
|
|
).toHaveLength(2);
|
|
|
|
// PATCH replace-set: Knie → Rücken (relink), then [] (unlink-all).
|
|
const relinked = await patch(
|
|
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
|
|
episodeIds: [ruecken.id],
|
|
}),
|
|
ctx(doc.id),
|
|
);
|
|
expect(relinked.status).toBe(200);
|
|
expect((await relinked.json()).data.conditionLinks).toEqual([
|
|
{ episodeId: ruecken.id, name: "Rücken" },
|
|
]);
|
|
|
|
const unlinked = await patch(
|
|
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
|
|
episodeIds: [],
|
|
}),
|
|
ctx(doc.id),
|
|
);
|
|
expect((await unlinked.json()).data.conditionLinks).toEqual([]);
|
|
|
|
// Foreign episode id → 404-shaped refusal.
|
|
cookieJar.clear();
|
|
const stranger = await seedVaultUser("vault-links-stranger");
|
|
void stranger;
|
|
const foreign = await patch(
|
|
jsonRequest(`http://localhost/api/documents/inbound/${doc.id}`, "PATCH", {
|
|
episodeIds: [knie.id],
|
|
}),
|
|
ctx(doc.id),
|
|
);
|
|
expect(foreign.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("document vault — delete / restore / purge", () => {
|
|
it("restore clears the tombstone; purge-then-restore 409s; duplicate restore 409s", async () => {
|
|
await seedVaultUser("vault-restore");
|
|
const prisma = getPrismaClient();
|
|
const { post, del, postRestore } = await routes();
|
|
|
|
const created = await post(uploadRequest(PNG_1X1, "scan.png"));
|
|
const docId = (await created.json()).data.id as string;
|
|
|
|
await del(
|
|
new Request(`http://localhost/api/documents/inbound/${docId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
ctx(docId),
|
|
);
|
|
expect(
|
|
(await prisma.inboundDocument.findFirstOrThrow()).deletedAt,
|
|
).not.toBeNull();
|
|
|
|
// Restore inside the grace window succeeds and the row is live again.
|
|
const restored = await postRestore(
|
|
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
|
|
method: "POST",
|
|
}),
|
|
ctx(docId),
|
|
);
|
|
expect(restored.status).toBe(200);
|
|
const row = await prisma.inboundDocument.findFirstOrThrow();
|
|
expect(row.deletedAt).toBeNull();
|
|
expect(row.status).toBe("STORED");
|
|
|
|
// Tombstone again, upload the same bytes fresh (allowed — the partial
|
|
// index only guards LIVE rows), then the tombstone's restore conflicts.
|
|
await del(
|
|
new Request(`http://localhost/api/documents/inbound/${docId}`, {
|
|
method: "DELETE",
|
|
}),
|
|
ctx(docId),
|
|
);
|
|
const reupload = await post(uploadRequest(PNG_1X1, "scan-again.png"));
|
|
expect(reupload.status).toBe(201);
|
|
|
|
const conflicted = await postRestore(
|
|
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
|
|
method: "POST",
|
|
}),
|
|
ctx(docId),
|
|
);
|
|
expect(conflicted.status).toBe(409);
|
|
expect((await conflicted.json()).meta.reason).toBe("duplicateExists");
|
|
|
|
// Hard-purge the tombstone → restore now answers 409 (undo window over).
|
|
await prisma.inboundDocument.delete({ where: { id: docId } });
|
|
const gone = await postRestore(
|
|
new Request(`http://localhost/api/documents/inbound/${docId}/restore`, {
|
|
method: "POST",
|
|
}),
|
|
ctx(docId),
|
|
);
|
|
expect(gone.status).toBe(409);
|
|
expect((await gone.json()).meta.reason).toBe("purged");
|
|
});
|
|
|
|
it("the purge job hard-deletes only past-grace tombstones", async () => {
|
|
const user = await seedVaultUser("vault-purge");
|
|
const prisma = getPrismaClient();
|
|
const { post, del } = await routes();
|
|
const { purgeExpiredDocumentTombstones } =
|
|
await import("@/lib/jobs/document-purge");
|
|
|
|
const a = (await (await post(uploadRequest(PNG_1X1, "old.png"))).json())
|
|
.data.id as string;
|
|
const b = (await (await post(uploadRequest(PDF_SMALL, "fresh.pdf"))).json())
|
|
.data.id as string;
|
|
const c = (
|
|
await (
|
|
await post(uploadRequest(Buffer.from("text file\n"), "live.txt"))
|
|
).json()
|
|
).data.id as string;
|
|
|
|
// a: tombstoned 31 days ago (past grace). b: tombstoned now. c: live.
|
|
for (const id of [a, b]) {
|
|
await del(
|
|
new Request(`http://localhost/api/documents/inbound/${id}`, {
|
|
method: "DELETE",
|
|
}),
|
|
ctx(id),
|
|
);
|
|
}
|
|
await prisma.inboundDocument.update({
|
|
where: { id: a },
|
|
data: { deletedAt: new Date(Date.now() - 31 * 86_400_000) },
|
|
});
|
|
|
|
const purged = await purgeExpiredDocumentTombstones(prisma);
|
|
expect(purged).toBe(1);
|
|
|
|
const remaining = await prisma.inboundDocument.findMany({
|
|
where: { userId: user.id },
|
|
select: { id: true },
|
|
orderBy: { id: "asc" },
|
|
});
|
|
expect(remaining.map((r) => r.id).sort()).toEqual([b, c].sort());
|
|
});
|
|
});
|
|
|
|
describe("document vault — bulk + usage", () => {
|
|
it("bulk applies per-id with partial-failure results", async () => {
|
|
const user = await seedVaultUser("vault-bulk");
|
|
const prisma = getPrismaClient();
|
|
const episode = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: user.id,
|
|
label: "Knie",
|
|
type: "OTHER",
|
|
onsetAt: new Date("2025-09-01T00:00:00.000Z"),
|
|
},
|
|
});
|
|
const { post, postBulk } = await routes();
|
|
|
|
const id1 = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json())
|
|
.data.id as string;
|
|
const id2 = (await (await post(uploadRequest(PDF_SMALL, "b.pdf"))).json())
|
|
.data.id as string;
|
|
|
|
// setKind over one real + one unknown id → per-id ok/notFound.
|
|
const setKind = await postBulk(
|
|
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
|
|
ids: [id1, "does-not-exist"],
|
|
action: "setKind",
|
|
kind: "LAB_RESULT",
|
|
}),
|
|
);
|
|
expect(setKind.status).toBe(200);
|
|
const { results } = (await setKind.json()).data;
|
|
expect(results).toEqual([
|
|
{ id: id1, ok: true, error: null },
|
|
{ id: "does-not-exist", ok: false, error: "notFound" },
|
|
]);
|
|
expect(
|
|
(await prisma.inboundDocument.findUniqueOrThrow({ where: { id: id1 } }))
|
|
.kind,
|
|
).toBe("LAB_RESULT");
|
|
|
|
// linkEpisode + delete + restore round-trip through bulk.
|
|
const link = await postBulk(
|
|
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
|
|
ids: [id1, id2],
|
|
action: "linkEpisode",
|
|
episodeId: episode.id,
|
|
}),
|
|
);
|
|
expect(
|
|
(await link.json()).data.results.every((r: { ok: boolean }) => r.ok),
|
|
).toBe(true);
|
|
expect(await prisma.documentConditionLink.count()).toBe(2);
|
|
|
|
const bulkDelete = await postBulk(
|
|
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
|
|
ids: [id1, id2],
|
|
action: "delete",
|
|
}),
|
|
);
|
|
expect(bulkDelete.status).toBe(200);
|
|
expect(
|
|
await prisma.inboundDocument.count({ where: { deletedAt: null } }),
|
|
).toBe(0);
|
|
|
|
const bulkRestore = await postBulk(
|
|
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
|
|
ids: [id1, id2],
|
|
action: "restore",
|
|
}),
|
|
);
|
|
expect(bulkRestore.status).toBe(200);
|
|
expect(
|
|
await prisma.inboundDocument.count({ where: { deletedAt: null } }),
|
|
).toBe(2);
|
|
|
|
// Cap: 101 ids → 422 (schema bound).
|
|
const over = await postBulk(
|
|
jsonRequest("http://localhost/api/documents/inbound/bulk", "POST", {
|
|
ids: Array.from({ length: 101 }, (_, i) => `id-${i}`),
|
|
action: "delete",
|
|
}),
|
|
);
|
|
expect(over.status).toBe(422);
|
|
});
|
|
|
|
it("usage reflects the override + tombstone-inclusive accounting", async () => {
|
|
const user = await seedVaultUser("vault-usage");
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { documentQuotaBytes: BigInt(123_456_789) },
|
|
});
|
|
const { post, del, getUsage } = await routes();
|
|
|
|
const id = (await (await post(uploadRequest(PNG_1X1, "a.png"))).json()).data
|
|
.id as string;
|
|
await del(
|
|
new Request(`http://localhost/api/documents/inbound/${id}`, {
|
|
method: "DELETE",
|
|
}),
|
|
ctx(id),
|
|
);
|
|
|
|
// Condition chips source: an episode linked to a LIVE document surfaces
|
|
// in `linkedEpisodes`; an episode whose only link sits on the tombstoned
|
|
// row does not.
|
|
const liveEpisode = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: user.id,
|
|
label: "Knie",
|
|
type: "INJURY",
|
|
onsetAt: new Date("2026-01-01T00:00:00Z"),
|
|
},
|
|
});
|
|
const deadEpisode = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: user.id,
|
|
label: "Erkältung",
|
|
type: "INFECTION",
|
|
onsetAt: new Date("2026-02-01T00:00:00Z"),
|
|
},
|
|
});
|
|
await post(
|
|
uploadRequest(PDF_SMALL, "knie.pdf", { episodeIds: [liveEpisode.id] }),
|
|
);
|
|
await prisma.documentConditionLink.create({
|
|
data: { documentId: id, episodeId: deadEpisode.id, userId: user.id },
|
|
});
|
|
|
|
const res = await getUsage(
|
|
new Request("http://localhost/api/documents/inbound/usage"),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const { data } = await res.json();
|
|
// Tombstoned bytes still count until the purge reclaims them.
|
|
expect(data.usedBytes).toBe(PNG_1X1.byteLength + PDF_SMALL.byteLength);
|
|
expect(data.quotaBytes).toBe(123_456_789);
|
|
expect(data.maxFileBytes).toBe(26_214_400);
|
|
expect(data.acceptedExtensions).toContain(".pdf");
|
|
expect(data.acceptedExtensions).not.toContain(".heic");
|
|
expect(data.linkedEpisodes).toEqual([
|
|
{ episodeId: liveEpisode.id, name: "Knie" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("document vault — the sick note kind", () => {
|
|
it("is accepted at upload and on edit, and filters like every other kind", async () => {
|
|
await seedVaultUser("vault-sick-note");
|
|
const { post, get, patch } = await routes();
|
|
|
|
const uploaded = await post(
|
|
uploadRequest(PDF_SMALL, "au-bescheinigung.pdf", { kind: "SICK_NOTE" }),
|
|
);
|
|
expect(uploaded.status).toBe(201);
|
|
expect((await uploaded.json()).data.kind).toBe("SICK_NOTE");
|
|
|
|
const other = await post(uploadRequest(PNG_1X1, "scan.png"));
|
|
expect(other.status).toBe(201);
|
|
const otherDoc = (await other.json()).data;
|
|
expect(otherDoc.kind).toBe("OTHER");
|
|
|
|
const edited = await patch(
|
|
jsonRequest(
|
|
`http://localhost/api/documents/inbound/${otherDoc.id}`,
|
|
"PATCH",
|
|
{ kind: "SICK_NOTE" },
|
|
),
|
|
ctx(otherDoc.id),
|
|
);
|
|
expect(edited.status).toBe(200);
|
|
expect((await edited.json()).data.kind).toBe("SICK_NOTE");
|
|
|
|
const sickNotes = await get(
|
|
new Request("http://localhost/api/documents/inbound?kind=SICK_NOTE"),
|
|
);
|
|
expect(sickNotes.status).toBe(200);
|
|
expect((await sickNotes.json()).data.documents).toHaveLength(2);
|
|
const others = await get(
|
|
new Request("http://localhost/api/documents/inbound?kind=OTHER"),
|
|
);
|
|
expect((await others.json()).data.documents).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("document vault — the procedure filter", () => {
|
|
it("lists linked procedures in usage and filters, combines and pages by encounterId", async () => {
|
|
const user = await seedVaultUser("vault-procedures");
|
|
const prisma = getPrismaClient();
|
|
const { post, get, del, getUsage } = await routes();
|
|
|
|
const clinic = await prisma.practitioner.create({
|
|
data: { userId: user.id, name: "Day clinic" },
|
|
});
|
|
const arthroscopy = await prisma.encounter.create({
|
|
data: {
|
|
userId: user.id,
|
|
kind: "PROCEDURE",
|
|
status: "DONE",
|
|
occurredAt: new Date("2025-10-02T08:00:00Z"),
|
|
reasonEncrypted: encryptToBytes("Knee arthroscopy"),
|
|
bodySiteEncrypted: encryptToBytes("Knee"),
|
|
laterality: "LEFT",
|
|
},
|
|
});
|
|
// No reason: the visits page heads it with the practice.
|
|
const unnamed = await prisma.encounter.create({
|
|
data: {
|
|
userId: user.id,
|
|
kind: "PROCEDURE",
|
|
status: "DONE",
|
|
occurredAt: new Date("2024-05-10T08:00:00Z"),
|
|
practitionerId: clinic.id,
|
|
},
|
|
});
|
|
// Not a procedure: linked, but never a procedure choice.
|
|
const routine = await prisma.encounter.create({
|
|
data: {
|
|
userId: user.id,
|
|
kind: "ROUTINE",
|
|
status: "DONE",
|
|
occurredAt: new Date("2025-03-01T08:00:00Z"),
|
|
},
|
|
});
|
|
// A deleted procedure and one whose only document is tombstoned drop out.
|
|
const deletedProcedure = await prisma.encounter.create({
|
|
data: {
|
|
userId: user.id,
|
|
kind: "PROCEDURE",
|
|
status: "DONE",
|
|
occurredAt: new Date("2023-01-10T08:00:00Z"),
|
|
deletedAt: new Date(),
|
|
},
|
|
});
|
|
const orphaned = await prisma.encounter.create({
|
|
data: {
|
|
userId: user.id,
|
|
kind: "PROCEDURE",
|
|
status: "DONE",
|
|
occurredAt: new Date("2022-01-10T08:00:00Z"),
|
|
},
|
|
});
|
|
// Linked to nothing: not a choice either.
|
|
await prisma.encounter.create({
|
|
data: {
|
|
userId: user.id,
|
|
kind: "PROCEDURE",
|
|
status: "DONE",
|
|
occurredAt: new Date("2021-01-10T08:00:00Z"),
|
|
},
|
|
});
|
|
const knee = await prisma.illnessEpisode.create({
|
|
data: {
|
|
userId: user.id,
|
|
label: "Knee",
|
|
type: "INJURY",
|
|
onsetAt: new Date("2025-09-01T00:00:00Z"),
|
|
},
|
|
});
|
|
|
|
const upload = async (
|
|
bytes: Buffer,
|
|
filename: string,
|
|
fields: Record<string, string | string[]>,
|
|
) => {
|
|
const res = await post(uploadRequest(bytes, filename, fields));
|
|
expect(res.status).toBe(201);
|
|
return (await res.json()).data.id as string;
|
|
};
|
|
const imaging = await upload(PDF_SMALL, "mri.pdf", {
|
|
kind: "IMAGING",
|
|
documentDate: "2025-10-01",
|
|
encounterIds: [arthroscopy.id],
|
|
episodeIds: [knee.id],
|
|
});
|
|
const lab = await upload(PNG_1X1, "lab.png", {
|
|
kind: "LAB_RESULT",
|
|
documentDate: "2024-11-03",
|
|
encounterIds: [arthroscopy.id],
|
|
});
|
|
await upload(Buffer.from("%PDF-1.7\n%unnamed\n%%EOF\n"), "op.pdf", {
|
|
kind: "OTHER",
|
|
documentDate: "2024-05-10",
|
|
encounterIds: [unnamed.id],
|
|
});
|
|
await upload(Buffer.from("%PDF-1.7\n%routine\n%%EOF\n"), "r.pdf", {
|
|
documentDate: "2025-03-01",
|
|
encounterIds: [routine.id],
|
|
});
|
|
await upload(Buffer.from("%PDF-1.7\n%loose\n%%EOF\n"), "loose.pdf", {
|
|
documentDate: "2025-06-01",
|
|
});
|
|
// The deleted procedure's link is written below the API, which refuses a
|
|
// tombstoned visit; the orphan's only document is deleted after linking.
|
|
const forDeleted = await upload(
|
|
Buffer.from("%PDF-1.7\n%deleted\n%%EOF\n"),
|
|
"d.pdf",
|
|
{},
|
|
);
|
|
await prisma.encounterDocumentLink.create({
|
|
data: {
|
|
encounterId: deletedProcedure.id,
|
|
documentId: forDeleted,
|
|
userId: user.id,
|
|
},
|
|
});
|
|
const forOrphan = await upload(
|
|
Buffer.from("%PDF-1.7\n%orphan\n%%EOF\n"),
|
|
"o.pdf",
|
|
{ encounterIds: [orphaned.id] },
|
|
);
|
|
await del(
|
|
new Request(`http://localhost/api/documents/inbound/${forOrphan}`, {
|
|
method: "DELETE",
|
|
}),
|
|
ctx(forOrphan),
|
|
);
|
|
|
|
// Usage: exactly the two live, linked procedures, newest first, with
|
|
// the reason and site decrypted and the practice for the unnamed one.
|
|
const usage = (
|
|
await (
|
|
await getUsage(
|
|
new Request("http://localhost/api/documents/inbound/usage"),
|
|
)
|
|
).json()
|
|
).data;
|
|
expect(usage.linkedProcedures).toEqual([
|
|
{
|
|
encounterId: arthroscopy.id,
|
|
occurredAt: "2025-10-02T08:00:00.000Z",
|
|
reason: "Knee arthroscopy",
|
|
bodySite: "Knee",
|
|
laterality: "LEFT",
|
|
practitionerName: null,
|
|
},
|
|
{
|
|
encounterId: unnamed.id,
|
|
occurredAt: "2024-05-10T08:00:00.000Z",
|
|
reason: null,
|
|
bodySite: null,
|
|
laterality: null,
|
|
practitionerName: "Day clinic",
|
|
},
|
|
]);
|
|
|
|
const list = async (query: string) => {
|
|
const res = await get(
|
|
new Request(`http://localhost/api/documents/inbound?${query}`),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()).data as {
|
|
documents: { id: string }[];
|
|
nextCursor: string | null;
|
|
};
|
|
};
|
|
const ids = (page: { documents: { id: string }[] }) =>
|
|
page.documents.map((d) => d.id).sort();
|
|
|
|
// The filter alone, then AND-combined with kind, year and condition.
|
|
expect(ids(await list(`encounterId=${arthroscopy.id}`))).toEqual(
|
|
[imaging, lab].sort(),
|
|
);
|
|
expect(
|
|
ids(await list(`encounterId=${arthroscopy.id}&kind=IMAGING`)),
|
|
).toEqual([imaging]);
|
|
expect(ids(await list(`encounterId=${arthroscopy.id}&year=2024`))).toEqual([
|
|
lab,
|
|
]);
|
|
expect(
|
|
ids(await list(`encounterId=${arthroscopy.id}&episodeId=${knee.id}`)),
|
|
).toEqual([imaging]);
|
|
expect(
|
|
ids(await list(`encounterId=${unnamed.id}&episodeId=${knee.id}`)),
|
|
).toEqual([]);
|
|
|
|
// Keyset pagination stays inside the filter: newest filing date first.
|
|
const first = await list(`encounterId=${arthroscopy.id}&limit=1`);
|
|
expect(first.documents.map((d) => d.id)).toEqual([imaging]);
|
|
expect(first.nextCursor).toBe(imaging);
|
|
const second = await list(
|
|
`encounterId=${arthroscopy.id}&limit=1&cursor=${first.nextCursor}`,
|
|
);
|
|
expect(second.documents.map((d) => d.id)).toEqual([lab]);
|
|
expect(second.nextCursor).toBeNull();
|
|
|
|
// A tombstoned document never answers the filter.
|
|
expect(ids(await list(`encounterId=${orphaned.id}`))).toEqual([]);
|
|
|
|
// Another account: the owner's procedure id matches nothing of theirs,
|
|
// and their usage read lists no procedure of the owner's.
|
|
cookieJar.clear();
|
|
await seedVaultUser("vault-procedures-stranger");
|
|
await upload(Buffer.from("%PDF-1.7\n%stranger\n%%EOF\n"), "s.pdf", {});
|
|
const foreign = await list(`encounterId=${arthroscopy.id}`);
|
|
expect(foreign.documents).toEqual([]);
|
|
expect(foreign.nextCursor).toBeNull();
|
|
const strangerUsage = (
|
|
await (
|
|
await getUsage(
|
|
new Request("http://localhost/api/documents/inbound/usage"),
|
|
)
|
|
).json()
|
|
).data;
|
|
expect(strangerUsage.linkedProcedures).toEqual([]);
|
|
});
|
|
});
|