mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
cc1c16ec34
Saving a start on a stop rewrote the whole day as the timed stops in time order followed by every untimed stop. Its own comment and the commit that added it promised that untimed stops keep their place, but a day of two untimed stops and a third one given 14:00 came back with the third one first. On a road trip the stop order is the drive, so the day was re-routed, its vias landed on other legs, and collaborators, who only got the one edited row, saw a third order until they reloaded. The intended rule, an untimed stop inheriting the time of the timed stop before it, now lives once as chronoOrder in @trek/shared, used by both the day plan (dayMerge) and AssignmentsService.updateTime. The server still reads a booked night's hour from its check-in, and a value without a colon still sorts after every clock time. The day is sorted only when the stop's start actually changed, so taking an End off or saving an unchanged start no longer snaps back a day somebody dragged out of time order. A sorted day is numbered from 0 like a drag. In the same transaction the day's road trip vias follow the stop they were drawn behind, the planner's own drag rule, and a via behind a last stop that stays last is kept, so the drive into the next day keeps its road. PUT /assignments/:id/time and the MCP update_assignment_time tool both broadcast assignment:reordered with the whole day, to the writer as well, and roadtripVia:changed when vias moved, so every client ends up with the same order. ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the intended one.
151 lines
11 KiB
TypeScript
151 lines
11 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');
|
|
});
|
|
});
|
|
|
|
describe('AssignmentOpsController (parity with the per-assignment op routes)', () => {
|
|
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');
|
|
});
|
|
});
|