From 41b51849b7df5ecd2e5587c02be92b031cba6da0 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 26 Jul 2026 20:34:06 +0200 Subject: [PATCH] feat(server): adopt zod DTOs for the assignment body contracts --- .../assignments/assignments.controller.ts | 44 +++---- .../src/nest/assignments/assignments.dto.ts | 23 ++++ .../nest/common/body-contract-allow-list.ts | 6 - server/tests/e2e/assignments.e2e.test.ts | 107 ++++++++++++------ .../unit/nest/assignments.controller.test.ts | 15 +-- .../src/assignment/assignment.schema.spec.ts | 14 ++- shared/src/assignment/assignment.schema.ts | 17 ++- 7 files changed, 153 insertions(+), 73 deletions(-) create mode 100644 server/src/nest/assignments/assignments.dto.ts diff --git a/server/src/nest/assignments/assignments.controller.ts b/server/src/nest/assignments/assignments.controller.ts index e21819c9b..f003f2781 100644 --- a/server/src/nest/assignments/assignments.controller.ts +++ b/server/src/nest/assignments/assignments.controller.ts @@ -12,6 +12,14 @@ import { } from '@nestjs/common'; import type { User } from '../../types'; import { AssignmentsService } from './assignments.service'; +import { + AssignmentCreateDto, + AssignmentReorderDto, + AssignmentMoveDto, + AssignmentTimeDto, + AssignmentTransportDto, + AssignmentParticipantsDto, +} from './assignments.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; @@ -35,10 +43,11 @@ function requireEdit(svc: AssignmentsService, trip: Trip, user: User): void { /** * /api/trips/:tripId/days/:dayId/assignments — the day's ordered itinerary items. * - * Byte-identical to the legacy Express route (server/src/routes/assignments.ts): - * trip access (404), 'day_edit' on mutations (403, GET is access-only), create - * 201 / rest 200, the bespoke "Day not found" / "Place not found" / "Assignment - * not found" bodies, the journey skeleton reconcile, and WebSocket broadcasts. + * Parity with the retired legacy Express route: trip access (404), 'day_edit' + * on mutations (403, GET is access-only), create 201 / rest 200, the bespoke + * "Day not found" / "Place not found" / "Assignment not found" bodies, the + * journey skeleton reconcile, and WebSocket broadcasts. Bodies are validated + * by the @trek/shared Zod contracts via assignments.dto.ts (global pipe). */ @Controller('api/trips/:tripId/days/:dayId/assignments') @UseGuards(JwtAuthGuard) @@ -59,7 +68,7 @@ export class DayAssignmentsController { @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string, - @Body() body: { place_id?: unknown; notes?: string | null }, + @Body() body: AssignmentCreateDto, @Headers('x-socket-id') socketId?: string, ) { const trip = requireTrip(this.assignments, tripId, user); @@ -81,7 +90,7 @@ export class DayAssignmentsController { @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('dayId') dayId: string, - @Body('orderedIds') orderedIds: number[], + @Body() body: AssignmentReorderDto, @Headers('x-socket-id') socketId?: string, ) { const trip = requireTrip(this.assignments, tripId, user); @@ -89,8 +98,8 @@ export class DayAssignmentsController { if (!this.assignments.dayExists(dayId, tripId)) { throw new HttpException({ error: 'Day not found' }, 404); } - this.assignments.reorderAssignments(dayId, orderedIds); - this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, socketId); + this.assignments.reorderAssignments(dayId, body.orderedIds); + this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds: body.orderedIds }, socketId); return { success: true }; } @@ -128,20 +137,18 @@ export class AssignmentOpsController { @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, - @Body() body: { new_day_id?: unknown; order_index?: number }, + @Body() body: AssignmentMoveDto, @Headers('x-socket-id') socketId?: string, ) { const trip = requireTrip(this.assignments, tripId, user); requireEdit(this.assignments, trip, user); - const existing = this.assignments.getAssignmentForTrip(id, tripId); - if (!existing) { + if (!this.assignments.getAssignmentForTrip(id, tripId)) { throw new HttpException({ error: 'Assignment not found' }, 404); } if (!this.assignments.dayExists(String(body.new_day_id), tripId)) { throw new HttpException({ error: 'Target day not found' }, 404); } - const oldDayId = (existing as { day_id: number }).day_id; - const { assignment } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index, oldDayId); + const { assignment, oldDayId } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index); this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId); this.assignments.reconcile(tripId, socketId); return { assignment }; @@ -158,7 +165,7 @@ export class AssignmentOpsController { @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, - @Body() body: { place_time?: string | null; end_time?: string | null }, + @Body() body: AssignmentTimeDto, @Headers('x-socket-id') socketId?: string, ) { const trip = requireTrip(this.assignments, tripId, user); @@ -177,7 +184,7 @@ export class AssignmentOpsController { @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, - @Body() body: { transport_mode?: string | null }, + @Body() body: AssignmentTransportDto, @Headers('x-socket-id') socketId?: string, ) { const trip = requireTrip(this.assignments, tripId, user); @@ -195,15 +202,12 @@ export class AssignmentOpsController { @CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, - @Body('user_ids') userIds: unknown, + @Body() body: AssignmentParticipantsDto, @Headers('x-socket-id') socketId?: string, ) { const trip = requireTrip(this.assignments, tripId, user); requireEdit(this.assignments, trip, user); - if (!Array.isArray(userIds)) { - throw new HttpException({ error: 'user_ids must be an array' }, 400); - } - const participants = this.assignments.setParticipants(id, userIds); + const participants = this.assignments.setParticipants(id, body.user_ids); this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId); return { participants }; } diff --git a/server/src/nest/assignments/assignments.dto.ts b/server/src/nest/assignments/assignments.dto.ts new file mode 100644 index 000000000..b5197a4da --- /dev/null +++ b/server/src/nest/assignments/assignments.dto.ts @@ -0,0 +1,23 @@ +import { createZodDto } from 'nestjs-zod'; +import { + assignmentCreateRequestSchema, + assignmentReorderRequestSchema, + assignmentMoveRequestSchema, + assignmentTimeRequestSchema, + assignmentTransportRequestSchema, + assignmentParticipantsRequestSchema, +} from '@trek/shared'; + +/** + * Server-side createZodDto wrappers over the @trek/shared assignment + * contracts. The global ZodValidationPipe (APP_PIPE in app.module.ts) + * validates any @Body() parameter typed with one of these classes by + * metatype — the Zod schemas in shared/ remain the single source of truth for + * the wire contract. + */ +export class AssignmentCreateDto extends createZodDto(assignmentCreateRequestSchema) {} +export class AssignmentReorderDto extends createZodDto(assignmentReorderRequestSchema) {} +export class AssignmentMoveDto extends createZodDto(assignmentMoveRequestSchema) {} +export class AssignmentTimeDto extends createZodDto(assignmentTimeRequestSchema) {} +export class AssignmentTransportDto extends createZodDto(assignmentTransportRequestSchema) {} +export class AssignmentParticipantsDto extends createZodDto(assignmentParticipantsRequestSchema) {} diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index 0f5223f33..bd711e9f3 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -30,10 +30,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [ 'AdminController.updateTemplateCategory', 'AdminController.updateTemplateItem', 'AdminController.updateUser', - 'AssignmentOpsController.move', - 'AssignmentOpsController.setParticipants', - 'AssignmentOpsController.time', - 'AssignmentOpsController.transport', 'AtlasController.createBucketItem', 'AtlasController.markRegion', 'AtlasController.updateBucketItem', @@ -74,8 +70,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [ 'CollabController.votePoll', 'CollectionsController.deleteMany', 'CollectionsController.reorder', - 'DayAssignmentsController.create', - 'DayAssignmentsController.reorder', 'DaysController.create', 'DaysController.reorder', 'DaysController.transport', diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index c0c67f1d2..d725c2207 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -1,7 +1,8 @@ /** * Assignments module e2e — exercises both migrated controllers through the real - * JwtAuthGuard against a temp SQLite db. assignmentService, journeyService, - * the permission check, canAccessTrip and the WebSocket broadcast are mocked. + * JwtAuthGuard against a temp SQLite db. AssignmentsService runs its real SQL + * (DI-injected, no service mock); journeyService, the permission check, + * canAccessTrip and the WebSocket broadcast stay mocked. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -16,8 +17,26 @@ const { db } = vi.hoisted(() => { const Database = require('better-sqlite3'); const tmp = new Database(':memory:'); tmp.exec('PRAGMA journal_mode = WAL'); + // Only what the un-mocked DI service actually touches: the auth guard reads + // users; AssignmentsService's real SQL reads/writes the itinerary tables + // (display_name/avatar feed the participants COALESCE projection). tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, + display_name TEXT, avatar TEXT);`); + tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL);`); + tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`); + tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, + description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, + place_time TEXT, end_time TEXT, duration_minutes INTEGER DEFAULT 60, notes TEXT, image_url TEXT, + transport_mode TEXT DEFAULT 'walking', google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT);`); + tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, + place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT, + assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT, + created_at TEXT DEFAULT (datetime('now')));`); + tmp.exec(`CREATE TABLE assignment_participants (assignment_id INTEGER NOT NULL, user_id INTEGER NOT NULL, + UNIQUE(assignment_id, user_id));`); + tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at TEXT);`); + tmp.exec(`CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL);`); return { db: tmp }; }); @@ -33,17 +52,9 @@ vi.mock('../../src/services/journeyService', () => ({ reconcileTripSkeletons })) const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); vi.mock('../../src/services/permissions', () => ({ checkPermission })); -const { asg } = vi.hoisted(() => ({ - asg: { - getAssignmentWithPlace: vi.fn(), listDayAssignments: vi.fn(), dayExists: vi.fn(), placeExists: vi.fn(), - createAssignment: vi.fn(), assignmentExistsInDay: vi.fn(), deleteAssignment: vi.fn(), reorderAssignments: vi.fn(), - getAssignmentForTrip: vi.fn(), moveAssignment: vi.fn(), getParticipants: vi.fn(), updateTime: vi.fn(), setParticipants: vi.fn(), - }, -})); -vi.mock('../../src/services/assignmentService', () => asg); - import { AssignmentsModule } from '../../src/nest/assignments/assignments.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; describe('Assignments e2e (real auth guard + temp SQLite)', () => { let server: Server; @@ -53,6 +64,7 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AssignmentsModule] }).compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); nest.useGlobalFilters(new TrekExceptionFilter()); await nest.init(); return nest; @@ -60,41 +72,50 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { beforeAll(async () => { seedUser(db as never, { id: 1 }); + seedUser(db as never, { id: 2, username: 'peer', email: 'peer@example.test' }); + db.prepare('INSERT INTO days (id, trip_id) VALUES (3, 5), (4, 5)').run(); + db.prepare('INSERT INTO places (id, trip_id, name) VALUES (2, 5, ?)').run('Louvre'); app = await build(); server = app.getHttpServer(); - asg.listDayAssignments.mockReturnValue([{ id: 1 }]); - asg.createAssignment.mockReturnValue({ id: 9 }); - asg.getParticipants.mockReturnValue([{ user_id: 2 }]); }); beforeEach(() => { canAccessTrip.mockReturnValue({ id: 5, user_id: 1 }); checkPermission.mockReturnValue(true); - asg.dayExists.mockReturnValue(true); - asg.placeExists.mockReturnValue(true); + db.prepare('DELETE FROM day_assignments').run(); + db.prepare('DELETE FROM assignment_participants').run(); }); afterAll(async () => { await app.close(); }); + const seedAssignment = (dayId = 3, placeId = 2, orderIndex = 0) => + Number(db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)').run(dayId, placeId, orderIndex).lastInsertRowid); + it('401 without a cookie', async () => { expect((await request(server).get('/api/trips/5/days/3/assignments')).status).toBe(401); }); it('200 list day-assignments', async () => { + const id = seedAssignment(); const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ assignments: [{ id: 1 }] }); + expect(res.body.assignments).toHaveLength(1); + expect(res.body.assignments[0]).toMatchObject({ + id, day_id: 3, place_id: 2, order_index: 0, participants: [], + place: { id: 2, name: 'Louvre', tags: [] }, + }); }); it('201 create, 404 place', async () => { reconcileTripSkeletons.mockClear(); const ok = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 2 }); expect(ok.status).toBe(201); - expect(ok.body).toEqual({ assignment: { id: 9 } }); + expect(ok.body.assignment).toMatchObject({ day_id: 3, place_id: 2, order_index: 0, notes: null, place: { id: 2, name: 'Louvre' } }); + const row = db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(ok.body.assignment.id); + expect(row).toMatchObject({ day_id: 3, place_id: 2, order_index: 0 }); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); - asg.placeExists.mockReturnValue(false); const miss = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 99 }); expect(miss.status).toBe(404); expect(miss.body).toEqual({ error: 'Place not found' }); @@ -102,47 +123,67 @@ describe('Assignments e2e (real auth guard + temp SQLite)', () => { it('200 delete assignment reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); - asg.assignmentExistsInDay.mockReturnValue(true); - const res = await request(server).delete('/api/trips/5/days/3/assignments/9').set('Cookie', sessionCookie(1)); + const id = seedAssignment(); + const res = await request(server).delete(`/api/trips/5/days/3/assignments/${id}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(asg.deleteAssignment).toHaveBeenCalledWith('9'); + expect(db.prepare('SELECT id FROM day_assignments WHERE id = ?').get(id)).toBeUndefined(); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); }); it('200 move assignment reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); - asg.getAssignmentForTrip.mockReturnValue({ day_id: 3 }); - asg.moveAssignment.mockReturnValue({ assignment: { id: 9 }, oldDayId: 3 }); + const id = seedAssignment(); const res = await request(server) - .put('/api/trips/5/assignments/9/move') + .put(`/api/trips/5/assignments/${id}/move`) .set('Cookie', sessionCookie(1)) .send({ new_day_id: 4, order_index: 0 }); expect(res.status).toBe(200); + expect(res.body.assignment).toMatchObject({ id, day_id: 4, order_index: 0 }); + expect(db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id)).toEqual({ day_id: 4 }); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); }); it('200 update time reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); - asg.getAssignmentForTrip.mockReturnValue({ id: 9 }); - asg.updateTime.mockReturnValue({ id: 9 }); + const id = seedAssignment(); const res = await request(server) - .put('/api/trips/5/assignments/9/time') + .put(`/api/trips/5/assignments/${id}/time`) .set('Cookie', sessionCookie(1)) .send({ place_time: '09:00', end_time: null }); expect(res.status).toBe(200); + expect(res.body.assignment).toMatchObject({ id, assignment_time: '09:00', assignment_end_time: null }); + expect(db.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(id)).toEqual({ assignment_time: '09:00' }); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); }); it('200 participants (access-only)', async () => { - const res = await request(server).get('/api/trips/5/assignments/9/participants').set('Cookie', sessionCookie(1)); + const id = seedAssignment(); + db.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, 2)').run(id); + const res = await request(server).get(`/api/trips/5/assignments/${id}/participants`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body).toEqual({ participants: [{ user_id: 2 }] }); + expect(res.body).toEqual({ participants: [{ user_id: 2, username: 'peer', avatar: null }] }); }); - it('400 set participants with non-array', async () => { + it('400 from the Zod pipe on set participants with non-array', async () => { const res = await request(server).put('/api/trips/5/assignments/9/participants').set('Cookie', sessionCookie(1)).send({ user_ids: 'no' }); expect(res.status).toBe(400); - expect(res.body).toEqual({ error: 'user_ids must be an array' }); + expect(res.body.error).toContain('user_ids'); + }); + + it('400 from the Zod pipe on create without a place_id', async () => { + const res = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({}); + expect(res.status).toBe(400); + expect(res.body.error).toContain('place_id'); + }); + + it('move accepts the client api form order_index: null (coerces to 0)', async () => { + const id = seedAssignment(); + const res = await request(server) + .put(`/api/trips/5/assignments/${id}/move`) + .set('Cookie', sessionCookie(1)) + .send({ new_day_id: 4, order_index: null }); + expect(res.status).toBe(200); + expect(res.body.assignment).toMatchObject({ id, day_id: 4, order_index: 0 }); }); }); diff --git a/server/tests/unit/nest/assignments.controller.test.ts b/server/tests/unit/nest/assignments.controller.test.ts index ea6749a0e..25f29e9bd 100644 --- a/server/tests/unit/nest/assignments.controller.test.ts +++ b/server/tests/unit/nest/assignments.controller.test.ts @@ -49,9 +49,10 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route }); it('PUT /reorder 404 day, else reorders + broadcasts', () => { - expect(thrown(() => new DayAssignmentsController(svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial)).reorder(user, '5', '3', [1, 2]))).toEqual({ status: 404, body: { error: 'Day not found' } }); + expect(thrown(() => new DayAssignmentsController(svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial)).reorder(user, '5', '3', { orderedIds: [1, 2] }))).toEqual({ status: 404, body: { error: 'Day not found' } }); const reorderAssignments = vi.fn(); const broadcast = vi.fn(); - expect(new DayAssignmentsController(svc({ reorderAssignments, broadcast } as Partial)).reorder(user, '5', '3', [2, 1], 'sock')).toEqual({ success: true }); + expect(new DayAssignmentsController(svc({ reorderAssignments, broadcast } as Partial)).reorder(user, '5', '3', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true }); + expect(reorderAssignments).toHaveBeenCalledWith('3', [2, 1]); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:reordered', { dayId: 3, orderedIds: [2, 1] }, 'sock'); }); @@ -68,10 +69,10 @@ describe('AssignmentOpsController (parity with the per-assignment op routes)', ( it('PUT /:id/move 404 assignment, 404 target day, else moves', () => { expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Assignment not found' } }); expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), dayExists: vi.fn().mockReturnValue(false) } as Partial)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Target day not found' } }); - const moveAssignment = vi.fn().mockReturnValue({ assignment: { id: 9 } }); const broadcast = vi.fn(); const reconcile = vi.fn(); + const moveAssignment = vi.fn().mockReturnValue({ assignment: { id: 9 }, oldDayId: 3 }); const broadcast = vi.fn(); const reconcile = vi.fn(); const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), moveAssignment, broadcast, reconcile } as Partial); expect(new AssignmentOpsController(s).move(user, '5', '9', { new_day_id: 4, order_index: 0 }, 'sock')).toEqual({ assignment: { id: 9 } }); - expect(moveAssignment).toHaveBeenCalledWith('9', 4, 0, 3); + expect(moveAssignment).toHaveBeenCalledWith('9', 4, 0); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:moved', { assignment: { id: 9 }, oldDayId: 3, newDayId: 4 }, 'sock'); expect(reconcile).toHaveBeenCalledWith('5', 'sock'); }); @@ -90,10 +91,10 @@ describe('AssignmentOpsController (parity with the per-assignment op routes)', ( expect(reconcile).toHaveBeenCalledWith('5', 'sock'); }); - it('PUT /:id/participants 400 not array, else sets + broadcasts', () => { - expect(thrown(() => new AssignmentOpsController(svc()).setParticipants(user, '5', '9', 'no'))).toEqual({ status: 400, body: { error: 'user_ids must be an array' } }); + it('PUT /:id/participants sets + broadcasts (non-array bodies are the Zod pipe\'s 400, covered in e2e)', () => { const setParticipants = vi.fn().mockReturnValue([{ user_id: 2 }]); const broadcast = vi.fn(); - expect(new AssignmentOpsController(svc({ setParticipants, broadcast } as Partial)).setParticipants(user, '5', '9', [2], 'sock')).toEqual({ participants: [{ user_id: 2 }] }); + expect(new AssignmentOpsController(svc({ setParticipants, broadcast } as Partial)).setParticipants(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ participants: [{ user_id: 2 }] }); + expect(setParticipants).toHaveBeenCalledWith('9', [2]); expect(broadcast).toHaveBeenCalledWith('5', 'assignment:participants', { assignmentId: 9, participants: [{ user_id: 2 }] }, 'sock'); }); }); diff --git a/shared/src/assignment/assignment.schema.spec.ts b/shared/src/assignment/assignment.schema.spec.ts index 52bf787d2..f75a03b73 100644 --- a/shared/src/assignment/assignment.schema.spec.ts +++ b/shared/src/assignment/assignment.schema.spec.ts @@ -2,6 +2,7 @@ import { assignmentCreateRequestSchema, assignmentMoveRequestSchema, assignmentParticipantsRequestSchema, + assignmentTransportRequestSchema, } from './assignment.schema'; import { describe, it, expect } from 'vitest'; @@ -15,13 +16,24 @@ describe('assignmentCreateRequestSchema', () => { }); describe('assignmentMoveRequestSchema', () => { - it('requires new_day_id; order_index optional', () => { + it('requires new_day_id; order_index optional/nullable', () => { expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success).toBe(true); expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 }).success).toBe(true); + // The client api sends `order_index: null` when no insert position is given. + expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: null }).success).toBe(true); expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false); }); }); +describe('assignmentTransportRequestSchema', () => { + it('accepts a mode, an explicit null, and an absent key (legacy `?? null`)', () => { + expect(assignmentTransportRequestSchema.safeParse({ transport_mode: 'cycling' }).success).toBe(true); + expect(assignmentTransportRequestSchema.safeParse({ transport_mode: null }).success).toBe(true); + expect(assignmentTransportRequestSchema.safeParse({}).success).toBe(true); + expect(assignmentTransportRequestSchema.safeParse({ transport_mode: 5 }).success).toBe(false); + }); +}); + describe('assignmentParticipantsRequestSchema', () => { it('requires a numeric user_ids array', () => { expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true); diff --git a/shared/src/assignment/assignment.schema.ts b/shared/src/assignment/assignment.schema.ts index 86d54417d..abf6bdf02 100644 --- a/shared/src/assignment/assignment.schema.ts +++ b/shared/src/assignment/assignment.schema.ts @@ -7,10 +7,11 @@ import { z } from 'zod'; * endpoints under /api/trips/:tripId/days/:dayId/assignments and * /api/trips/:tripId/assignments/:id/*. * - * Trip-scoped; mutations use the 'day_edit' permission. The legacy route - * (server/src/routes/assignments.ts, mounted on /api) wraps assignmentService. - * Assignment rows carry joined place data and are kept open in responses; the - * request schemas + the bespoke 404/400 controller messages pin the rest. + * Trip-scoped; mutations use the 'day_edit' permission. The server side is the + * DI-native AssignmentsService (server/src/nest/assignments/), validated via + * the createZodDto wrappers in assignments.dto.ts. Assignment rows carry + * joined place data and are kept open in responses; the request schemas + the + * bespoke 404 controller messages pin the rest. */ /** @@ -61,7 +62,9 @@ export type AssignmentReorderRequest = z.infer; @@ -73,7 +76,9 @@ export type AssignmentTimeRequest = z.infer; /** Set the leg's travel mode (a RouteProfileKey, or null to inherit the day default). */ export const assignmentTransportRequestSchema = z.object({ - transport_mode: z.string().nullable(), + // The legacy route read `body.transport_mode ?? null`, so an absent key + // behaves like an explicit null — keep it optional on the wire. + transport_mode: z.string().nullable().optional(), }); export type AssignmentTransportRequest = z.infer;