mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The daily sweep and the measurement-ingest hook surfaced due reminders into a Coach conversation whatever the record's coach capability said, and the nag cap then dismissed them after three ticks although no badge ever showed. The app-open path already waited on the capability. surfaceCoachReminders now holds every row while the coach capability is unavailable (the same answer that keeps the badge dark), and the nag cap neither charges nor dismisses a held user's surfaced rows. They surface on the first tick after the Coach is back. The integration fixture gives its users a provider and consent, and a held case covers the sweep, the nag cap and the ingest hook.
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
/**
|
|
* 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<unknown>;
|
|
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);
|
|
});
|
|
});
|