Files
proof-of-work/proof-of-work/tests/routes-programs.test.ts
T
Keysat 3a5b929284 v1.1.0:1 — Programs UI (manual create / save / follow)
Schema
- Workout.programDayId added (nullable FK to ProgramDay) so a
  Workout logged from a program day can be tied back to the planned
  session for adherence analytics. Compat ALTER in entrypoint adds
  the column + index to existing /data; ON DELETE SET NULL so
  deleting a program doesn't remove historical workouts logged
  against it.
- Back-relation `workouts: Workout[]` added to ProgramDay.

API (proof-of-work/app/api/programs/...)
- GET    /api/programs                       — list user's programs
- POST   /api/programs                       — create with full nested
                                                 weeks/days/exercises
                                                 tree in one transaction
- GET    /api/programs/[id]                  — full tree
- PATCH  /api/programs/[id]                  — update metadata AND/OR
                                                 replace entire weeks
                                                 tree (same shape as
                                                 POST). UI editor + AI
                                                 apply flow share this.
- DELETE /api/programs/[id]                  — cascading
- POST   /api/programs/[id]/days/[dayId]/start
                                              — creates a Workout
                                                 pre-populated with
                                                 empty SetLogs (one per
                                                 planned set), tagged
                                                 with programDayId.

UI (proof-of-work/app/main/programs/...)
- /main/programs               — list with cards, today's-session
                                  callout, "active" badge
- /main/programs/new           — create form using ProgramEditor
- /main/programs/[id]          — detail + edit using same editor;
                                  today's-session card + Start button
                                  if program is active
- ProgramEditor component (components/programs/ProgramEditor.tsx) —
  expandable tree editor for weeks -> days -> exercises with
  per-row sets/reps/RPE/rest/notes fields + library exercise picker
- ProgramActions: delete button
- StartSessionButton: POSTs to start endpoint, redirects to new
  workout

Navigation
- "Programs" link added to bottom nav + sidebar (between Workouts
  and Exercises).
- /main/programs page itself shows the today's-session card; the
  same component pattern can be lifted into the dashboard later
  if we want.

lib/db/programs.ts
- getPrograms, getProgramById, getActivePrograms,
  computeTodaysSessionForProgram, getTodaysSession helpers.
- Today's session math: floor((todayUTC - startDateUTC) / 1day),
  weekNumber = floor(.../7) + 1, dayOfWeek = today.getUTCDay().
  Returns null if not started, past durationWeeks, or no day
  matching today's slot (= rest day).

Tests (tests/routes-programs.test.ts)
- 11 new tests covering: 401 unauthenticated, full-tree create
  with nested weeks+days+exercises, cross-user exerciseId
  rejection, list scoped to actor, GET detail returns 404 for
  another user's program, PATCH replace-tree atomicity,
  cascading DELETE, start-day Workout creation with the right
  number of empty SetLogs + programDayId stamped, start-day
  refused for cross-user program day.
- Total: 96 tests across 11 files.

This is the foundation for v1.1.0:2's AI-generated programs —
the AI will produce the same JSON shape POST /api/programs
already accepts, so the apply path is `editor.tsx + POST
/api/programs` with no new API surface.
2026-05-10 07:15:31 -05:00

