diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index b605ade..22360f4 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -17,6 +17,7 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + exerciseRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', @@ -34,6 +35,7 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + exerciseRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', @@ -56,6 +58,7 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + exerciseRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', @@ -78,6 +81,7 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + exerciseRouter: Router(), csrfErrorHandler: noOpErrorMiddleware, csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 695cd07..d0240f0 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,6 +8,7 @@ type AppDependencies = { checkDatabaseConnection: () => Promise; csrfErrorHandler: express.ErrorRequestHandler; csrfProtection: RequestHandler; + exerciseRouter: Router; sessionMiddleware: RequestHandler; trustProxyHops: number; webOrigin: string; @@ -18,6 +19,7 @@ export function createApp({ checkDatabaseConnection, csrfErrorHandler, csrfProtection, + exerciseRouter, sessionMiddleware, trustProxyHops, webOrigin, @@ -33,6 +35,7 @@ export function createApp({ app.use(express.json()); app.use(csrfProtection); app.use('/auth', authRouter); + app.use('/exercises', exerciseRouter); app.get('/health', async (_request, response) => { try { diff --git a/apps/api/src/exercises/exercise.routes.test.ts b/apps/api/src/exercises/exercise.routes.test.ts new file mode 100644 index 0000000..dd56374 --- /dev/null +++ b/apps/api/src/exercises/exercise.routes.test.ts @@ -0,0 +1,66 @@ +/** Verifies authentication and response behavior for the exercise list route. */ +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; + +import { createSessionMiddleware } from '../auth/session/session.middleware.js'; +import { establishAuthenticatedTestSession } from '../test/authenticated-session.test-helper.js'; +import { testSessionConfiguration } from '../test/fixtures/session-configuration.fixture.js'; +import { createExerciseRouter } from './exercise.routes.js'; + +const userId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486'; +const exerciseList = [ + { + id: 'bde77251-e433-4f39-b1cb-41a2f2ad5462', + name: 'Bench Press', + description: 'Press a barbell from chest level.', + isCustom: false, + }, + { + id: 'f29f209d-d1f9-4988-b693-69b291917b0f', + name: 'Custom Carry', + description: null, + isCustom: true, + }, +]; + +function createTestApp( + listExercises = vi.fn(() => Promise.resolve(exerciseList)), +) { + const app = express(); + app.use( + createSessionMiddleware( + new session.MemoryStore(), + testSessionConfiguration, + ), + ); + app.use(establishAuthenticatedTestSession()); + app.use('/exercises', createExerciseRouter({ listExercises })); + + return { app, listExercises }; +} + +describe('GET /exercises', () => { + it('rejects anonymous requests', async () => { + const { app, listExercises } = createTestApp(); + + const response = await request(app).get('/exercises'); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'authentication_required' }); + expect(listExercises).not.toHaveBeenCalled(); + }); + + it('returns exercises visible to the authenticated user', async () => { + const { app, listExercises } = createTestApp(); + + const response = await request(app) + .get('/exercises') + .set('x-test-authenticated-user-id', userId); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ exercises: exerciseList }); + expect(listExercises).toHaveBeenCalledWith(userId); + }); +}); diff --git a/apps/api/src/exercises/exercise.routes.ts b/apps/api/src/exercises/exercise.routes.ts new file mode 100644 index 0000000..6c90a07 --- /dev/null +++ b/apps/api/src/exercises/exercise.routes.ts @@ -0,0 +1,38 @@ +/** Defines authenticated exercise-list API routes. */ +import { + exerciseListResponseSchema, + type ExerciseSummary, +} from '@goforlift/contracts'; +import { Router } from 'express'; + +import { + getAuthenticatedUserId, + requireAuthentication, +} from '../auth/guards/authentication.middleware.js'; + +type ExerciseRouterDependencies = { + listExercises: (userId: string) => Promise; +}; + +export function createExerciseRouter({ + listExercises, +}: ExerciseRouterDependencies) { + const router = Router(); + + router.get('/', requireAuthentication, async (request, response, next) => { + try { + const userId = getAuthenticatedUserId(request); + const exercises = await listExercises(userId); + + response.status(200).json( + exerciseListResponseSchema.parse({ + exercises, + }), + ); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/apps/api/src/exercises/exercise.service.test.ts b/apps/api/src/exercises/exercise.service.test.ts new file mode 100644 index 0000000..c2d2ed6 --- /dev/null +++ b/apps/api/src/exercises/exercise.service.test.ts @@ -0,0 +1,56 @@ +/** Verifies that exercise listing applies ownership visibility rules. */ +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { describe, expect, it, vi } from 'vitest'; + +import { exercises } from '../db/schema/index.js'; +import { listExercisesForUser } from './exercise.service.js'; + +describe('listExercisesForUser', () => { + it('selects the safe projection and identifies custom exercises', async () => { + const builtInExercise = { + id: 'bde77251-e433-4f39-b1cb-41a2f2ad5462', + name: 'Bench Press', + description: null, + ownerUserId: null, + }; + const customExercise = { + id: 'f29f209d-d1f9-4988-b693-69b291917b0f', + name: 'Custom Carry', + description: 'A custom loaded carry.', + ownerUserId: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + }; + const rows = [builtInExercise, customExercise]; + const orderBy = vi.fn().mockResolvedValue(rows); + const where = vi.fn(() => ({ orderBy })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + const database = { select } as unknown as NodePgDatabase; + + const result = await listExercisesForUser( + database, + '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486', + ); + + expect(select).toHaveBeenCalledWith({ + id: exercises.id, + name: exercises.name, + description: exercises.description, + ownerUserId: exercises.ownerUserId, + }); + expect(where).toHaveBeenCalledOnce(); + expect(result).toEqual([ + { + id: builtInExercise.id, + name: builtInExercise.name, + description: builtInExercise.description, + isCustom: false, + }, + { + id: customExercise.id, + name: customExercise.name, + description: customExercise.description, + isCustom: true, + }, + ]); + }); +}); diff --git a/apps/api/src/exercises/exercise.service.ts b/apps/api/src/exercises/exercise.service.ts new file mode 100644 index 0000000..13b801b --- /dev/null +++ b/apps/api/src/exercises/exercise.service.ts @@ -0,0 +1,27 @@ +/** Lists exercises visible to the authenticated user. */ +import type { ExerciseSummary } from '@goforlift/contracts'; +import { asc, eq, isNull, or } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { exercises } from '../db/schema/index.js'; + +export async function listExercisesForUser( + database: NodePgDatabase, + userId: string, +): Promise { + const visibleExercises = await database + .select({ + id: exercises.id, + name: exercises.name, + description: exercises.description, + ownerUserId: exercises.ownerUserId, + }) + .from(exercises) + .where(or(isNull(exercises.ownerUserId), eq(exercises.ownerUserId, userId))) + .orderBy(asc(exercises.name)); + + return visibleExercises.map(({ ownerUserId, ...exercise }) => ({ + ...exercise, + isCustom: ownerUserId !== null, + })); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 5b3872e..b79740b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -17,6 +17,8 @@ import { createSessionRouter } from './auth/session/session.routes.js'; import { findPublicUserById } from './auth/user/current-user.service.js'; import { env } from './config/env.js'; import { createDatabase } from './db/client.js'; +import { createExerciseRouter } from './exercises/exercise.routes.js'; +import { listExercisesForUser } from './exercises/exercise.service.js'; const { db, pool } = createDatabase(env.DATABASE_URL); const { middleware: sessionMiddleware, store: sessionStore } = @@ -31,6 +33,7 @@ authRouter.use( provisionUser: (profile) => provisionGoogleUser(db, profile), }), ); + authRouter.use( createSessionRouter({ cookieName: env.SESSION_COOKIE.name, @@ -38,10 +41,16 @@ authRouter.use( findPublicUser: (userId) => findPublicUserById(db, userId), }), ); + +const exerciseRouter = createExerciseRouter({ + listExercises: (userId) => listExercisesForUser(db, userId), +}); + const app = createApp({ authRouter, csrfErrorHandler, csrfProtection, + exerciseRouter, webOrigin: env.WEB_ORIGIN, trustProxyHops: env.TRUST_PROXY_HOPS, sessionMiddleware, diff --git a/packages/contracts/src/exercises/exercise.contract.test.ts b/packages/contracts/src/exercises/exercise.contract.test.ts new file mode 100644 index 0000000..2c8f29a --- /dev/null +++ b/packages/contracts/src/exercises/exercise.contract.test.ts @@ -0,0 +1,41 @@ +/** Verifies exercise-list contracts accept public data and reject malformed payloads. */ +import { describe, expect, it } from 'vitest'; + +import { + exerciseListResponseSchema, + exerciseSummarySchema, +} from './exercise.contract.js'; + +describe('exercise contracts', () => { + it('accepts built-in and custom exercise summaries', () => { + const result = exerciseListResponseSchema.safeParse({ + exercises: [ + { + id: '9f4b5a8e-2c3d-4f10-8a11-000000000001', + name: 'Bicep Curl', + description: null, + isCustom: false, + }, + { + id: '0d10f788-a39b-4f97-9927-5b28fec83aef', + name: 'My Custom Press', + description: 'A custom pressing movement.', + isCustom: true, + }, + ], + }); + + expect(result.success).toBe(true); + }); + + it('rejects malformed exercise summaries', () => { + const result = exerciseSummarySchema.safeParse({ + id: 'not-a-uuid', + name: '', + description: null, + isCustom: 'yes', + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/contracts/src/exercises/exercise.contract.ts b/packages/contracts/src/exercises/exercise.contract.ts new file mode 100644 index 0000000..162f991 --- /dev/null +++ b/packages/contracts/src/exercises/exercise.contract.ts @@ -0,0 +1,16 @@ +/** Defines browser-safe exercise-list contracts shared across applications. */ +import { z } from 'zod'; + +export const exerciseSummarySchema = z.object({ + id: z.uuid(), + name: z.string().min(1), + description: z.string().nullable(), + isCustom: z.boolean(), +}); + +export const exerciseListResponseSchema = z.object({ + exercises: z.array(exerciseSummarySchema), +}); + +export type ExerciseSummary = z.infer; +export type ExerciseListResponse = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index de7ec6d..5435c08 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ /** Exposes the public runtime-validated contracts shared across GoForLift. */ export * from './auth/auth.contract.js'; +export * from './exercises/exercise.contract.js'; export * from './health/health.contract.js';