mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user