mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(server): adopt zod DTOs for the assignment body contracts
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AssignmentsService>)).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<AssignmentsService>)).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<AssignmentsService>)).reorder(user, '5', '3', [2, 1], 'sock')).toEqual({ success: true });
|
||||
expect(new DayAssignmentsController(svc({ reorderAssignments, broadcast } as Partial<AssignmentsService>)).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<AssignmentsService>)).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<AssignmentsService>)).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<AssignmentsService>);
|
||||
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<AssignmentsService>)).setParticipants(user, '5', '9', [2], 'sock')).toEqual({ participants: [{ user_id: 2 }] });
|
||||
expect(new AssignmentOpsController(svc({ setParticipants, broadcast } as Partial<AssignmentsService>)).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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof assignmentReorderRequestSc
|
||||
|
||||
export const assignmentMoveRequestSchema = z.object({
|
||||
new_day_id: z.union([z.number(), z.string()]),
|
||||
order_index: z.number().optional(),
|
||||
// The client api types this `number | null` (assignmentsApi.move) and the
|
||||
// legacy route accepted null (`orderIndex || 0`) — keep null on the wire.
|
||||
order_index: z.number().nullable().optional(),
|
||||
});
|
||||
export type AssignmentMoveRequest = z.infer<typeof assignmentMoveRequestSchema>;
|
||||
|
||||
@@ -73,7 +76,9 @@ export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
|
||||
|
||||
/** 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<typeof assignmentTransportRequestSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user