/** * The Coach badge and the Coach conversation, over the real assembly. * * The report: the FAB shows its unread dot, you open the Coach, the * conversation is empty, and the dot is still there afterwards. Two separate * paths were feeding one indicator — a "is any reminder due?" query lit the * dot, while the nightly sweep that made the reminder due wrote nothing into a * conversation and had no way to be cleared by opening one. * * The unit suite can prove the sweep CALLS the right prisma methods; it cannot * prove a row exists, that the badge derives from that row, or that opening * the Coach clears it. Those three claims are the defect, so they are asserted * here against Postgres, through the same functions the cron and the FAB use: * `runCoachReminderSweep` writes, `readCoachNudgeStatus` reads, and the seen * stamp in between is the one `POST /api/insights/coach/seen` writes. * * The note text is round-tripped through the real AES codec rather than a * mock, so an encoding mistake between the sweep and the conversation reader * fails here instead of shipping as an unreadable reminder. */ import { beforeEach, describe, expect, it } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep"; import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; const NOW = new Date("2026-08-07T05:20:00.000Z"); const YESTERDAY = new Date("2026-08-06T09:00:00.000Z"); beforeEach(async () => { await truncateAllTables(getPrismaClient()); }); /** * A user whose Coach can answer: a provider (presence only) and a Coach * consent receipt. Reminders are held while the Coach is unavailable, so a * user without them would prove only the hold. */ async function seedUser( username: string, { coach = true }: { coach?: boolean } = {}, ) { return getPrismaClient().user.create({ data: { username, email: `${username}@example.test`, timezone: "UTC", locale: "en", ...(coach ? COACH_READY : {}), }, }); } const COACH_READY = { aiProvider: "ANTHROPIC", aiAnthropicKeyEncrypted: "v1:presence-only", consentReceipts: { create: { kind: "ai_full", artefact: "test", signedAt: new Date() }, }, } as const; describe("a due Coach reminder reaches the conversation the badge points at", () => { it("writes the note as an assistant message, and that message is what makes the badge unread", async () => { const prisma = getPrismaClient(); const user = await seedUser("reminder-surfacing"); await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("ask me about my sleep again"), triggerKind: "date", dueAt: YESTERDAY, status: "active", source: "user", }, }); // Before the sweep there is nothing to be unread ABOUT. const before = await readCoachNudgeStatus(user.id); expect(before.unread).toBe(false); expect(before.conversationId).toBeNull(); const summary = await runCoachReminderSweep(prisma, NOW); expect(summary.remindersDue).toBe(1); expect(summary.errored).toBe(0); // BOTH ENDS. The row exists… const messages = await prisma.coachMessage.findMany({ where: { conversation: { userId: user.id } }, }); expect(messages).toHaveLength(1); expect(messages[0].role).toBe("assistant"); expect(decryptFromBytes(messages[0].encryptedContent)).toContain( "ask me about my sleep again", ); // …and the badge is derived from THAT row, not from a parallel query. const after = await readCoachNudgeStatus(user.id); expect(after.unread).toBe(true); expect(after.conversationId).toBe(messages[0].conversationId); // The reminder is marked surfaced, so it stops being pending work. const reminder = await prisma.coachReminder.findFirstOrThrow({ where: { userId: user.id }, }); expect(reminder.status).toBe("surfaced"); expect(reminder.surfaceCount).toBe(1); }); it("opening the Coach clears the badge", async () => { const prisma = getPrismaClient(); const user = await seedUser("reminder-seen"); await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("check the blood pressure trend"), triggerKind: "date", dueAt: YESTERDAY, status: "active", source: "user", }, }); await runCoachReminderSweep(prisma, NOW); expect((await readCoachNudgeStatus(user.id)).unread).toBe(true); // What `POST /api/insights/coach/seen` writes when the Coach is opened. // Anchored on the MESSAGE's own `createdAt`: the sweep takes its `now` for // the due-date arithmetic, but the row is stamped by the database, and the // route stamps the seen time from the same clock the row came from. const written = await prisma.coachMessage.findFirstOrThrow({ where: { conversation: { userId: user.id } }, }); await prisma.user.update({ where: { id: user.id }, data: { coachLastSeenAt: new Date(written.createdAt.getTime() + 1_000) }, }); expect((await readCoachNudgeStatus(user.id)).unread).toBe(false); }); it("does not surface the same reminder twice", async () => { const prisma = getPrismaClient(); const user = await seedUser("reminder-idempotent"); await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("weekly review"), triggerKind: "date", dueAt: YESTERDAY, status: "active", source: "user", }, }); await runCoachReminderSweep(prisma, NOW); const second = await runCoachReminderSweep( prisma, new Date(NOW.getTime() + 86_400_000), ); expect(second.remindersDue).toBe(0); expect( await prisma.coachMessage.count({ where: { conversation: { userId: user.id } }, }), ).toBe(1); }); it("takes a reminder an older build left stuck on `due` and gives it the message it promised", async () => { const prisma = getPrismaClient(); const user = await seedUser("reminder-legacy"); await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("the note nobody ever saw"), triggerKind: "date", dueAt: new Date("2026-05-01T09:00:00.000Z"), status: "due", lastSurfacedAt: new Date("2026-05-01T05:20:00.000Z"), surfaceCount: 1, source: "user", }, }); // This is the reported state exactly: something the UI treated as // outstanding, with an empty conversation behind it. expect( await prisma.coachMessage.count({ where: { conversation: { userId: user.id } }, }), ).toBe(0); await runCoachReminderSweep(prisma, NOW); const messages = await prisma.coachMessage.findMany({ where: { conversation: { userId: user.id } }, }); expect(messages).toHaveLength(1); expect(decryptFromBytes(messages[0].encryptedContent)).toContain( "the note nobody ever saw", ); expect((await readCoachNudgeStatus(user.id)).unread).toBe(true); }); it("keeps a failed write from leaving a badge with nothing behind it", async () => { const prisma = getPrismaClient(); const user = await seedUser("reminder-partial"); const reminder = await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("intact note"), triggerKind: "date", dueAt: YESTERDAY, status: "active", source: "user", }, }); // Fail the transaction the way a real outage would — inside the write, so // the conversation, the message and the status flip are all in flight. const failing = new Proxy(prisma, { get(target, prop, receiver) { if (prop === "$transaction") { return (arg: unknown) => { if (typeof arg === "function") { return Promise.reject(new Error("connection reset")); } const passthrough = Reflect.get(target, prop, receiver) as ( ops: unknown, ) => Promise; return passthrough.call(target, arg); }; } return Reflect.get(target, prop, receiver); }, }); const summary = await runCoachReminderSweep(failing as typeof prisma, NOW); expect(summary.remindersDue).toBe(0); expect(summary.errored).toBe(1); // Nothing written, nothing marked — and the badge stays dark, which is the // honest state: there is no message to read. expect( await prisma.coachMessage.count({ where: { conversation: { userId: user.id } }, }), ).toBe(0); const untouched = await prisma.coachReminder.findFirstOrThrow({ where: { id: reminder.id }, }); expect(untouched.status).toBe("active"); expect((await readCoachNudgeStatus(user.id)).unread).toBe(false); }); it("holds every reminder while the Coach is unavailable, and resumes when it is back", async () => { const prisma = getPrismaClient(); const user = await seedUser("reminder-held", { coach: false }); const due = await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("held until the Coach is back"), triggerKind: "date", dueAt: YESTERDAY, status: "active", source: "user", }, }); // One more ignored day would reach the nag cap. const nagging = await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("surfaced before the Coach went away"), triggerKind: "date", dueAt: new Date("2026-08-01T09:00:00.000Z"), status: "surfaced", lastSurfacedAt: new Date("2026-08-04T05:20:00.000Z"), surfaceCount: 2, source: "user", }, }); const context = await prisma.coachReminder.create({ data: { userId: user.id, noteEncrypted: encryptToBytes("ask about the weigh-in"), triggerKind: "context", contextCue: "NEXT_WEIGHT_LOGGED", status: "active", source: "user", }, }); await prisma.measurement.create({ data: { userId: user.id, type: "WEIGHT", value: 70, unit: "kg", measuredAt: YESTERDAY, }, }); // Three daily ticks: none surfaces, none is charged or dismissed. for (let day = 0; day < 3; day++) { const summary = await runCoachReminderSweep( prisma, new Date(NOW.getTime() + day * 86_400_000), ); expect(summary.remindersDue).toBe(0); expect(summary.contextSurfaced).toBe(0); expect(summary.nagDismissed).toBe(0); } // The ingest hook holds too. expect( (await evaluateCoachContextReminders(prisma, user.id, "measurement", NOW)) .surfaced, ).toBe(0); expect( await prisma.coachMessage.count({ where: { conversation: { userId: user.id } }, }), ).toBe(0); const rows = await prisma.coachReminder.findMany({ where: { userId: user.id }, orderBy: { createdAt: "asc" }, }); const byId = new Map(rows.map((r) => [r.id, r])); expect(byId.get(due.id)?.status).toBe("active"); expect(byId.get(nagging.id)?.status).toBe("surfaced"); expect(byId.get(nagging.id)?.surfaceCount).toBe(2); expect(byId.get(context.id)?.status).toBe("active"); // The Coach comes back: the held reminders surface on the next tick. await prisma.user.update({ where: { id: user.id }, data: { aiProvider: COACH_READY.aiProvider, aiAnthropicKeyEncrypted: COACH_READY.aiAnthropicKeyEncrypted, consentReceipts: { create: COACH_READY.consentReceipts.create }, }, }); const resumed = await runCoachReminderSweep(prisma, NOW); expect(resumed.remindersDue).toBe(1); expect(resumed.contextSurfaced).toBe(1); }); });