From 44b4e6cf06349d1c9a289de6cc9b4037139d4be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:38:36 +0200 Subject: [PATCH] test(coach): cover reminder capture, sweep, and action endpoints Add unit coverage for the closed when-grammar and `---REMEMBER---` capture (propose-free active write, recall-only note, malformed drop, per-user cap), the closed action-card allowlist (off-list and bad-interval rejection, no auto-apply), the daily sweep (due flip, plan-review mint, undecryptable skip), and the routes (owner-scoped no-IDOR 404, field-by-field build, the off-allowlist 422, and the module gate). --- .../coach/reminders/__tests__/route.test.ts | 312 ++++++++++++++++++ .../suggested-actions/__tests__/route.test.ts | 169 ++++++++++ src/lib/ai/coach/__tests__/reminders.test.ts | 177 ++++++++++ .../ai/coach/__tests__/suggest-action.test.ts | 94 ++++++ .../__tests__/coach-reminder-sweep.test.ts | 106 ++++++ 5 files changed, 858 insertions(+) create mode 100644 src/app/api/coach/reminders/__tests__/route.test.ts create mode 100644 src/app/api/coach/suggested-actions/__tests__/route.test.ts create mode 100644 src/lib/ai/coach/__tests__/reminders.test.ts create mode 100644 src/lib/ai/coach/__tests__/suggest-action.test.ts create mode 100644 src/lib/jobs/__tests__/coach-reminder-sweep.test.ts diff --git a/src/app/api/coach/reminders/__tests__/route.test.ts b/src/app/api/coach/reminders/__tests__/route.test.ts new file mode 100644 index 000000000..5f3fd26dd --- /dev/null +++ b/src/app/api/coach/reminders/__tests__/route.test.ts @@ -0,0 +1,312 @@ +/** + * v1.22 (B2/B6) — `/api/coach/reminders` (+ `/[id]`) routes. + * + * Covers: + * - GET lists the caller's reminders decrypted, scoped to the user; an + * undecryptable row is skipped rather than 500ing the list. + * - POST creates a reminder field-by-field (source manual, active), resolving + * the closed `when` grammar server-side; an invalid `when` 422s. + * - PATCH confirms proposed → active field-by-field; a 0-count match 404s + * (no-IDOR: the cross-user / unknown id never leaks existence). + * - DELETE soft-deletes the caller's reminder; a cross-user id is a 0-count + * no-op returning `{ deleted: false }`. + * - the coach module gate runs on every verb. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + coachReminder: { + findMany: vi.fn(), + count: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 39, + resetAt: Date.now() + 60_000, + })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn((buf: Uint8Array) => { + const tag = Buffer.from(buf).toString("utf8"); + if (tag === "__bad__") throw new Error("unknown key id"); + return `dec:${tag}`; + }), + encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(`enc:${s}`))), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { PATCH, DELETE as DELETE_ONE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "t", + role: "USER" as const, + displayName: null, + }, +}; + +function bytes(tag: string): Uint8Array { + return new Uint8Array(Buffer.from(tag, "utf8")); +} + +const callGet = (url = "http://localhost/api/coach/reminders") => + (GET as unknown as (req: Request) => Promise)(new Request(url)); + +function jsonReq(body: unknown, method = "POST"): NextRequest { + return new NextRequest("http://localhost/api/coach/reminders", { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} +const callPost = (body: unknown) => + (POST as unknown as (req: Request) => Promise)(jsonReq(body)); + +const callPatch = (body: unknown, id = "r1") => + ( + PATCH as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new NextRequest("http://localhost/api/coach/reminders/x", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); + +const callDelete = (id = "r1") => + ( + DELETE_ONE as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new NextRequest("http://localhost/api/coach/reminders/x", { + method: "DELETE", + }), + { params: Promise.resolve({ id }) }, + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("GET /api/coach/reminders", () => { + it("lists the caller's reminders decrypted, scoped to the user", async () => { + vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([ + { + id: "r1", + metric: "SLEEP", + noteEncrypted: bytes("revisit sleep"), + triggerKind: "date", + dueAt: new Date("2026-07-11T09:00:00Z"), + contextCue: null, + status: "active", + source: "sentinel", + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-02T00:00:00Z"), + }, + ] as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { reminders: Array> }; + }; + expect(body.data.reminders[0]).toMatchObject({ + id: "r1", + note: "dec:revisit sleep", + status: "active", + }); + const where = vi.mocked(prisma.coachReminder.findMany).mock.calls[0]?.[0] + ?.where as { userId: string; deletedAt: null }; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + }); + + it("skips an undecryptable row rather than 500ing", async () => { + vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([ + { + id: "r1", + metric: null, + noteEncrypted: bytes("__bad__"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "r2", + metric: null, + noteEncrypted: bytes("ok"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + }, + ] as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { reminders: Array<{ id: string }> }; + }; + expect(body.data.reminders).toHaveLength(1); + expect(body.data.reminders[0]?.id).toBe("r2"); + }); +}); + +describe("POST /api/coach/reminders", () => { + it("creates a reminder field-by-field, resolving the when grammar", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + vi.mocked(prisma.coachReminder.create).mockResolvedValue({ + id: "r9", + metric: "SLEEP", + noteEncrypted: bytes("x"), + triggerKind: "date", + dueAt: new Date(), + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const res = await callPost({ + note: "revisit sleep", + when: "+7d", + metric: "SLEEP", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + status: string; + source: string; + }; + expect(data.userId).toBe("user-1"); + expect(data.status).toBe("active"); + expect(data.source).toBe("manual"); + }); + + it("422s on an invalid when token", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + const res = await callPost({ note: "x", when: "someday" }); + expect(res.status).toBe(422); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + }); + + it("422s on an unknown key (strict)", async () => { + const res = await callPost({ note: "x", userId: "evil" }); + expect(res.status).toBe(422); + }); +}); + +describe("PATCH /api/coach/reminders/[id]", () => { + it("confirms proposed → active field-by-field, owner-scoped", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.coachReminder.findFirst).mockResolvedValue({ + id: "r1", + metric: null, + noteEncrypted: bytes("x"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "sentinel", + createdAt: new Date(), + updatedAt: new Date(), + } as never); + const res = await callPatch({ status: "active" }); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.coachReminder.updateMany).mock + .calls[0]?.[0] as { + where: { id: string; userId: string; deletedAt: null }; + data: Record; + }; + expect(arg.where.userId).toBe("user-1"); + expect(Object.keys(arg.data)).toEqual(["status"]); + }); + + it("404s on a 0-count (cross-user / unknown) match — no IDOR leak", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await callPatch({ status: "done" }, "someone-elses"); + expect(res.status).toBe(404); + expect(prisma.coachReminder.findFirst).not.toHaveBeenCalled(); + }); + + it("422s on an empty body", async () => { + const res = await callPatch({}); + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /api/coach/reminders/[id]", () => { + it("soft-deletes the caller's reminder", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await callDelete(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } }; + expect(body.data.deleted).toBe(true); + }); + + it("returns deleted:false for a cross-user id (0-count no-op)", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await callDelete("someone-elses"); + const body = (await res.json()) as { data: { deleted: boolean } }; + expect(body.data.deleted).toBe(false); + }); +}); diff --git a/src/app/api/coach/suggested-actions/__tests__/route.test.ts b/src/app/api/coach/suggested-actions/__tests__/route.test.ts new file mode 100644 index 000000000..f38a76c2c --- /dev/null +++ b/src/app/api/coach/suggested-actions/__tests__/route.test.ts @@ -0,0 +1,169 @@ +/** + * v1.22 (F6) — `POST /api/coach/suggested-actions`. + * + * Covers the closed allowlist confirm endpoint: + * - checkup.create builds a Vorsorge MeasurementReminder field-by-field + * (origin COACH, server-resolved RRULE from the closed interval id). + * - reminder.note builds a CoachReminder field-by-field (source action). + * - an off-allowlist actionType (e.g. medication.create) 422s — the moat + * never auto-applies or mints an arbitrary entity. + * - the owner is always the session user (no IDOR, no body userId). + * - the coach module gate runs. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + measurementReminder: { create: vi.fn() }, + coachReminder: { count: vi.fn(), create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + resetAt: Date.now() + 1000, + })), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), +})); +vi.mock("@/lib/measurement-reminders/scheduling", () => ({ + computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")), +})); +vi.mock("@/lib/measurement-reminders/dto", () => ({ + toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "t", + role: "USER" as const, + displayName: null, + }, +}; + +const call = (body: unknown) => + (POST as unknown as (req: Request) => Promise)( + new NextRequest("http://localhost/api/coach/suggested-actions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("POST /api/coach/suggested-actions", () => { + it("checkup.create builds a Vorsorge reminder field-by-field (origin COACH, server RRULE)", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.measurementReminder.create).mockResolvedValue({ + id: "mr1", + } as never); + + const res = await call({ + actionType: "checkup.create", + label: "Annual blood panel", + interval: "yearly", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.measurementReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + origin: string; + rrule: string; + measurementType: null; + label: string; + }; + expect(data.userId).toBe("user-1"); // owner from session, never the body + expect(data.origin).toBe("COACH"); + expect(data.rrule).toBe("FREQ=YEARLY;INTERVAL=1"); + expect(data.measurementType).toBeNull(); + expect(data.label).toBe("Annual blood panel"); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + }); + + it("reminder.note builds a CoachReminder field-by-field (source action)", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + vi.mocked(prisma.coachReminder.create).mockResolvedValue({ + id: "cr1", + } as never); + + const res = await call({ + actionType: "reminder.note", + note: "revisit evening walk", + when: "+14d", + metric: "SLEEP", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + source: string; + status: string; + }; + expect(data.userId).toBe("user-1"); + expect(data.source).toBe("action"); + expect(data.status).toBe("active"); + }); + + it("422s an off-allowlist actionType — never auto-applies a clinical/med change", async () => { + const res = await call({ + actionType: "medication.create", + label: "start a drug", + interval: "monthly", + }); + expect(res.status).toBe(422); + expect(prisma.measurementReminder.create).not.toHaveBeenCalled(); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + }); + + it("403s when the coach module is disabled", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + const res = await call({ + actionType: "reminder.note", + note: "x", + }); + expect(res.status).toBe(403); + }); +}); diff --git a/src/lib/ai/coach/__tests__/reminders.test.ts b/src/lib/ai/coach/__tests__/reminders.test.ts new file mode 100644 index 000000000..149578958 --- /dev/null +++ b/src/lib/ai/coach/__tests__/reminders.test.ts @@ -0,0 +1,177 @@ +/** + * v1.22 (B2) — Coach episodic reminder capture: grammar + sentinel + write. + * + * Covers the closed `when` grammar (ISO date / relative / context cue / reject), + * the `---REMEMBER---` strip (capture, recall-only note, malformed drop, prose + * preserved), and the cap-aware capture write. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("../bytes-codec", () => ({ + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), +})); + +import { + resolveWhenGrammar, + parseRememberSentinel, + captureReminderFromSentinel, + MAX_REMINDERS_PER_USER, +} from "../reminders"; + +const NOW = new Date("2026-06-27T12:00:00.000Z"); + +describe("resolveWhenGrammar", () => { + it("parses an ISO date into a date trigger at 09:00 UTC", () => { + const r = resolveWhenGrammar("2026-07-11", NOW); + expect(r).toEqual({ + triggerKind: "date", + dueAt: new Date("2026-07-11T09:00:00.000Z"), + contextCue: null, + }); + }); + + it("parses a relative +Nd / +Nw offset from now", () => { + expect(resolveWhenGrammar("+14d", NOW)?.dueAt).toEqual( + new Date("2026-07-11T12:00:00.000Z"), + ); + expect(resolveWhenGrammar("+2w", NOW)?.dueAt).toEqual( + new Date("2026-07-11T12:00:00.000Z"), + ); + }); + + it("parses a context cue into a context trigger (no dueAt)", () => { + expect(resolveWhenGrammar("NEXT_BP_LOGGED", NOW)).toEqual({ + triggerKind: "context", + dueAt: null, + contextCue: "NEXT_BP_LOGGED", + }); + }); + + it("rejects an unknown / out-of-bounds token", () => { + expect(resolveWhenGrammar("someday", NOW)).toBeNull(); + expect(resolveWhenGrammar("+0d", NOW)).toBeNull(); + expect(resolveWhenGrammar("+9999d", NOW)).toBeNull(); + expect(resolveWhenGrammar("NEXT_MOON", NOW)).toBeNull(); + }); +}); + +describe("parseRememberSentinel", () => { + it("captures note + when + metric and strips the block from the prose", () => { + const raw = `Got it — I'll bring that up around the 11th. +---REMEMBER--- +note: revisit the evening-walk idea for your sleep +when: 2026-07-11 +metric: SLEEP +---END---`; + const r = parseRememberSentinel(raw, NOW); + expect(r.malformed).toBe(false); + expect(r.prose).toBe("Got it — I'll bring that up around the 11th."); + expect(r.reminder?.note).toBe( + "revisit the evening-walk idea for your sleep", + ); + expect(r.reminder?.metric).toBe("SLEEP"); + expect(r.reminder?.trigger?.dueAt).toEqual( + new Date("2026-07-11T09:00:00.000Z"), + ); + }); + + it("captures a recall-only note when `when` is omitted", () => { + const raw = `Noted. +---REMEMBER--- +note: you wanted to discuss your labs +---END---`; + const r = parseRememberSentinel(raw, NOW); + expect(r.malformed).toBe(false); + expect(r.reminder?.note).toBe("you wanted to discuss your labs"); + expect(r.reminder?.trigger).toBeNull(); + }); + + it("drops the block (malformed) on a missing note or an invalid when, keeping prose", () => { + const noNote = parseRememberSentinel( + "Sure.\n---REMEMBER---\nwhen: 2026-07-11\n---END---", + NOW, + ); + expect(noNote.reminder).toBeNull(); + expect(noNote.malformed).toBe(true); + expect(noNote.prose).toBe("Sure."); + + const badWhen = parseRememberSentinel( + "Sure.\n---REMEMBER---\nnote: x\nwhen: someday\n---END---", + NOW, + ); + expect(badWhen.reminder).toBeNull(); + expect(badWhen.malformed).toBe(true); + // The raw marker is never left in the prose. + expect(badWhen.prose).not.toContain("REMEMBER"); + }); + + it("returns the prose untouched when no block is present", () => { + const r = parseRememberSentinel("just a normal reply", NOW); + expect(r).toEqual({ + prose: "just a normal reply", + reminder: null, + malformed: false, + }); + }); +}); + +describe("captureReminderFromSentinel", () => { + function db(count: number) { + return { + coachReminder: { + count: vi.fn(async () => count), + create: vi.fn(async () => ({ id: "r1" })), + }, + }; + } + + it("writes an active reminder field-by-field with source sentinel", async () => { + const d = db(0); + const id = await captureReminderFromSentinel({ + userId: "u1", + conversationId: "c1", + parsed: { + note: "revisit sleep", + trigger: { + triggerKind: "date", + dueAt: new Date("2026-07-11T09:00:00.000Z"), + contextCue: null, + }, + metric: "SLEEP", + }, + db: d as never, + }); + expect(id).toBe("r1"); + const createArgs = d.coachReminder.create.mock.calls[0] as unknown as [ + { + data: { + userId: string; + status: string; + source: string; + sourceConversationId: string; + metric: string; + }; + }, + ]; + const data = createArgs[0].data; + expect(data.userId).toBe("u1"); + expect(data.status).toBe("active"); + expect(data.source).toBe("sentinel"); + expect(data.sourceConversationId).toBe("c1"); + expect(data.metric).toBe("SLEEP"); + }); + + it("refuses the write once the per-user cap is reached", async () => { + const d = db(MAX_REMINDERS_PER_USER); + const id = await captureReminderFromSentinel({ + userId: "u1", + conversationId: "c1", + parsed: { note: "x", trigger: null, metric: null }, + db: d as never, + }); + expect(id).toBeNull(); + expect(d.coachReminder.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai/coach/__tests__/suggest-action.test.ts b/src/lib/ai/coach/__tests__/suggest-action.test.ts new file mode 100644 index 000000000..6a7a386fd --- /dev/null +++ b/src/lib/ai/coach/__tests__/suggest-action.test.ts @@ -0,0 +1,94 @@ +/** + * v1.22 (F6) — generalised confirm-card sentinel parse. + * + * Covers the CLOSED allowlist (checkup.create / reminder.note), the + * field-by-field resolution, and the safety property that an unknown action + * type or a malformed block yields NO action (never auto-applies, never mints + * an arbitrary entity) while the prose is preserved and the raw marker stripped. + */ +import { describe, it, expect } from "vitest"; + +import { + parseSuggestAction, + isSuggestedActionType, + isCheckupIntervalId, +} from "../suggest-action"; + +describe("parseSuggestAction", () => { + it("resolves a checkup.create action with a closed interval id", () => { + const raw = `You might add an annual panel. +---SUGGEST-ACTION--- +action: checkup.create +label: Annual blood panel +interval: yearly +---END---`; + const r = parseSuggestAction(raw); + expect(r.malformed).toBe(false); + expect(r.prose).toBe("You might add an annual panel."); + expect(r.action?.actionType).toBe("checkup.create"); + expect(r.action?.summary).toBe("Annual blood panel"); + expect(r.action?.params).toEqual({ + actionType: "checkup.create", + label: "Annual blood panel", + interval: "yearly", + }); + }); + + it("resolves a reminder.note action with the closed when grammar token", () => { + const raw = `---SUGGEST-ACTION--- +action: reminder.note +note: revisit the evening-walk effect on sleep +when: +14d +metric: SLEEP +---END---`; + const r = parseSuggestAction(raw); + expect(r.action?.params).toEqual({ + actionType: "reminder.note", + note: "revisit the evening-walk effect on sleep", + when: "+14d", + metric: "SLEEP", + }); + }); + + it("drops an unknown / off-allowlist action type (no entity, prose kept)", () => { + const raw = `ok. +---SUGGEST-ACTION--- +action: medication.create +label: start a new drug +interval: monthly +---END---`; + const r = parseSuggestAction(raw); + expect(r.action).toBeNull(); + expect(r.malformed).toBe(true); + expect(r.prose).toBe("ok."); + expect(r.prose).not.toContain("SUGGEST-ACTION"); + }); + + it("drops a checkup with an unknown interval id", () => { + const raw = `---SUGGEST-ACTION--- +action: checkup.create +label: x +interval: hourly +---END---`; + const r = parseSuggestAction(raw); + expect(r.action).toBeNull(); + expect(r.malformed).toBe(true); + }); + + it("passes the prose through untouched when no block is present", () => { + const r = parseSuggestAction("a normal reply"); + expect(r).toEqual({ + prose: "a normal reply", + action: null, + malformed: false, + }); + }); + + it("closed-allowlist guards reject anything off-list", () => { + expect(isSuggestedActionType("checkup.create")).toBe(true); + expect(isSuggestedActionType("reminder.note")).toBe(true); + expect(isSuggestedActionType("medication.create")).toBe(false); + expect(isCheckupIntervalId("yearly")).toBe(true); + expect(isCheckupIntervalId("hourly")).toBe(false); + }); +}); diff --git a/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts new file mode 100644 index 000000000..5a3ceaf9b --- /dev/null +++ b/src/lib/jobs/__tests__/coach-reminder-sweep.test.ts @@ -0,0 +1,106 @@ +/** + * v1.22 (M4) — daily Coach-reminder sweep. + * + * Covers: overdue active date-reminders flip to `due`; a passed CoachPlan + * reviewDate mints a one-off reminder from the plan's own cue→action text and + * clears the reviewDate (activating the dangling B1 column); an undecryptable + * plan is skipped (counted errored) without sinking the tick. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: (buf: Uint8Array) => { + const tag = Buffer.from(buf).toString("utf8"); + if (tag === "__bad__") throw new Error("unknown key id"); + return `dec:${tag}`; + }, + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), +})); + +import { runCoachReminderSweep } from "../coach-reminder-sweep"; + +const NOW = new Date("2026-06-27T05:20:00.000Z"); + +function bytes(tag: string): Uint8Array { + return new Uint8Array(Buffer.from(tag, "utf8")); +} + +describe("runCoachReminderSweep", () => { + it("flips overdue active date-reminders to due and mints plan-review reminders", async () => { + const prisma = { + coachReminder: { + updateMany: vi.fn(async () => ({ count: 3 })), + create: vi.fn(async () => ({ id: "r-new" })), + }, + coachPlan: { + findMany: vi.fn(async () => [ + { + id: "p1", + userId: "u1", + metric: "WEIGHT", + ifCueEncrypted: bytes("every morning"), + thenActionEncrypted: bytes("weigh in"), + }, + ]), + update: vi.fn(async () => ({})), + }, + }; + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.remindersDue).toBe(3); + expect(summary.planReviewsMinted).toBe(1); + expect(summary.errored).toBe(0); + + // The flip query is scoped to active, date-triggered, overdue rows. + const flipArgs = prisma.coachReminder.updateMany.mock + .calls[0] as unknown as [ + { where: { status: string; triggerKind: string; dueAt: unknown } }, + ]; + const where = flipArgs[0].where; + expect(where.status).toBe("active"); + expect(where.triggerKind).toBe("date"); + expect(where.dueAt).toEqual({ not: null, lte: NOW }); + + // The minted reminder carries the plan's own prose + relatedPlanId. + const createArgs = prisma.coachReminder.create.mock.calls[0] as unknown as [ + { data: { relatedPlanId: string; source: string; status: string } }, + ]; + const data = createArgs[0].data; + expect(data.relatedPlanId).toBe("p1"); + expect(data.source).toBe("extractor"); + expect(data.status).toBe("due"); + + // The plan's reviewDate is cleared so the review fires exactly once. + expect(prisma.coachPlan.update).toHaveBeenCalledWith({ + where: { id: "p1" }, + data: { reviewDate: null }, + }); + }); + + it("skips an undecryptable plan without sinking the tick", async () => { + const prisma = { + coachReminder: { + updateMany: vi.fn(async () => ({ count: 0 })), + create: vi.fn(async () => ({ id: "r-new" })), + }, + coachPlan: { + findMany: vi.fn(async () => [ + { + id: "p1", + userId: "u1", + metric: "SLEEP", + ifCueEncrypted: bytes("__bad__"), + thenActionEncrypted: bytes("lights out"), + }, + ]), + update: vi.fn(async () => ({})), + }, + }; + + const summary = await runCoachReminderSweep(prisma as never, NOW); + expect(summary.planReviewsMinted).toBe(0); + expect(summary.errored).toBe(1); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + expect(prisma.coachPlan.update).not.toHaveBeenCalled(); + }); +});