From e1ce6f4090e68e6e5ab62f8a1f37bd9f18a974b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:15:06 +0000 Subject: [PATCH] test(coach): repository, streak and rhythm suites Covers what the coach interfaces promise before anything builds on them: goal and habit ownership and limits, one check-in per habit, goal and day dated in the user's time zone, streaks across weekends and weeks, and a rhythm that keeps exactly one main-bot routine per check-in and is idempotent when set again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .../src/coach-rhythm.postgres.test.ts | 182 ++++++++++ packages/core/src/coach.test.ts | 66 ++++ packages/db/src/coach.postgres.test.ts | 327 ++++++++++++++++++ packages/testkit/src/cli/harness.ts | 2 + 4 files changed, 577 insertions(+) create mode 100644 packages/adapters/src/coach-rhythm.postgres.test.ts create mode 100644 packages/core/src/coach.test.ts create mode 100644 packages/db/src/coach.postgres.test.ts diff --git a/packages/adapters/src/coach-rhythm.postgres.test.ts b/packages/adapters/src/coach-rhythm.postgres.test.ts new file mode 100644 index 0000000..73136e2 --- /dev/null +++ b/packages/adapters/src/coach-rhythm.postgres.test.ts @@ -0,0 +1,182 @@ +import { randomUUID } from "node:crypto"; +import { routineJobKey } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("coach rhythm (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `cr${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return id; + } + + function fakeJobs() { + return { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }; + } + + function coachRoutines(userId: string) { + return prisma.routine.findMany({ + where: { userId, coachKey: { not: null } }, + orderBy: { coachKey: "asc" }, + }); + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("keeps one main-bot routine per check-in in the main conversation and user's time zone", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const rhythm = { morning: "07:30", evening: null, weekly: { day: 0, time: "18:00" } }; + + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + const conversation = await ensureMainConversation(prisma, userId); + const created = await coachRoutines(userId); + expect( + created.map( + ({ coachKey, botId, threadId, spaceId, crons, timezone, active, notify, name }) => ({ + coachKey, + botId, + threadId, + spaceId, + crons, + timezone, + active, + notify, + name, + }), + ), + ).toEqual([ + { + coachKey: "morning", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["30 7 * * *"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.morning.name, + }, + { + coachKey: "weekly", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["0 18 * * 0"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.weekly.name, + }, + ]); + for (const routine of created) { + expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + expect(jobs.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + name: "routine.wakeup", + replaceKey: routineJobKey(routine.id), + availableAt: routine.nextRunAt, + }), + ); + } + expect(created[0]?.prompt).toBe(COACH_ROUTINES.morning.prompt); + + // The same rhythm again changes nothing. + jobs.enqueue.mockClear(); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect(jobs.enqueue).not.toHaveBeenCalled(); + expect(await coachRoutines(userId)).toEqual(created); + + // Moving, adding and turning off check-ins. + const next = { morning: "06:45", evening: "21:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: next })).toEqual(next); + const updated = await coachRoutines(userId); + expect(updated.map((routine) => [routine.coachKey, routine.crons[0]])).toEqual([ + ["evening", "0 21 * * *"], + ["morning", "45 6 * * *"], + ]); + expect(updated.find((routine) => routine.coachKey === "morning")?.id).toBe(created[0]?.id); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(created[1]!.id)); + expect(await getCoachRhythm(prisma, userId)).toEqual(next); + }); + + it("reads paused or reshaped routines as off and turns them back on", async () => { + const userId = await newUser("UTC"); + const jobs = fakeJobs(); + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "08:00", evening: "20:00", weekly: null } }, + ); + const routines = await coachRoutines(userId); + await prisma.routine.update({ where: { id: routines[0]!.id }, data: { active: false } }); + await prisma.routine.update({ + where: { id: routines[1]!.id }, + data: { crons: ["0 8 * * 1-5"] }, + }); + expect(await getCoachRhythm(prisma, userId)).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + + const rhythm = { morning: "08:00", evening: "20:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect((await coachRoutines(userId)).map((routine) => routine.id)).toEqual( + routines.map((routine) => routine.id), + ); + }); + + it("turning everything off removes the routines and never creates a main bot", async () => { + const userId = await newUser(null); + const jobs = fakeJobs(); + const off = { morning: null, evening: null, weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await prisma.bot.count({ where: { userId, kind: "main" } })).toBe(0); + expect(await getCoachRhythm(prisma, userId)).toEqual(off); + + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "09:15", evening: null, weekly: null } }, + ); + const [routine] = await coachRoutines(userId); + expect(routine).toMatchObject({ timezone: "UTC", crons: ["15 9 * * *"] }); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await coachRoutines(userId)).toEqual([]); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id)); + }); +}); diff --git a/packages/core/src/coach.test.ts b/packages/core/src/coach.test.ts new file mode 100644 index 0000000..4f8f816 --- /dev/null +++ b/packages/core/src/coach.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + addDays, + habitDoneInPeriod, + habitStreak, + localDateIn, + weekdayOf, + weekStartOf, +} from "./coach.js"; + +const dates = (...values: string[]) => new Set(values); + +describe("calendar dates", () => { + it("reads the date in the user's time zone, not the server's", () => { + const instant = new Date("2026-10-03T23:30:00Z"); + expect(localDateIn("UTC", instant)).toBe("2026-10-03"); + expect(localDateIn("Europe/Vienna", instant)).toBe("2026-10-04"); + expect(localDateIn("America/Los_Angeles", instant)).toBe("2026-10-03"); + expect(localDateIn("Pacific/Kiritimati", new Date("2026-10-03T10:30:00Z"))).toBe("2026-10-04"); + }); + + it("moves across months, years and daylight saving changes by whole days", () => { + expect(addDays("2026-10-31", 1)).toBe("2026-11-01"); + expect(addDays("2027-01-01", -1)).toBe("2026-12-31"); + expect(addDays("2026-03-28", 2)).toBe("2026-03-30"); + expect(weekdayOf("2026-10-03")).toBe(6); + expect(weekStartOf("2026-10-03")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-04")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-05")).toBe("2026-10-05"); + }); +}); + +describe("habit streaks", () => { + const saturday = "2026-10-03"; + + it("counts consecutive days and keeps today open until it is done", () => { + const done = dates("2026-09-30", "2026-10-01", "2026-10-02"); + expect(habitStreak("daily", done, saturday)).toBe(3); + expect(habitStreak("daily", new Set([...done, saturday]), saturday)).toBe(4); + expect(habitStreak("daily", dates("2026-09-30", "2026-10-01"), saturday)).toBe(0); + expect(habitStreak("daily", dates(), saturday)).toBe(0); + }); + + it("skips weekends for weekday habits", () => { + const monday = "2026-10-05"; + const done = dates("2026-10-01", "2026-10-02"); + expect(habitStreak("weekdays", done, saturday)).toBe(2); + expect(habitStreak("weekdays", done, monday)).toBe(2); + expect(habitStreak("weekdays", new Set([...done, monday]), monday)).toBe(3); + expect(habitStreak("weekdays", dates("2026-10-01"), monday)).toBe(0); + }); + + it("counts weeks with at least one done day for weekly habits", () => { + const done = dates("2026-09-15", "2026-09-26", "2026-09-29"); + expect(habitStreak("weekly", done, saturday)).toBe(3); + expect(habitStreak("weekly", dates("2026-09-15", "2026-09-26"), saturday)).toBe(2); + expect(habitStreak("weekly", dates("2026-09-15"), saturday)).toBe(0); + }); + + it("reads a weekly habit as done for the rest of the week", () => { + expect(habitDoneInPeriod("weekly", dates("2026-09-29"), saturday)).toBe(true); + expect(habitDoneInPeriod("weekly", dates("2026-09-27"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates("2026-10-02"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates(saturday), saturday)).toBe(true); + }); +}); diff --git a/packages/db/src/coach.postgres.test.ts b/packages/db/src/coach.postgres.test.ts new file mode 100644 index 0000000..b4bb7a1 --- /dev/null +++ b/packages/db/src/coach.postgres.test.ts @@ -0,0 +1,327 @@ +import { randomUUID } from "node:crypto"; +import { COACH_ACTIVE_GOAL_LIMIT, COACH_ACTIVE_HABIT_LIMIT } from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import type { PrismaClient } from "./client.js"; +import { createDb } from "./client.js"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachSummary, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "./coach.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; +/** Saturday 2026-10-03, 23:30 UTC: already Sunday in Vienna. */ +const NOW = new Date("2026-10-03T23:30:00Z"); + +describePostgres("coach repository (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `co${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return { userId: id, spaceId }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("creates, completes, reopens and removes goals, scoped to their owner", async () => { + const { userId, spaceId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { + title: " Ship the beta ", + detail: "Invite ten testers", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + }); + expect(goal).toMatchObject({ + title: "Ship the beta", + detail: "Invite ten testers", + status: "active", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + createdByBotId: null, + completedAt: null, + }); + + const done = await updateGoal(prisma, userId, goal.id, { status: "done" }); + expect(done.status).toBe("done"); + expect(done.completedAt).not.toBeNull(); + const reopened = await updateGoal(prisma, userId, goal.id, { + status: "active", + targetDate: null, + }); + expect(reopened).toMatchObject({ status: "active", completedAt: null, targetDate: null }); + + await expect(updateGoal(prisma, other.userId, goal.id, { title: "Mine" })).rejects.toThrow( + CoachNotFoundError, + ); + await expect(removeGoal(prisma, other.userId, goal.id)).rejects.toThrow(CoachNotFoundError); + await expect( + createGoal(prisma, other.userId, { title: "Theirs", spaceId, source: "user" }), + ).rejects.toThrow("Space not found"); + expect(await listGoals(prisma, other.userId)).toEqual([]); + expect((await listGoals(prisma, userId, { status: "active" })).map((g) => g.id)).toEqual([ + goal.id, + ]); + + await removeGoal(prisma, userId, goal.id); + expect(await listGoals(prisma, userId)).toEqual([]); + }); + + it("validates titles, dates and the active limits", async () => { + const { userId } = await newUser(); + await expect(createGoal(prisma, userId, { title: " ", source: "user" })).rejects.toThrow( + InvalidCoachInputError, + ); + await expect( + createGoal(prisma, userId, { title: "x".repeat(201), source: "user" }), + ).rejects.toThrow("longer than 200"); + await expect( + createGoal(prisma, userId, { title: "Bad date", targetDate: "2026-02-30", source: "user" }), + ).rejects.toThrow("YYYY-MM-DD"); + + await prisma.userGoal.createMany({ + data: Array.from({ length: COACH_ACTIVE_GOAL_LIMIT }, (_, index) => ({ + userId, + title: `Goal ${index}`, + source: "user", + })), + }); + await expect(createGoal(prisma, userId, { title: "One more", source: "user" })).rejects.toThrow( + `at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`, + ); + const [first] = await listGoals(prisma, userId); + await updateGoal(prisma, userId, first!.id, { status: "dropped" }); + await createGoal(prisma, userId, { title: "One more", source: "user" }); + await expect(updateGoal(prisma, userId, first!.id, { status: "active" })).rejects.toThrow( + "active goals", + ); + + await prisma.habit.createMany({ + data: Array.from({ length: COACH_ACTIVE_HABIT_LIMIT }, (_, index) => ({ + userId, + title: `Habit ${index}`, + source: "user", + })), + }); + await expect( + createHabit(prisma, userId, { title: "One more", source: "user" }), + ).rejects.toThrow(`at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); + }); + + it("links habits to goals, pauses them and keeps their history", async () => { + const { userId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { title: "Run a 10k", source: "chat" }); + const habit = await createHabit(prisma, userId, { + title: "Run", + cadence: "weekdays", + goalId: goal.id, + source: "chat", + createdByBotId: "bot-1", + }); + expect(habit).toMatchObject({ cadence: "weekdays", goalId: goal.id, active: true }); + await expect( + createHabit(prisma, other.userId, { title: "Steal", goalId: goal.id, source: "user" }), + ).rejects.toThrow(CoachNotFoundError); + + const paused = await updateHabit(prisma, userId, habit.id, { active: false, title: "Jog" }); + expect(paused).toMatchObject({ active: false, title: "Jog" }); + expect(await listHabits(prisma, userId)).toEqual([]); + expect((await listHabits(prisma, userId, { includeInactive: true })).length).toBe(1); + + // Removing the goal keeps the habit, unlinked. + await removeGoal(prisma, userId, goal.id); + expect((await listHabits(prisma, userId, { includeInactive: true }))[0]?.goalId).toBeNull(); + await expect(removeHabit(prisma, other.userId, habit.id)).rejects.toThrow(CoachNotFoundError); + await removeHabit(prisma, userId, habit.id); + expect(await listHabits(prisma, userId, { includeInactive: true })).toEqual([]); + }); + + it("keeps one check-in per habit, goal and day, dated in the user's time zone", async () => { + const { userId } = await newUser("Europe/Vienna"); + const other = await newUser(); + const habit = await createHabit(prisma, userId, { title: "Stretch", source: "user" }); + const goal = await createGoal(prisma, userId, { title: "Learn Spanish", source: "user" }); + + const first = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, done: true }, + NOW, + ); + // 23:30 UTC on Saturday is Sunday in Vienna. + expect(first).toMatchObject({ kind: "habit", date: "2026-10-04", done: true, note: "" }); + const again = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, note: "10 minutes", createdByBotId: "bot-1" }, + NOW, + ); + expect(again).toMatchObject({ id: first.id, done: true, note: "10 minutes" }); + // Who created the row stays as it was. + expect(again.createdByBotId).toBeNull(); + + const daily = await logCheckIn(prisma, userId, { kind: "daily", mood: 4 }, NOW); + const dailyAgain = await logCheckIn(prisma, userId, { kind: "daily", energy: 2 }, NOW); + expect(dailyAgain).toMatchObject({ id: daily.id, mood: 4, energy: 2 }); + const goalCheckIn = await logCheckIn( + prisma, + userId, + { kind: "goal", goalId: goal.id, note: "Finished unit 3", date: "2026-10-02" }, + NOW, + ); + expect(goalCheckIn.date).toBe("2026-10-02"); + + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2026-10-05" }, NOW), + ).rejects.toThrow("future"); + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2025-01-01" }, NOW), + ).rejects.toThrow("go back at most"); + await expect(logCheckIn(prisma, userId, { kind: "daily", mood: 6 }, NOW)).rejects.toThrow( + "1 to 5", + ); + await expect( + logCheckIn(prisma, userId, { kind: "habit", goalId: goal.id }, NOW), + ).rejects.toThrow(InvalidCoachInputError); + await expect( + logCheckIn(prisma, other.userId, { kind: "habit", habitId: habit.id, done: true }, NOW), + ).rejects.toThrow(CoachNotFoundError); + + expect((await listCheckIns(prisma, userId)).map((row) => row.kind).sort()).toEqual([ + "daily", + "goal", + "habit", + ]); + expect(await listCheckIns(prisma, userId, { from: "2026-10-03" })).toHaveLength(2); + expect(await listCheckIns(prisma, userId, { goalId: goal.id })).toHaveLength(1); + expect(await listCheckIns(prisma, other.userId)).toEqual([]); + }); + + it("reports today's brief, goals, habits with streaks and the daily check-in", async () => { + const { userId } = await newUser("Europe/Vienna"); + const daily = await createHabit(prisma, userId, { title: "Walk", source: "user" }); + const weekly = await createHabit(prisma, userId, { + title: "Long run", + cadence: "weekly", + source: "user", + }); + const later = await createGoal(prisma, userId, { title: "Later", source: "user" }); + const soon = await createGoal(prisma, userId, { + title: "Soon", + targetDate: "2026-10-10", + source: "user", + }); + await createGoal(prisma, userId, { title: "Old", source: "user" }).then((goal) => + updateGoal(prisma, userId, goal.id, { status: "done" }), + ); + for (const date of ["2026-10-01", "2026-10-02", "2026-10-03"]) { + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, date, done: true }, NOW); + } + await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: weekly.id, date: "2026-09-30", done: true }, + NOW, + ); + + let today = await loadCoachToday(prisma, userId, NOW); + expect(today).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna", brief: null }); + expect(today.checkIn).toBeNull(); + expect(today.goals.map((goal) => goal.id)).toEqual([soon.id, later.id]); + expect( + today.habits.map(({ title, doneToday, streak }) => ({ title, doneToday, streak })), + ).toEqual([ + { title: "Walk", doneToday: false, streak: 3 }, + { title: "Long run", doneToday: true, streak: 1 }, + ]); + + const brief = await upsertDailyBrief( + prisma, + userId, + { text: "Rest day. Plan the week.", botId: "bot-1" }, + NOW, + ); + expect(brief).toMatchObject({ date: "2026-10-04", botId: "bot-1" }); + const rewritten = await upsertDailyBrief(prisma, userId, { text: "Rest day." }, NOW); + expect(rewritten).toMatchObject({ id: brief.id, text: "Rest day.", botId: null }); + await expect( + upsertDailyBrief(prisma, userId, { text: "Far ahead", date: "2026-10-20" }, NOW), + ).rejects.toThrow("today or tomorrow"); + await expect(upsertDailyBrief(prisma, userId, { text: " " }, NOW)).rejects.toThrow("empty"); + + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, done: true }, NOW); + await logCheckIn(prisma, userId, { kind: "daily", mood: 5 }, NOW); + // Unchecking a weekly habit clears it for the whole week. + await logCheckIn(prisma, userId, { kind: "habit", habitId: weekly.id, done: false }, NOW); + today = await loadCoachToday(prisma, userId, NOW); + expect(today.brief?.text).toBe("Rest day."); + expect(today.checkIn).toMatchObject({ kind: "daily", mood: 5 }); + expect(today.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: false, streak: 0 }, + ]); + + const summary = await loadCoachSummary(prisma, userId, NOW); + expect(summary).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna" }); + expect(summary.habits).toEqual(today.habits); + expect(summary.goals).toEqual(today.goals); + }); + + it("uses UTC dates when the user has no time zone", async () => { + const { userId } = await newUser(null); + expect(await loadCoachToday(prisma, userId, NOW)).toMatchObject({ + date: "2026-10-03", + timeZone: "UTC", + goals: [], + habits: [], + }); + }); +}); diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..ffe58e9 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -109,6 +109,7 @@ async function main() { "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", + "packages/db/src/coach.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts", @@ -117,6 +118,7 @@ async function main() { "packages/adapters/src/cloud-agent.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/profile-consolidation.postgres.test.ts", + "packages/adapters/src/coach-rhythm.postgres.test.ts", ]; // Each app reconciles all durable work in its database, including intentionally // unfinished fixture runs. Clone the pristine migrated schema so one suite