493 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
const { getCurrentUserMock } = vi.hoisted(() => ({
getCurrentUserMock: vi.fn(),
}));
vi.mock('@/lib/auth', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, getCurrentUser: getCurrentUserMock };
});
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import {
GET as getPrograms,
POST as createProgram,
} from '@/app/api/programs/route';
import {
GET as getProgram,
PATCH as patchProgram,
DELETE as deleteProgram,
} from '@/app/api/programs/[id]/route';
import { POST as startDay } from '@/app/api/programs/[id]/days/[dayId]/start/route';
function jsonReq(url: string, body?: unknown, method?: string): NextRequest {
return new NextRequest(url, {
method: method ?? (body !== undefined ? 'POST' : 'GET'),
headers: { 'content-type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
} as ConstructorParameters<typeof NextRequest>[1]);
}
async function makeUserAndExercises(opts: {
email: string;
exerciseNames: string[];
}) {
const user = await prisma.user.create({
data: { email: opts.email, passwordHash: 'fake', isAdmin: false },
});
const exercises = [];
for (const name of opts.exerciseNames) {
exercises.push(
await prisma.exercise.create({
data: {
userId: user.id,
name,
type: 'barbell',
muscleGroups: '[]',
},
}),
);
}
return { user, exercises };
}
beforeEach(async () => {
await prisma.session.deleteMany();
await prisma.setLog.deleteMany();
await prisma.workout.deleteMany();
await prisma.programExercise.deleteMany();
await prisma.programDay.deleteMany();
await prisma.programWeek.deleteMany();
await prisma.program.deleteMany();
await prisma.exercise.deleteMany();
await prisma.user.deleteMany();
await prisma.instanceSettings.deleteMany();
getCurrentUserMock.mockReset();
});
describe('POST /api/programs', () => {
it('returns 401 unauthenticated', async () => {
getCurrentUserMock.mockResolvedValue(null);
const res = await createProgram(
jsonReq('http://x/api/programs', {
name: 'X',
type: 'hypertrophy',
durationWeeks: 4,
startDate: new Date().toISOString(),
}),
);
expect(res.status).toBe(401);
});
it('creates a program with the full nested tree in one transaction', async () => {
const { user, exercises } = await makeUserAndExercises({
email: 'a@x',
exerciseNames: ['Bench Press', 'Squat'],
});
getCurrentUserMock.mockResolvedValue(user);
const res = await createProgram(
jsonReq('http://x/api/programs', {
name: 'Test 4-week',
type: 'hypertrophy',
durationWeeks: 4,
startDate: '2026-05-10',
weeks: [
{
weekNumber: 1,
phase: 'Volume',
days: [
{
dayOfWeek: 1,
name: 'Push',
exercises: [
{
exerciseId: exercises[0].id,
order: 0,
sets: 4,
repsMin: 6,
repsMax: 10,
},
],
},
{
dayOfWeek: 3,
name: 'Pull',
exercises: [],
},
],
},
{
weekNumber: 2,
days: [
{
dayOfWeek: 1,
name: 'Push',
exercises: [
{
exerciseId: exercises[0].id,
order: 0,
sets: 4,
repsMin: 6,
repsMax: 10,
},
{
exerciseId: exercises[1].id,
order: 1,
sets: 5,
repsMin: 5,
repsMax: 5,
},
],
},
],
},
],
}),
);
expect(res.status).toBe(201);
const programs = await prisma.program.findMany({
where: { userId: user.id },
include: {
weeks: {
include: {
days: { include: { exercises: true } },
},
},
},
});
expect(programs).toHaveLength(1);
expect(programs[0].weeks).toHaveLength(2);
const week2 = programs[0].weeks.find((w) => w.weekNumber === 2);
expect(week2!.days[0].exercises).toHaveLength(2);
});
it('rejects exerciseIds that belong to a different user', async () => {
const { exercises: aliceExs } = await makeUserAndExercises({
email: 'alice@x',
exerciseNames: ['Alice Squat'],
});
const { user: bob } = await makeUserAndExercises({
email: 'bob@x',
exerciseNames: [],
});
getCurrentUserMock.mockResolvedValue(bob);
const res = await createProgram(
jsonReq('http://x/api/programs', {
name: 'Bob trying to use Alice exercise',
type: 'hypertrophy',
durationWeeks: 1,
startDate: '2026-05-10',
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [{ exerciseId: aliceExs[0].id, order: 0, sets: 3 }],
},
],
},
],
}),
);
expect(res.status).toBe(400);
});
});
describe('GET /api/programs + GET /api/programs/[id]', () => {
it('lists programs scoped to the actor and returns full tree on detail', async () => {
const { user, exercises } = await makeUserAndExercises({
email: 'a@x',
exerciseNames: ['Bench'],
});
getCurrentUserMock.mockResolvedValue(user);
await createProgram(
jsonReq('http://x/api/programs', {
name: 'Plan A',
type: 'hypertrophy',
durationWeeks: 1,
startDate: '2026-05-10',
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [{ exerciseId: exercises[0].id, order: 0, sets: 3 }],
},
],
},
],
}),
);
const list = await (await getPrograms()).json();
expect(list).toHaveLength(1);
const programId = list[0].id;
const detail = await (
await getProgram(jsonReq(`http://x/api/programs/${programId}`), {
params: { id: programId },
})
).json();
expect(detail.weeks[0].days[0].exercises[0].exercise.name).toBe('Bench');
});
it('GET detail returns 404 for another user\'s program', async () => {
const { user: aliceForOtherTest } = await makeUserAndExercises({
email: 'alice@x',
exerciseNames: [],
});
const aliceProg = await prisma.program.create({
data: {
userId: aliceForOtherTest.id,
name: 'Alice plan',
type: 'hypertrophy',
durationWeeks: 1,
startDate: new Date(),
},
});
const { user: bob } = await makeUserAndExercises({
email: 'bob@x',
exerciseNames: [],
});
getCurrentUserMock.mockResolvedValue(bob);
const res = await getProgram(
jsonReq(`http://x/api/programs/${aliceProg.id}`),
{ params: { id: aliceProg.id } },
);
expect(res.status).toBe(404);
});
});
describe('PATCH /api/programs/[id] (replace tree)', () => {
it('replaces the entire weeks tree atomically', async () => {
const { user, exercises } = await makeUserAndExercises({
email: 'a@x',
exerciseNames: ['Bench', 'Squat'],
});
getCurrentUserMock.mockResolvedValue(user);
const created = await (
await createProgram(
jsonReq('http://x/api/programs', {
name: 'Original',
type: 'hypertrophy',
durationWeeks: 4,
startDate: '2026-05-10',
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{ exerciseId: exercises[0].id, order: 0, sets: 3 },
],
},
],
},
],
}),
)
).json();
const patchRes = await patchProgram(
jsonReq(
`http://x/api/programs/${created.id}`,
{
name: 'Updated name',
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 2,
name: 'Pull',
exercises: [
{ exerciseId: exercises[1].id, order: 0, sets: 5 },
],
},
],
},
],
},
'PATCH',
),
{ params: { id: created.id } },
);
expect(patchRes.status).toBe(200);
const after = await prisma.program.findUnique({
where: { id: created.id },
include: {
weeks: {
include: {
days: { include: { exercises: true } },
},
},
},
});
expect(after!.name).toBe('Updated name');
expect(after!.weeks[0].days).toHaveLength(1);
expect(after!.weeks[0].days[0].dayOfWeek).toBe(2);
expect(after!.weeks[0].days[0].exercises[0].exerciseId).toBe(
exercises[1].id,
);
});
});
describe('DELETE /api/programs/[id]', () => {
it('cascades to weeks/days/exercises and refuses cross-user', async () => {
const { user, exercises } = await makeUserAndExercises({
email: 'a@x',
exerciseNames: ['Bench'],
});
getCurrentUserMock.mockResolvedValue(user);
const created = await (
await createProgram(
jsonReq('http://x/api/programs', {
name: 'Will be deleted',
type: 'hypertrophy',
durationWeeks: 1,
startDate: '2026-05-10',
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{ exerciseId: exercises[0].id, order: 0, sets: 3 },
],
},
],
},
],
}),
)
).json();
expect(await prisma.programWeek.count()).toBeGreaterThan(0);
expect(await prisma.programExercise.count()).toBeGreaterThan(0);
const res = await deleteProgram(
jsonReq(`http://x/api/programs/${created.id}`, undefined, 'DELETE'),
{ params: { id: created.id } },
);
expect(res.status).toBe(200);
expect(await prisma.programWeek.count()).toBe(0);
expect(await prisma.programExercise.count()).toBe(0);
});
});
describe('POST /api/programs/[id]/days/[dayId]/start', () => {
it('creates a workout pre-populated with empty SetLogs from the program day', async () => {
const { user, exercises } = await makeUserAndExercises({
email: 'a@x',
exerciseNames: ['Bench', 'Squat'],
});
getCurrentUserMock.mockResolvedValue(user);
const created = await (
await createProgram(
jsonReq('http://x/api/programs', {
name: 'Plan',
type: 'hypertrophy',
durationWeeks: 1,
startDate: '2026-05-10',
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
name: 'Push Day',
exercises: [
{ exerciseId: exercises[0].id, order: 0, sets: 4 },
{ exerciseId: exercises[1].id, order: 1, sets: 3 },
],
},
],
},
],
}),
)
).json();
const day = await prisma.programDay.findFirst({
where: { week: { programId: created.id } },
});
const startRes = await startDay(
jsonReq(
`http://x/api/programs/${created.id}/days/${day!.id}/start`,
{},
),
{ params: { id: created.id, dayId: day!.id } },
);
expect(startRes.status).toBe(201);
const workout = await startRes.json();
// 4 sets of Bench + 3 sets of Squat = 7 SetLogs
expect(workout.setLogs).toHaveLength(7);
expect(workout.programDayId).toBe(day!.id);
expect(workout.name).toBe('Push Day');
// SetLogs should be empty (no reps/weight) — user fills them in
for (const sl of workout.setLogs) {
expect(sl.reps).toBeNull();
expect(sl.weight).toBeNull();
}
});
it('refuses if the program day belongs to a different user', async () => {
const { user: alice, exercises: aliceExs } = await makeUserAndExercises({
email: 'alice@x',
exerciseNames: ['Alice Bench'],
});
getCurrentUserMock.mockResolvedValue(alice);
const aliceProg = await (
await createProgram(
jsonReq('http://x/api/programs', {
name: 'Alice Plan',
type: 'hypertrophy',
durationWeeks: 1,
startDate: '2026-05-10',
weeks: [
{
weekNumber: 1,
days: [
{
dayOfWeek: 1,
exercises: [
{ exerciseId: aliceExs[0].id, order: 0, sets: 3 },
],
},
],
},
],
}),
)
).json();
const aliceDay = await prisma.programDay.findFirst({
where: { week: { programId: aliceProg.id } },
});
const { user: bob } = await makeUserAndExercises({
email: 'bob@x',
exerciseNames: [],
});
getCurrentUserMock.mockResolvedValue(bob);
const res = await startDay(
jsonReq(
`http://x/api/programs/${aliceProg.id}/days/${aliceDay!.id}/start`,
),
{ params: { id: aliceProg.id, dayId: aliceDay!.id } },
);
expect(res.status).toBe(404);
});
});