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)); }); });