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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
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<void>;
|
|
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));
|
|
});
|
|
});
|