import { randomUUID } from "node:crypto"; import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; import { nextCronDate } from "@rakazo/core"; 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"; import { updateUserProfileFromTool } from "./user-profile-tools.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)); }); it("moves the check-ins, and only them, when the profile time zone changes", async () => { const userId = await newUser("Europe/Vienna"); const jobs = fakeJobs(); const deps = { prisma, jobs }; await setCoachRhythm(deps, { userId, rhythm: { morning: "07:30", evening: "21:00", weekly: { day: 0, time: "18:00" } }, }); const conversation = await ensureMainConversation(prisma, userId); const evening = await prisma.routine.findFirstOrThrow({ where: { userId, coachKey: "evening" }, }); await prisma.routine.update({ where: { id: evening.id }, data: { active: false, nextRunAt: null }, }); const own = await prisma.routine.create({ data: { botId: conversation.leadBotId, userId, spaceId: conversation.spaceId, name: "Water the plants", prompt: "Remind me to water the plants.", crons: ["0 9 * * *"], timezone: "Europe/Vienna", active: true, nextRunAt: nextCronDate("0 9 * * *", new Date(), "Europe/Vienna"), }, }); const setZone = (time_zone: string) => updateUserProfileFromTool( deps, { userId, bot: { id: conversation.leadBotId, kind: "main" }, threadId: conversation.threadId, sourceMessageId: null, }, { time_zone }, ); const before = await coachRoutines(userId); // Saving the zone the check-ins already use changes nothing. jobs.enqueue.mockClear(); expect(await setZone("Europe/Vienna")).toMatchObject({ ok: true }); expect(await coachRoutines(userId)).toEqual(before); expect(jobs.enqueue).not.toHaveBeenCalled(); expect(await setZone("America/New_York")).toMatchObject({ ok: true }); const moved = await coachRoutines(userId); expect(moved.map((routine) => [routine.coachKey, routine.timezone])).toEqual([ ["evening", "America/New_York"], ["morning", "America/New_York"], ["weekly", "America/New_York"], ]); const localTime = (date: Date) => new Intl.DateTimeFormat("en-GB", { timeZone: "America/New_York", weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).format(date); const [pausedEvening, morning, weekly] = moved; expect(localTime(morning!.nextRunAt!)).toMatch(/ 07:30$/); expect(localTime(weekly!.nextRunAt!)).toBe("Sun 18:00"); for (const routine of [morning!, weekly!]) { expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); expect(routine.nextRunAt).not.toEqual(before.find((old) => old.id === routine.id)?.nextRunAt); } expect(jobs.enqueue).toHaveBeenCalledTimes(2); expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(morning!.id, morning!.nextRunAt!)); expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(weekly!.id, weekly!.nextRunAt!)); // A paused check-in moves but stays paused; the user's own routine keeps its zone. expect(pausedEvening).toMatchObject({ active: false, nextRunAt: null }); expect(await prisma.routine.findUniqueOrThrow({ where: { id: own.id } })).toEqual(own); expect(await getCoachRhythm(prisma, userId)).toEqual({ morning: "07:30", evening: null, weekly: { day: 0, time: "18:00" }, }); // Saving the new zone again changes nothing. jobs.enqueue.mockClear(); await setZone("America/New_York"); expect(await coachRoutines(userId)).toEqual(moved); expect(jobs.enqueue).not.toHaveBeenCalled(); }); it("creates no check-ins for a user without any when the time zone changes", async () => { const userId = await newUser("Europe/Vienna"); const jobs = fakeJobs(); const main = await ensureMainConversation(prisma, userId); await updateUserProfileFromTool( { prisma, jobs }, { userId, bot: { id: main.leadBotId, kind: "main" }, threadId: main.threadId, sourceMessageId: null, }, { time_zone: "Asia/Tokyo" }, ); expect(await prisma.userProfile.findUnique({ where: { userId } })).toMatchObject({ timeZone: "Asia/Tokyo", }); expect(await prisma.routine.count({ where: { userId } })).toBe(0); expect(jobs.enqueue).not.toHaveBeenCalled(); }); });