mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|