mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The Coach button showed its unread dot over an empty conversation, and opening the Coach neither showed anything nor cleared the dot. Two paths fed one indicator. The nightly sweep flipped a "remind me" reminder from active to due and stopped there — no conversation, no message, and `surfaced` had no writer at all. The launcher lit its dot off a second query that asked only whether anything was due. So the dot promised content that was never written, and because opening the Coach clears the message-based signal and not the reminder one, the only way out was to resolve the reminder in Settings, which nothing on the badge suggested. A reminder minted for a plan review put a user who had never typed in the Coach in exactly that state. The sweep now writes the reminder into the user's Coach thread as a real assistant message and marks it surfaced in the same transaction: no message, no status change, and nothing to point at. The badge derives from that message like every other proactive one, so opening the Coach shows the note and clears the dot. The launcher's second query is gone. Reminders an older build left stuck on due are picked up by the same pass, so an indicator that has been sitting on an empty conversation resolves into the message it always implied. No provider call and no push: the body is a deterministic localized template around the user's own note text, six languages. Proven end to end against Postgres — the row exists, the badge reads that row, opening clears it, a second tick writes nothing, and a failed write leaves the reminder untouched with the badge dark. All five fail against the previous sweep. The second indicator in the report, under the bottom bar's overflow entry, is the active-route marker for that group and not a badge. Refs #715
240 lines
8.5 KiB
TypeScript
240 lines
8.5 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 { 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());
|
|
});
|
|
|
|
async function seedUser(username: string, locale = "en") {
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
timezone: "UTC",
|
|
locale,
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|