3a5b929284
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.
493 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|