Sessions UI, CSV parser tests, route tests, composite indexes, verify-db action
Per-user sessions UI (Settings -> Active sessions) - listMySessions returns the current user's still-valid sessions with last-8-char token suffix (UX hint) and an isCurrent flag (the authoritative "this device" marker). - revokeSession refuses if the target is the actor's current token — use Sign out for that flow. Per-row Revoke button on every other. - revokeAllOtherSessions = the previously-internal `deleteOtherSessions` helper exposed as a single button "Sign out other devices". - All gated to the actor's own userId (never lets a user touch another user's sessions). CSV parser refactor + tests - Extracted parseCSV, NAME_MAP, parseFloatMaybe, parseIntMaybe, getVariationNote, resolveExerciseName, parseDate from app/api/import/parse/route.ts to lib/csvParser.ts. Behavior byte-identical; route is now a thin wrapper that imports from the lib. - 18 tests covering: empty input, simple rows, lowercased headers, quoted-field commas, escaped double quotes, CRLF normalization, empty-line handling; numeric maybe-parsers; getVariationNote known patterns + null pass-through; ALL 27 NAME_MAP entries map to their canonical target; named CSV-shorthand examples; M/D/YYYY + ISO date parsing with noon-UTC anchoring (so US negative-offset zones still see the same calendar day). Workout + exercise CRUD route tests - New tests/routes-crud.test.ts: GET/POST /api/exercises, GET/POST /api/workouts. 401 on unauthenticated, per-user data isolation, query filtering, soft-delete exclusion, isCustom stamping, duplicate detection, type-driven inputFields defaults (cardio gets duration+calories), Zod validation rejection, set creation with weight/reps/rpe persisted, negative-reps rejected. - Helper builds NextRequest objects so the routes' nextUrl.searchParams access works. Composite indexes for hot query paths (schema.prisma + entrypoint) - Session: (userId, expiresAt) for "list my still-valid sessions" and per-user cleanup. - Workout: (userId, deletedAt, date) for the workout list query (filter by user + alive + date order). - SetLog: (workoutId, setNumber) for the always-ordered set fetch under each workout. - Existing single-column indexes kept; composites are additive. - Entrypoint runs CREATE INDEX IF NOT EXISTS so live snapshots pick up the new indexes on first boot after upgrade. verify-database StartOS action (start9/0.4/startos/actions/verifyDatabase.ts) - Read-only. Runs PRAGMA integrity_check + quick_check + row-count queries against /data/app.db, reports as a structured result. - allowedStatuses: only-running. Mounts the volume read-only. - Use after a StartOS Backup, after a host crash, or after a fresh sideload to confirm the data is sound before relying on it. Test suite now 67 tests across 7 files in ~2.4s.
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
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';
|
||||
|
||||
// `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('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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user