Merge branch 'main' into fix/handover-p1-batch

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-07 18:24:33 +02:00
commit 384992bb15
34 files changed
+1305 -383

No files matched your search

@@ -0,0 +1,239 @@
/**
* 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);
});
});