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: [], }); }); });