Files
TREK/server/tests/unit/nest/assignments.controller.test.ts
T
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00

181 lines
14 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { DayAssignmentsController, AssignmentOpsController } from '../../../src/nest/assignments/assignments.controller';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import type { User } from '../../../src/types';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const trip = { user_id: 1 };
function svc(o: Partial<AssignmentsService> = {}): AssignmentsService {
return {
verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(),
dayExists: vi.fn().mockReturnValue(true), placeExists: vi.fn().mockReturnValue(true), reconcile: vi.fn(),
...o,
} as unknown as AssignmentsService;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
describe('DayAssignmentsController (parity with the legacy day-assignments routes)', () => {
// The 404 "Trip not found" and 403 "No permission" cases moved to
// trip-access.guard.test.ts with the check itself.
it('404 day on GET', () => {
const s = svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>);
expect(thrown(() => new DayAssignmentsController(s).list(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } });
});
it('GET returns assignments (access-only, no permission gate)', () => {
const s = svc({ canEdit: vi.fn().mockReturnValue(false), listDayAssignments: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AssignmentsService>);
expect(new DayAssignmentsController(s).list(user, '5', '3')).toEqual({ assignments: [{ id: 1 }] });
});
describe('POST', () => {
it('404 place not found; then creates + hooks', () => {
expect(thrown(() => new DayAssignmentsController(svc({ placeExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).create(user, '5', '3', { place_id: 2 }))).toEqual({ status: 404, body: { error: 'Place not found' } });
const createAssignment = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const reconcile = vi.fn();
const s = svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>);
expect(new DayAssignmentsController(s).create(user, '5', '3', { place_id: 2, notes: 'n' }, 'sock')).toEqual({ assignment: { id: 9 } });
expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n');
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
});
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', { 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', { 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');
});
it('DELETE /:id 404 when not in day, else success', () => {
expect(thrown(() => new DayAssignmentsController(svc({ assignmentExistsInDay: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).remove(user, '5', '3', '9'))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
const reconcile = vi.fn();
const s = svc({ assignmentExistsInDay: vi.fn().mockReturnValue(true), deleteAssignment: vi.fn(), reconcile } as Partial<AssignmentsService>);
expect(new DayAssignmentsController(s).remove(user, '5', '3', '9', 'sock')).toEqual({ success: true });
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('DELETE / (clear day) 404 day, else empties the day and announces every removal (#2470)', () => {
expect(thrown(() => new DayAssignmentsController(svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).clear(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Day not found' } });
const clearDay = vi.fn().mockReturnValue([7, 8]); const broadcast = vi.fn(); const reconcile = vi.fn();
const s = svc({ clearDay, broadcast, reconcile } as Partial<AssignmentsService>);
expect(new DayAssignmentsController(s).clear(user, '5', '3', 'sock')).toEqual({ success: true, removedIds: [7, 8] });
expect(clearDay).toHaveBeenCalledWith('3');
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:deleted', { assignmentId: 7, dayId: 3 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:deleted', { assignmentId: 8, dayId: 3 }, 'sock');
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('DELETE / on an already empty day succeeds without a journey reconcile', () => {
const reconcile = vi.fn(); const broadcast = vi.fn();
const s = svc({ clearDay: vi.fn().mockReturnValue([]), reconcile, broadcast } as Partial<AssignmentsService>);
expect(new DayAssignmentsController(s).clear(user, '5', '3')).toEqual({ success: true, removedIds: [] });
expect(broadcast).not.toHaveBeenCalled();
expect(reconcile).not.toHaveBeenCalled();
});
});
describe('AssignmentOpsController (parity with the per-assignment op routes)', () => {
it('PUT /:id/route takes a stop out of the route and back, scoped to the trip (#2532)', () => {
const setRouteExcluded = vi.fn().mockReturnValue({ id: 9, route_excluded: true })
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), setRouteExcluded } as Partial<AssignmentsService>)
const controller = new AssignmentOpsController(s)
expect(controller.route(user, '5', '9', { excluded: true }, 'sock')).toEqual({ assignment: { id: 9, route_excluded: true } })
expect(setRouteExcluded).toHaveBeenCalledWith('9', true)
expect(s.broadcast).toHaveBeenCalledWith('5', 'assignment:updated', { assignment: { id: 9, route_excluded: true } }, 'sock')
vi.mocked(s.getAssignmentForTrip).mockReturnValue(undefined)
expect(thrown(() => controller.route(user, '5', '9', { excluded: false }))).toEqual({ status: 404, body: { error: 'Assignment not found' } })
});
it('scopes explicit day ends to the trip and broadcasts the saved visit', () => {
const setEndDay = vi.fn().mockReturnValue({ id: 9, end_day: true });
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), setEndDay });
const controller = new AssignmentOpsController(s);
expect(controller.endDay('5', '9', { end_day: true }, 'sock')).toEqual({ assignment: { id: 9, end_day: true } });
expect(s.getAssignmentForTrip).toHaveBeenCalledWith('9', '5');
expect(setEndDay).toHaveBeenCalledWith('9', true);
expect(s.broadcast).toHaveBeenCalledWith('5', 'assignment:updated', { assignment: { id: 9, end_day: true } }, 'sock');
vi.mocked(s.getAssignmentForTrip).mockReturnValue(undefined);
expect(thrown(() => controller.endDay('5', '9', { end_day: false }))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
});
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 }, 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);
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:moved', { assignment: { id: 9 }, oldDayId: 3, newDayId: 4 }, 'sock');
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('GET /:id/participants 404 when the assignment is on another trip, else returns participants (access-only)', () => {
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).participants(user, '5', '9')))
.toEqual({ status: 404, body: { error: 'Assignment not found' } });
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), getParticipants: vi.fn().mockReturnValue([{ user_id: 2 }]) } as Partial<AssignmentsService>);
expect(new AssignmentOpsController(s).participants(user, '5', '9')).toEqual({ participants: [{ user_id: 2 }] });
});
it('PUT /:id/time 404 missing, else updates', () => {
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).time(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
const updateTime = vi.fn().mockReturnValue({ assignment: { id: 9 }, reordered: null, vias: null }); const broadcast = vi.fn(); const reconcile = vi.fn();
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateTime, broadcast, reconcile } as Partial<AssignmentsService>);
expect(new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } });
expect(updateTime).toHaveBeenCalledWith('9', '10:00', undefined);
// Nothing moved, so the row is all collaborators hear about.
expect(broadcast.mock.calls).toEqual([['5', 'assignment:updated', { assignment: { id: 9 } }, 'sock']]);
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('PUT /:id/time sends the whole day and the re-pinned vias to every socket when a start moved stops', () => {
const reordered = { dayId: 3, orderedIds: [7, 9, 8] };
const vias = { dayId: 3, vias: [{ id: 40, day_id: 3, after_order_index: 1, sequence: 0, lat: 1, lng: 2 }] };
const updateTime = vi.fn().mockReturnValue({ assignment: { id: 9 }, reordered, vias }); const broadcast = vi.fn();
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateTime, broadcast } as Partial<AssignmentsService>);
expect(new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } });
expect(broadcast.mock.calls).toEqual([
['5', 'assignment:updated', { assignment: { id: 9 } }, 'sock'],
// The writer's socket too, so the order and the vias pinned to it arrive
// together there as well, a replayed offline save included.
['5', 'assignment:reordered', reordered, undefined],
['5', 'roadtripVia:changed', vias, undefined],
]);
});
it('PUT /:id/notes 404 missing, else updates + broadcasts (#2163)', () => {
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).notes(user, '5', '9', { notes: 'n' }))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
const updateNotes = vi.fn().mockReturnValue({ id: 9, notes: 'Book the 10:00 entry' }); const broadcast = vi.fn(); const reconcile = vi.fn();
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateNotes, broadcast, reconcile } as Partial<AssignmentsService>);
expect(new AssignmentOpsController(s).notes(user, '5', '9', { notes: 'Book the 10:00 entry' }, 'sock')).toEqual({ assignment: { id: 9, notes: 'Book the 10:00 entry' } });
expect(updateNotes).toHaveBeenCalledWith('9', 'Book the 10:00 entry');
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:updated', { assignment: { id: 9, notes: 'Book the 10:00 entry' } }, 'sock');
// The note touches neither day order nor the journey skeleton — no reconcile.
expect(reconcile).not.toHaveBeenCalled();
});
it('PUT /:id/participants 404 on a foreign assignment, else 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(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined), setParticipants } as Partial<AssignmentsService>)).setParticipants(user, '5', '9', { user_ids: [2] })))
.toEqual({ status: 404, body: { error: 'Assignment not found' } });
expect(setParticipants).not.toHaveBeenCalled();
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), setParticipants, broadcast } as Partial<AssignmentsService>);
expect(new AssignmentOpsController(s).setParticipants(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ participants: [{ user_id: 2 }] });
// The trip comes along so the service can confine the ids to its roster.
expect(setParticipants).toHaveBeenCalledWith('9', [2], '5');
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:participants', { assignmentId: 9, participants: [{ user_id: 2 }] }, 'sock');
});
});