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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
7d7af8f3e9
commit
e1ce6f4090
182
packages/adapters/src/coach-rhythm.postgres.test.ts
Normal file
182
packages/adapters/src/coach-rhythm.postgres.test.ts
Normal file
@ -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<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));
|
||||||
|
});
|
||||||
|
});
|
||||||
66
packages/core/src/coach.test.ts
Normal file
66
packages/core/src/coach.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
327
packages/db/src/coach.postgres.test.ts
Normal file
327
packages/db/src/coach.postgres.test.ts
Normal file
@ -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<void>;
|
||||||
|
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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -109,6 +109,7 @@ async function main() {
|
|||||||
"packages/db/src/space-membership.postgres.test.ts",
|
"packages/db/src/space-membership.postgres.test.ts",
|
||||||
"packages/db/src/main-agent.postgres.test.ts",
|
"packages/db/src/main-agent.postgres.test.ts",
|
||||||
"packages/db/src/user-profile.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/db/src/messaging.postgres.test.ts",
|
||||||
"packages/memory/src/commit.postgres.test.ts",
|
"packages/memory/src/commit.postgres.test.ts",
|
||||||
"packages/adapters/src/wakeup.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/cloud-agent.postgres.test.ts",
|
||||||
"packages/adapters/src/builtin-memory-provider.postgres.test.ts",
|
"packages/adapters/src/builtin-memory-provider.postgres.test.ts",
|
||||||
"packages/adapters/src/profile-consolidation.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
|
// Each app reconciles all durable work in its database, including intentionally
|
||||||
// unfinished fixture runs. Clone the pristine migrated schema so one suite
|
// unfinished fixture runs. Clone the pristine migrated schema so one suite
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user