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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:38:36 +02:00
1 parent 5331528076
commit 44b4e6cf06
5 files changed
+858

No files matched your search

@@ -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<typeof import("@/lib/logging/context")>();
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<Response>)(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<Response>)(jsonReq(body));
const callPatch = (body: unknown, id = "r1") =>
(
PATCH as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>
)(
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<Response>
)(
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<Record<string, unknown>> };
};
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<string, unknown>;
};
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);
});
});
@@ -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<typeof import("@/lib/logging/context")>();
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<Response>)(
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);
});
});