Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('GET /health', () => {
createApp({
authRouter: Router(),
exerciseRouter: Router(),
routineRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand All @@ -36,6 +37,7 @@ describe('GET /health', () => {
createApp({
authRouter: Router(),
exerciseRouter: Router(),
routineRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand All @@ -59,6 +61,7 @@ describe('GET /health', () => {
createApp({
authRouter: Router(),
exerciseRouter: Router(),
routineRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand All @@ -82,6 +85,7 @@ describe('GET /health', () => {
createApp({
authRouter: Router(),
exerciseRouter: Router(),
routineRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand All @@ -104,6 +108,7 @@ describe('JSON request parsing', () => {
return createApp({
authRouter: Router(),
exerciseRouter: Router(),
routineRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type AppDependencies = {
csrfErrorHandler: express.ErrorRequestHandler;
csrfProtection: RequestHandler;
exerciseRouter: Router;
routineRouter: Router;
sessionMiddleware: RequestHandler;
trustProxyHops: number;
webOrigin: string;
Expand All @@ -30,6 +31,7 @@ export function createApp({
csrfErrorHandler,
csrfProtection,
exerciseRouter,
routineRouter,
sessionMiddleware,
trustProxyHops,
webOrigin,
Expand All @@ -46,6 +48,7 @@ export function createApp({
app.use(csrfProtection);
app.use('/auth', authRouter);
app.use('/exercises', exerciseRouter);
app.use('/routines', routineRouter);

app.get('/health', async (_request, response) => {
try {
Expand Down
74 changes: 74 additions & 0 deletions apps/api/src/features/routines/routine.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/** Verifies authentication and response behavior for the routine 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 { createRoutineRouter } from './routine.routes.js';

const userId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486';
const routineList = [
{
id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120',
name: 'Upper Body',
description: null,
exerciseCount: 3,
scheduledDays: [1, 3, 5],
createdAt: '2026-08-24T18:00:00.000Z',
updatedAt: '2026-08-24T19:00:00.000Z',
},
];

function createTestApp(
listRoutines = vi.fn(() => Promise.resolve(routineList)),
) {
const app = express();
app.use(
createSessionMiddleware(
new session.MemoryStore(),
testSessionConfiguration,
),
);
app.use(establishAuthenticatedTestSession());
app.use('/routines', createRoutineRouter({ listRoutines }));

return { app, listRoutines };
}

describe('GET /routines', () => {
it('rejects anonymous requests', async () => {
const { app, listRoutines } = createTestApp();

const response = await request(app).get('/routines');

expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'authentication_required' });
expect(listRoutines).not.toHaveBeenCalled();
});

it('returns routine summaries for the authenticated user', async () => {
const { app, listRoutines } = createTestApp();

const response = await request(app)
.get('/routines')
.set('x-test-authenticated-user-id', userId);

expect(response.status).toBe(200);
expect(response.body).toEqual({ routines: routineList });
expect(listRoutines).toHaveBeenCalledWith(userId);
});

it('returns an empty list for a user without routines', async () => {
const { app } = createTestApp(vi.fn(() => Promise.resolve([])));

const response = await request(app)
.get('/routines')
.set('x-test-authenticated-user-id', userId);

expect(response.status).toBe(200);
expect(response.body).toEqual({ routines: [] });
});
});
34 changes: 34 additions & 0 deletions apps/api/src/features/routines/routine.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Defines authenticated routine-list API routes. */
import {
routineListResponseSchema,
type RoutineSummary,
} from '@goforlift/contracts';
import { Router } from 'express';

import {
getAuthenticatedUserId,
requireAuthentication,
} from '../auth/guards/authentication.middleware.js';

type RoutineRouterDependencies = {
listRoutines: (userId: string) => Promise<RoutineSummary[]>;
};

export function createRoutineRouter({
listRoutines,
}: RoutineRouterDependencies) {
const router = Router();

router.get('/', requireAuthentication, async (request, response, next) => {
try {
const userId = getAuthenticatedUserId(request);
const routines = await listRoutines(userId);

response.status(200).json(routineListResponseSchema.parse({ routines }));
} catch (error) {
next(error);
}
});

return router;
}
48 changes: 48 additions & 0 deletions apps/api/src/features/routines/routine.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/** Verifies owner-scoped routine summary selection and serialization. */
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { describe, expect, it, vi } from 'vitest';

import { routines } from '../../db/schema/index.js';
import { listRoutinesForUser } from './routine.service.js';

describe('listRoutinesForUser', () => {
it('returns serialized routine summaries from the owner-scoped query', async () => {
const createdAt = new Date('2026-08-24T18:00:00.000Z');
const updatedAt = new Date('2026-08-24T19:00:00.000Z');
const rows = [
{
id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120',
name: 'Upper Body',
description: null,
exerciseCount: 3,
scheduledDays: [5, 1, 3],
createdAt,
updatedAt,
},
];
const orderBy = vi.fn().mockResolvedValue(rows);
const groupBy = vi.fn(() => ({ orderBy }));
const where = vi.fn(() => ({ groupBy }));
const secondLeftJoin = vi.fn(() => ({ where }));
const firstLeftJoin = vi.fn(() => ({ leftJoin: secondLeftJoin }));
const from = vi.fn(() => ({ leftJoin: firstLeftJoin }));
const select = vi.fn(() => ({ from }));
const database = { select } as unknown as NodePgDatabase;
const userId = '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486';

const result = await listRoutinesForUser(database, userId);

expect(select).toHaveBeenCalledOnce();
expect(where).toHaveBeenCalledOnce();
expect(result).toEqual([
{
...rows[0],
scheduledDays: [1, 3, 5],
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
},
]);
expect(result[0]?.id).toBe(rows[0]?.id);
expect(routines.userId).toBeDefined();
});
});
53 changes: 53 additions & 0 deletions apps/api/src/features/routines/routine.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/** Lists browser-safe routine summaries owned by the authenticated user. */
import type { RoutineSummary } from '@goforlift/contracts';
import { asc, countDistinct, desc, eq, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';

import {
routineExercises,
routineSchedules,
routines,
} from '../../db/schema/index.js';

export async function listRoutinesForUser(
database: NodePgDatabase,
userId: string,
): Promise<RoutineSummary[]> {
const ownedRoutines = await database
.select({
id: routines.id,
name: routines.name,
description: routines.description,
exerciseCount: countDistinct(routineExercises.id),
scheduledDays: sql<number[]>`
coalesce(
array_agg(distinct ${routineSchedules.dayOfWeek})
filter (where ${routineSchedules.dayOfWeek} is not null),
array[]::smallint[]
)
`,
createdAt: routines.createdAt,
updatedAt: routines.updatedAt,
})
.from(routines)
.leftJoin(routineExercises, eq(routineExercises.routineId, routines.id))
.leftJoin(routineSchedules, eq(routineSchedules.routineId, routines.id))
.where(eq(routines.userId, userId))
.groupBy(
routines.id,
routines.name,
routines.description,
routines.createdAt,
routines.updatedAt,
)
.orderBy(desc(routines.updatedAt), asc(routines.name));

return ownedRoutines.map((routine) => ({
...routine,
scheduledDays: [...routine.scheduledDays].sort(
(first, second) => first - second,
),
createdAt: routine.createdAt.toISOString(),
updatedAt: routine.updatedAt.toISOString(),
}));
}
7 changes: 7 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
createExerciseForUser,
listExercisesForUser,
} from './features/exercises/exercise.service.js';
import { createRoutineRouter } from './features/routines/routine.routes.js';
import { listRoutinesForUser } from './features/routines/routine.service.js';

const { db, pool } = createDatabase(env.DATABASE_URL);
const { middleware: sessionMiddleware, store: sessionStore } =
Expand Down Expand Up @@ -50,11 +52,16 @@ const exerciseRouter = createExerciseRouter({
listExercises: (userId) => listExercisesForUser(db, userId),
});

const routineRouter = createRoutineRouter({
listRoutines: (userId) => listRoutinesForUser(db, userId),
});

const app = createApp({
authRouter,
csrfErrorHandler,
csrfProtection,
exerciseRouter,
routineRouter,
webOrigin: env.WEB_ORIGIN,
trustProxyHops: env.TRUST_PROXY_HOPS,
sessionMiddleware,
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from './auth/auth.contract.js';
export * from './exercises/exercise.contract.js';
export * from './health/health.contract.js';
export * from './routines/routine.contract.js';
50 changes: 50 additions & 0 deletions packages/contracts/src/routines/routine.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** Verifies routine-list contracts accept public data and reject malformed payloads. */
import { describe, expect, it } from 'vitest';

import {
routineListResponseSchema,
routineSummarySchema,
} from './routine.contract.js';

const routineSummary = {
id: 'c38794ef-9a4a-40d8-8cdc-7f7416a53120',
name: 'Upper Body',
description: null,
exerciseCount: 3,
scheduledDays: [1, 3, 5],
createdAt: '2026-08-24T18:00:00.000Z',
updatedAt: '2026-08-24T19:00:00.000Z',
};

describe('routine contracts', () => {
it('accepts populated and empty routine lists', () => {
expect(
routineListResponseSchema.safeParse({ routines: [routineSummary] })
.success,
).toBe(true);
expect(routineListResponseSchema.safeParse({ routines: [] }).success).toBe(
true,
);
});

it('rejects malformed routine summaries', () => {
expect(
routineSummarySchema.safeParse({
...routineSummary,
exerciseCount: -1,
}).success,
).toBe(false);
expect(
routineSummarySchema.safeParse({
...routineSummary,
scheduledDays: [7],
}).success,
).toBe(false);
expect(
routineSummarySchema.safeParse({
...routineSummary,
createdAt: 'not-a-date',
}).success,
).toBe(false);
});
});
19 changes: 19 additions & 0 deletions packages/contracts/src/routines/routine.contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Defines browser-safe routine-list contracts shared across applications. */
import { z } from 'zod';

export const routineSummarySchema = z.object({
id: z.uuid(),
name: z.string().min(1),
description: z.string().nullable(),
exerciseCount: z.number().int().nonnegative(),
scheduledDays: z.array(z.number().int().min(0).max(6)),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
});

export const routineListResponseSchema = z.object({
routines: z.array(routineSummarySchema),
});

export type RoutineSummary = z.infer<typeof routineSummarySchema>;
export type RoutineListResponse = z.infer<typeof routineListResponseSchema>;