4be489d6d3
Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie) instead of RPE (6-10) as their effort field; strength keeps RPE. An exercise counts as cardio when its equipment type is "cardio" or it carries the "cardio" muscle group (isCardioExercise in lib/exerciseOptions), so the Assault Bike (type "assault bike") qualifies. New nullable SetLog.gear column added by the boot-time guarded ALTER in docker_entrypoint.sh (additive, idempotent); plumbed through all 5 set-write paths, the summary/edit views, and CSV/JSON import-export. Existing rpe data is untouched and still displays. Program/AI target-RPE is unaffected.
558 lines
18 KiB
TypeScript
558 lines
18 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 getExercises, POST as postExercise } from '@/app/api/exercises/route';
|
|
import { POST as postWorkout, GET as getWorkouts } from '@/app/api/workouts/route';
|
|
import { POST as postSets } from '@/app/api/workouts/[id]/sets/route';
|
|
import { PATCH as patchWorkout } from '@/app/api/workouts/[id]/route';
|
|
import { POST as postImportSave } from '@/app/api/workouts/import/save/route';
|
|
|
|
// `NextRequest` accepts a slightly stricter RequestInit (no `signal:
|
|
// null`), so cast the standard RequestInit to the constructor's
|
|
// expected shape.
|
|
function jsonReq(url: string, body?: unknown, init?: RequestInit): NextRequest {
|
|
const opts: RequestInit = {
|
|
method: body !== undefined ? 'POST' : 'GET',
|
|
headers: { 'content-type': 'application/json' },
|
|
...init,
|
|
};
|
|
if (body !== undefined) {
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
return new NextRequest(url, opts as ConstructorParameters<typeof NextRequest>[1]);
|
|
}
|
|
|
|
async function makeUser(opts: { email: string; isAdmin?: boolean }) {
|
|
return prisma.user.create({
|
|
data: {
|
|
email: opts.email,
|
|
passwordHash: 'fake',
|
|
isAdmin: opts.isAdmin ?? false,
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await prisma.session.deleteMany();
|
|
await prisma.exercise.deleteMany();
|
|
await prisma.workout.deleteMany();
|
|
await prisma.user.deleteMany();
|
|
await prisma.instanceSettings.deleteMany();
|
|
getCurrentUserMock.mockReset();
|
|
});
|
|
|
|
describe('GET /api/exercises', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
getCurrentUserMock.mockResolvedValue(null);
|
|
const res = await getExercises(jsonReq('http://x/api/exercises'));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns only exercises owned by the requesting user', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bob = await makeUser({ email: 'b@x' });
|
|
await prisma.exercise.createMany({
|
|
data: [
|
|
{ userId: alice.id, name: 'Alice Squat', type: 'barbell', muscleGroups: '[]' },
|
|
{ userId: alice.id, name: 'Alice Bench', type: 'barbell', muscleGroups: '[]' },
|
|
{ userId: bob.id, name: 'Bob Curl', type: 'dumbbell', muscleGroups: '[]' },
|
|
],
|
|
});
|
|
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await getExercises(jsonReq('http://x/api/exercises'));
|
|
const body = await res.json();
|
|
const names = body.map((e: { name: string }) => e.name).sort();
|
|
expect(names).toEqual(['Alice Bench', 'Alice Squat']);
|
|
});
|
|
|
|
it('filters by query string substring', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
await prisma.exercise.createMany({
|
|
data: [
|
|
{ userId: alice.id, name: 'Bench Press', type: 'barbell', muscleGroups: '[]' },
|
|
{ userId: alice.id, name: 'Cable Bench Fly', type: 'cable', muscleGroups: '[]' },
|
|
{ userId: alice.id, name: 'Squat', type: 'barbell', muscleGroups: '[]' },
|
|
],
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await getExercises(
|
|
jsonReq('http://x/api/exercises?q=Bench'),
|
|
);
|
|
const body = await res.json();
|
|
expect(body.map((e: { name: string }) => e.name).sort()).toEqual([
|
|
'Bench Press',
|
|
'Cable Bench Fly',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/exercises', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
getCurrentUserMock.mockResolvedValue(null);
|
|
const res = await postExercise(
|
|
jsonReq('http://x/api/exercises', {
|
|
name: 'X',
|
|
type: 'barbell',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('creates a new exercise and stamps isCustom=true', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postExercise(
|
|
jsonReq('http://x/api/exercises', {
|
|
name: 'My Custom Exercise',
|
|
type: 'machine',
|
|
muscleGroups: ['legs'],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const ex = await prisma.exercise.findUnique({
|
|
where: { userId_name: { userId: alice.id, name: 'My Custom Exercise' } },
|
|
});
|
|
expect(ex).toBeTruthy();
|
|
expect(ex?.isCustom).toBe(true);
|
|
});
|
|
|
|
it('rejects duplicates with 400', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
await prisma.exercise.create({
|
|
data: {
|
|
userId: alice.id,
|
|
name: 'Duplicate',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postExercise(
|
|
jsonReq('http://x/api/exercises', {
|
|
name: 'Duplicate',
|
|
type: 'barbell',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/already exists/i);
|
|
});
|
|
|
|
it('defaults inputFields based on type (cardio gets duration+calories)', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
await postExercise(
|
|
jsonReq('http://x/api/exercises', {
|
|
name: 'Treadmill Run',
|
|
type: 'cardio',
|
|
}),
|
|
);
|
|
const ex = await prisma.exercise.findUnique({
|
|
where: { userId_name: { userId: alice.id, name: 'Treadmill Run' } },
|
|
});
|
|
expect(JSON.parse(ex!.inputFields)).toEqual([
|
|
'sets',
|
|
'duration',
|
|
'calories',
|
|
]);
|
|
});
|
|
|
|
it('returns 400 with details on validation failure', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postExercise(
|
|
jsonReq('http://x/api/exercises', {
|
|
name: '',
|
|
type: 'barbell',
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/validation/i);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/workouts', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
getCurrentUserMock.mockResolvedValue(null);
|
|
const res = await getWorkouts(
|
|
jsonReq('http://x/api/workouts'),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns only workouts owned by the requesting user', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bob = await makeUser({ email: 'b@x' });
|
|
await prisma.workout.createMany({
|
|
data: [
|
|
{ userId: alice.id, date: new Date(), name: 'A1' },
|
|
{ userId: alice.id, date: new Date(), name: 'A2' },
|
|
{ userId: bob.id, date: new Date(), name: 'B1' },
|
|
],
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await getWorkouts(
|
|
jsonReq('http://x/api/workouts'),
|
|
);
|
|
const body = await res.json();
|
|
expect(body.data.length).toBe(2);
|
|
expect(body.data.every((w: { name: string }) => w.name?.startsWith('A'))).toBe(
|
|
true,
|
|
);
|
|
expect(body.meta.total).toBe(2);
|
|
});
|
|
|
|
it('omits soft-deleted workouts (deletedAt set)', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
await prisma.workout.create({
|
|
data: { userId: alice.id, date: new Date(), name: 'visible' },
|
|
});
|
|
await prisma.workout.create({
|
|
data: {
|
|
userId: alice.id,
|
|
date: new Date(),
|
|
name: 'tombstoned',
|
|
deletedAt: new Date(),
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await getWorkouts(
|
|
jsonReq('http://x/api/workouts'),
|
|
);
|
|
const body = await res.json();
|
|
expect(body.data.map((w: { name: string }) => w.name)).toEqual(['visible']);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/workouts', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
getCurrentUserMock.mockResolvedValue(null);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', { name: 'X' }),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('creates an empty workout with just a date', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', {
|
|
name: 'Today',
|
|
durationMinutes: 45,
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const workout = await res.json();
|
|
expect(workout.name).toBe('Today');
|
|
expect(workout.durationMinutes).toBe(45);
|
|
expect(workout.userId).toBe(alice.id);
|
|
});
|
|
|
|
it('creates a workout with sets attached and persists set fields', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bench = await prisma.exercise.create({
|
|
data: {
|
|
userId: alice.id,
|
|
name: 'Bench Press',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', {
|
|
name: 'Push Day',
|
|
sets: [
|
|
{
|
|
exerciseId: bench.id,
|
|
setNumber: 1,
|
|
reps: 5,
|
|
weight: 225,
|
|
weightUnit: 'lbs',
|
|
rpe: 7,
|
|
},
|
|
{
|
|
exerciseId: bench.id,
|
|
setNumber: 2,
|
|
reps: 5,
|
|
weight: 245,
|
|
weightUnit: 'lbs',
|
|
rpe: 8,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.setLogs).toHaveLength(2);
|
|
expect(body.setLogs[0].weight).toBe(225);
|
|
expect(body.setLogs[1].rpe).toBe(8);
|
|
});
|
|
|
|
it('persists avg. watts as a first-class set field (assault bike)', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bike = await prisma.exercise.create({
|
|
data: {
|
|
userId: alice.id,
|
|
name: 'Assault Bike',
|
|
type: 'assault bike',
|
|
muscleGroups: '[]',
|
|
inputFields: '["sets","duration","distance","calories","watts","notes"]',
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', {
|
|
name: 'Conditioning',
|
|
sets: [
|
|
{
|
|
exerciseId: bike.id,
|
|
setNumber: 1,
|
|
durationSeconds: 600,
|
|
distance: 2.5,
|
|
distanceUnit: 'mi',
|
|
calories: 120,
|
|
watts: 157,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.setLogs).toHaveLength(1);
|
|
expect(body.setLogs[0].watts).toBe(157);
|
|
// And it round-trips out of the DB, not just the response.
|
|
const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } });
|
|
expect(stored?.watts).toBe(157);
|
|
});
|
|
|
|
it('persists gear (cardio breathing effort) on a set', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bike = await prisma.exercise.create({
|
|
data: {
|
|
userId: alice.id,
|
|
name: 'Assault Bike',
|
|
type: 'assault bike',
|
|
muscleGroups: '["cardio"]',
|
|
inputFields: '["sets","duration","distance","calories","watts","notes"]',
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', {
|
|
name: 'Conditioning',
|
|
sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 3 }],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.setLogs[0].gear).toBe(3);
|
|
const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } });
|
|
expect(stored?.gear).toBe(3);
|
|
});
|
|
|
|
it('rejects gear outside 1-5 via Zod with 400', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bike = await prisma.exercise.create({
|
|
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '["cardio"]' },
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', {
|
|
sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 7 }],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('rejects negative reps via Zod with 400', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bench = await prisma.exercise.create({
|
|
data: {
|
|
userId: alice.id,
|
|
name: 'Bench Press',
|
|
type: 'barbell',
|
|
muscleGroups: '[]',
|
|
},
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', {
|
|
sets: [
|
|
{ exerciseId: bench.id, setNumber: 1, reps: -1, weightUnit: 'lbs' },
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/invalid/i);
|
|
});
|
|
|
|
it("rejects a set referencing another user's exerciseId with 400", async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bob = await makeUser({ email: 'b@x' });
|
|
const bobExercise = await prisma.exercise.create({
|
|
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postWorkout(
|
|
jsonReq('http://x/api/workouts', {
|
|
name: 'Steal attempt',
|
|
sets: [
|
|
{ exerciseId: bobExercise.id, setNumber: 1, reps: 5, weightUnit: 'lbs' },
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/library/i);
|
|
// Nothing was written — the guard runs before any create.
|
|
expect(await prisma.workout.count()).toBe(0);
|
|
expect(await prisma.setLog.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/workouts/[id]/sets', () => {
|
|
it("rejects adding sets for another user's exerciseId with 400", async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bob = await makeUser({ email: 'b@x' });
|
|
const aliceWorkout = await prisma.workout.create({
|
|
data: { userId: alice.id, date: new Date(), name: 'Leg Day' },
|
|
});
|
|
const bobExercise = await prisma.exercise.create({
|
|
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postSets(
|
|
jsonReq('http://x/api/workouts/' + aliceWorkout.id + '/sets', {
|
|
exerciseId: bobExercise.id,
|
|
sets: [{ setNumber: 1, reps: 5, weightUnit: 'lbs' }],
|
|
}),
|
|
{ params: Promise.resolve({ id: aliceWorkout.id }) },
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/library/i);
|
|
expect(await prisma.setLog.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('PATCH /api/workouts/[id]', () => {
|
|
it("rejects replacing sets with another user's exerciseId (400, nothing written)", async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bob = await makeUser({ email: 'b@x' });
|
|
const aliceWorkout = await prisma.workout.create({
|
|
data: { userId: alice.id, date: new Date(), name: 'Day' },
|
|
});
|
|
const bobExercise = await prisma.exercise.create({
|
|
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await patchWorkout(
|
|
jsonReq(
|
|
'http://x/api/workouts/' + aliceWorkout.id,
|
|
{
|
|
sets: [
|
|
{ exerciseId: bobExercise.id, setNumber: 1, reps: 5, weightUnit: 'lbs' },
|
|
],
|
|
},
|
|
{ method: 'PATCH' },
|
|
),
|
|
{ params: Promise.resolve({ id: aliceWorkout.id }) },
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/library/i);
|
|
// The guard runs before the set-replace transaction.
|
|
expect(await prisma.setLog.count()).toBe(0);
|
|
});
|
|
|
|
it('persists avg. watts when replacing sets via PATCH', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bike = await prisma.exercise.create({
|
|
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '[]' },
|
|
});
|
|
const workout = await prisma.workout.create({
|
|
data: { userId: alice.id, date: new Date(), name: 'Cond' },
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await patchWorkout(
|
|
jsonReq(
|
|
'http://x/api/workouts/' + workout.id,
|
|
{
|
|
sets: [
|
|
{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, watts: 180 },
|
|
],
|
|
},
|
|
{ method: 'PATCH' },
|
|
),
|
|
{ params: Promise.resolve({ id: workout.id }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
|
|
expect(stored?.watts).toBe(180);
|
|
});
|
|
|
|
it('persists gear when replacing sets via PATCH', async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bike = await prisma.exercise.create({
|
|
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '["cardio"]' },
|
|
});
|
|
const workout = await prisma.workout.create({
|
|
data: { userId: alice.id, date: new Date(), name: 'Cond' },
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await patchWorkout(
|
|
jsonReq(
|
|
'http://x/api/workouts/' + workout.id,
|
|
{ sets: [{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, gear: 4 }] },
|
|
{ method: 'PATCH' },
|
|
),
|
|
{ params: Promise.resolve({ id: workout.id }) },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
|
|
expect(stored?.gear).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/workouts/import/save', () => {
|
|
it("rejects an existingExerciseId owned by another user (400)", async () => {
|
|
const alice = await makeUser({ email: 'a@x' });
|
|
const bob = await makeUser({ email: 'b@x' });
|
|
const bobExercise = await prisma.exercise.create({
|
|
data: { userId: bob.id, name: 'Bob Squat', type: 'barbell', muscleGroups: '[]' },
|
|
});
|
|
getCurrentUserMock.mockResolvedValue(alice);
|
|
const res = await postImportSave(
|
|
jsonReq('http://x/api/workouts/import/save', {
|
|
workouts: [
|
|
{
|
|
date: new Date().toISOString(),
|
|
exercises: [
|
|
{ name: 'Squat', existingExerciseId: bobExercise.id, sets: [{ reps: 5 }] },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/library/i);
|
|
expect(await prisma.workout.count()).toBe(0);
|
|
});
|
|
});
|