Files
HealthLog/src/lib/illness/__tests__/day-log-write.test.ts
T
Marc-André Bombeck d7a698950c fix(illness): harden the episode + day-log write paths
Wrap episode-create in withIdempotency so an iOS retry can't mint duplicates.
Enforce resolvedAt >= onsetAt on create, edit, and the resolve verb (a partial
edit is checked against the stored onset). Run the day-log upsert, its symptom-
link replacement, and the read-back in one transaction and return the full DTO,
dropping the route's second round-trip. Restrict symptom severity to 1-3 (a
link's presence already means present, so 0 is not a distinct state). Return
the standard { deleted: true } envelope from DELETE instead of a bodyless 204.
2026-06-16 23:18:06 +02:00

182 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.18.1 — illness day-log upsert helper.
*
* Asserts the insert-vs-update split, the partial-merge contract (an
* omitted field never nulls a stored value; an explicit null clears), and
* the symptom-link reconciliation (resolve catalog keys, drop unknowns,
* carry the 0–3 severity).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => {
const client = {
illnessSymptom: { findMany: vi.fn() },
illnessSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
illnessDayLog: {
findUnique: vi.fn(),
upsert: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
// The upsert helper runs in one interactive transaction; the mock just
// hands the same client through as `tx`.
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(client)),
};
return client;
});
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
decryptFromBytes: (b: Uint8Array) =>
Buffer.from(b).toString("utf8").replace(/^enc:/, ""),
}));
import {
resolveIllnessSymptomIds,
replaceIllnessSymptomLinks,
upsertIllnessDayLog,
} from "@/lib/illness/day-log-write";
import type { IllnessDayLogInput } from "@/lib/validations/illness";
beforeEach(() => {
vi.clearAllMocks();
db.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) =>
fn(db),
);
db.illnessDayLog.upsert.mockResolvedValue({ id: "dl-1" });
db.illnessDayLog.findUniqueOrThrow.mockResolvedValue({
id: "dl-1",
episodeId: "ep1",
date: "2026-06-16",
functionalImpact: null,
feverC: null,
noteEncrypted: null,
updatedAt: new Date("2026-06-16T12:00:00Z"),
symptomLinks: [],
});
});
describe("resolveIllnessSymptomIds", () => {
it("queries the active catalog and drops unknown keys", async () => {
db.illnessSymptom.findMany.mockResolvedValue([
{ id: "is_cough", key: "cough" },
]);
const out = await resolveIllnessSymptomIds(["cough", "not_a_symptom"]);
expect(out).toEqual([{ key: "cough", id: "is_cough" }]);
const where = db.illnessSymptom.findMany.mock.calls[0][0].where;
expect(where.isActive).toBe(true);
expect(where.key.in).toEqual(["cough", "not_a_symptom"]);
});
it("short-circuits on an empty key set", async () => {
const out = await resolveIllnessSymptomIds([]);
expect(out).toEqual([]);
expect(db.illnessSymptom.findMany).not.toHaveBeenCalled();
});
});
describe("replaceIllnessSymptomLinks", () => {
it("clears then writes the links with their severity", async () => {
db.illnessSymptom.findMany.mockResolvedValue([
{ id: "is_cough", key: "cough" },
{ id: "is_fatigue", key: "fatigue" },
]);
await replaceIllnessSymptomLinks("dl-1", [
{ key: "cough", severity: 3 },
{ key: "fatigue" },
]);
expect(db.illnessSymptomLink.deleteMany).toHaveBeenCalledWith({
where: { dayLogId: "dl-1" },
});
expect(db.illnessSymptomLink.createMany).toHaveBeenCalledWith({
data: [
{ dayLogId: "dl-1", symptomId: "is_cough", severity: 3 },
{ dayLogId: "dl-1", symptomId: "is_fatigue", severity: null },
],
skipDuplicates: true,
});
});
it("does not call createMany when nothing resolves", async () => {
db.illnessSymptom.findMany.mockResolvedValue([]);
await replaceIllnessSymptomLinks("dl-1", [{ key: "unknown" }]);
expect(db.illnessSymptomLink.deleteMany).toHaveBeenCalled();
expect(db.illnessSymptomLink.createMany).not.toHaveBeenCalled();
});
});
describe("upsertIllnessDayLog", () => {
const base: IllnessDayLogInput = {
date: "2026-06-16",
functionalImpact: 2,
feverC: 38.4,
};
it("reports inserted (existed=false) on a fresh row", async () => {
db.illnessDayLog.findUnique.mockResolvedValue(null);
const result = await upsertIllnessDayLog("u1", "ep1", base, "Europe/Berlin");
expect(result.existed).toBe(false);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.where).toEqual({
episodeId_date: { episodeId: "ep1", date: "2026-06-16" },
});
expect(args.create.functionalImpact).toBe(2);
expect(args.create.feverC).toBe(38.4);
});
it("reports updated (existed=true) on a re-post", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 1,
feverC: null,
noteEncrypted: null,
});
const result = await upsertIllnessDayLog("u1", "ep1", base, null);
expect(result.existed).toBe(true);
});
it("partial-merges an omitted field (keeps the stored value)", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 3,
feverC: 39,
noteEncrypted: null,
});
// Only fever supplied — functionalImpact must keep the stored 3.
await upsertIllnessDayLog("u1", "ep1", { date: "2026-06-16", feverC: 37 }, null);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.update.functionalImpact).toBe(3);
expect(args.update.feverC).toBe(37);
});
it("clears a field on an explicit null", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 3,
feverC: 39,
noteEncrypted: null,
});
await upsertIllnessDayLog(
"u1",
"ep1",
{ date: "2026-06-16", feverC: null },
null,
);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.update.feverC).toBeNull();
});
it("encrypts a supplied note and skips link replace when symptoms omitted", async () => {
db.illnessDayLog.findUnique.mockResolvedValue(null);
await upsertIllnessDayLog(
"u1",
"ep1",
{ date: "2026-06-16", note: "rough night" },
null,
);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.create.noteEncrypted).toBeInstanceOf(Uint8Array);
expect(db.illnessSymptomLink.deleteMany).not.toHaveBeenCalled();
});
});