Files
TREK/server/tests/unit/nest/days.controller.test.ts
T
Maurice b56f335978 feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
2026-09-23 22:40:46 +02:00

272 lines
16 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { DaysController } from '../../../src/nest/days/days.controller';
import { DayNotesController } from '../../../src/nest/day-notes/day-notes.controller';
import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/day-notes/day-notes.dto';
import { DayReorderError, DayAppendError, NO_DATES_MESSAGE, type DatedDayAppend } from '../../../src/nest/days/days.service';
import { DayCreateDto } from '../../../src/nest/days/days.dto';
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
import type { DayReorderDto } from '../../../src/nest/days/days.dto';
import type { DaysService } from '../../../src/nest/days/days.service';
import type { DayNotesService } from '../../../src/nest/day-notes/day-notes.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 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');
}
function daysSvc(o: Partial<DaysService> = {}): DaysService {
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DaysService;
}
function removalSvc(o: Partial<DayRemovalService> = {}): DayRemovalService {
return { remove: vi.fn(), announce: vi.fn(), ...o } as unknown as DayRemovalService;
}
function notesSvc(o: Partial<DayNotesService> = {}): DayNotesService {
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DayNotesService;
}
// reorder() keeps the legacy raw-body guard on orderedIds, which DayReorderDto's type
// says can never fire: over HTTP the ZodValidationPipe rejects such a body first. These
// unit tests call the handler directly, so they can still reach the guard, and the
// payload is cast instead of being made to satisfy the DTO.
function rawReorderBody(body: Record<string, unknown>): DayReorderDto {
return body as unknown as DayReorderDto;
}
describe('DaysController (parity with the legacy /api/trips/:tripId/days route)', () => {
// The 404 "Trip not found" and 403 "No permission" cases moved to
// trip-access.guard.test.ts with the check itself: TripAccessGuard resolves :tripId
// for the whole controller now, so a handler is only ever reached for a trip the
// user may see. What is left here is what the handlers themselves still decide.
it('GET / returns the list service result verbatim (the { days } envelope)', () => {
const svc = daysSvc({ list: vi.fn().mockReturnValue({ days: [{ id: 1 }] }) } as Partial<DaysService>);
expect(new DaysController(svc, removalSvc()).list(user, '5')).toEqual({ days: [{ id: 1 }] });
});
it('POST / creates + broadcasts', () => {
const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
expect(new DaysController(daysSvc({ create, broadcast } as Partial<DaysService>), removalSvc()).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
expect(create).toHaveBeenCalledWith('5', '2026-07-01', undefined);
expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock');
});
it('POST / with a position inserts, broadcasts day:reordered, then the grown trip', () => {
const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn();
const getTripForViewer = vi.fn().mockReturnValue({ id: 5, end_date: '2026-07-04' });
const svc = daysSvc({ insert, create, broadcast, getTripForViewer } as Partial<DaysService>);
expect(new DaysController(svc, removalSvc()).create(user, '5', { position: 0 }, 'sock'))
.toEqual({ day: { id: 12 }, trip: { id: 5, end_date: '2026-07-04' } });
expect(insert).toHaveBeenCalledWith('5', 0);
expect(create).not.toHaveBeenCalled();
expect(getTripForViewer).toHaveBeenCalledWith('5', 1);
expect(broadcast.mock.calls).toEqual([
['5', 'day:reordered', { day: { id: 12 } }, 'sock'],
['5', 'trip:updated', { trip: { id: 5, end_date: '2026-07-04' } }, 'sock'],
]);
// A trip that is gone by the time it is re-read is not announced.
broadcast.mockClear();
getTripForViewer.mockReturnValue(undefined);
new DaysController(svc, removalSvc()).create(user, '5', { position: 1 }, 'sock');
expect(broadcast.mock.calls.map(call => call[1])).toEqual(['day:reordered']);
});
describe('POST / dated', () => {
const appended: DatedDayAppend = {
day: { id: 14, trip_id: 5, day_number: 4, date: '2026-07-04', assignments: [], notes_items: [] } as DatedDayAppend['day'],
endDate: '2026-07-04', boundaries: null, trip: { id: 5, end_date: '2026-07-04' },
};
it('appends as the caller, announces with and without the socket, and answers with the day and the trip', () => {
const appendDated = vi.fn().mockReturnValue(appended);
const announceDatedAppend = vi.fn();
const broadcast = vi.fn(); const insert = vi.fn(); const create = vi.fn();
const svc = daysSvc({ appendDated, announceDatedAppend, broadcast, insert, create } as Partial<DaysService>);
expect(new DaysController(svc, removalSvc()).create(user, '5', { dated: true, notes: 'Late checkout' }, 'sock'))
.toEqual({ day: appended.day, trip: appended.trip });
expect(appendDated).toHaveBeenCalledWith('5', 1, 'Late checkout');
expect(insert).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
const [sent, senders] = announceDatedAppend.mock.calls[0];
expect(sent).toBe(appended);
senders.all('roadtripBoundary:changed', { boundaries: [] });
senders.others('day:reordered', { day: appended.day });
expect(broadcast.mock.calls).toEqual([
['5', 'roadtripBoundary:changed', { boundaries: [] }, undefined],
['5', 'day:reordered', { day: appended.day }, 'sock'],
]);
});
it('400 with the service sentence for a trip without dates, and nothing is announced', () => {
const announceDatedAppend = vi.fn();
const svc = daysSvc({
appendDated: vi.fn(() => { throw new DayAppendError(NO_DATES_MESSAGE); }), announceDatedAppend,
} as Partial<DaysService>);
expect(thrown(() => new DaysController(svc, removalSvc()).create(user, '5', { dated: true }, 'sock')))
.toEqual({ status: 400, body: { error: NO_DATES_MESSAGE } });
expect(announceDatedAppend).not.toHaveBeenCalled();
});
it('rethrows anything else unchanged', () => {
const boom = new Error('db is down');
const svc = daysSvc({ appendDated: vi.fn(() => { throw boom; }) } as Partial<DaysService>);
expect(() => new DaysController(svc, removalSvc()).create(user, '5', { dated: true })).toThrow(boom);
});
it('the DTO refuses dated next to a position or a date before the handler runs', () => {
expect(DayCreateDto.schema.safeParse({ dated: true, position: 2 }).success).toBe(false);
expect(DayCreateDto.schema.safeParse({ dated: true, date: '2026-07-04' }).success).toBe(false);
expect(DayCreateDto.schema.safeParse({ dated: true }).success).toBe(true);
});
});
describe('PUT /reorder', () => {
it('400 when orderedIds is missing', () => {
expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({})))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
});
it('400 when orderedIds is not an array', () => {
expect(thrown(() => new DaysController(daysSvc(), removalSvc()).reorder(user, '5', rawReorderBody({ orderedIds: 'nope' })))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
});
it('maps a DayReorderError to 400 with its message', () => {
const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); });
const svc = daysSvc({ reorder } as Partial<DaysService>);
expect(thrown(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [9] }))).toEqual({
status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' },
});
});
it('rethrows a non-DayReorderError unchanged', () => {
const boom = new Error('db is down');
const reorder = vi.fn(() => { throw boom; });
const svc = daysSvc({ reorder } as Partial<DaysService>);
expect(() => new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
});
it('reorders and broadcasts day:reordered', () => {
const reorder = vi.fn(); const broadcast = vi.fn();
const svc = daysSvc({ reorder, broadcast } as Partial<DaysService>);
expect(new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
expect(reorder).toHaveBeenCalledWith('5', [2, 1]);
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock');
});
});
it('PUT /:id 404 when the day is missing, else updates', () => {
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>), removalSvc()).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
const update = vi.fn().mockReturnValue({ id: 9, title: 'T' });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), update } as Partial<DaysService>);
expect(new DaysController(svc, removalSvc()).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
});
describe('DELETE /:id', () => {
const removed: DayRemoval = {
dayId: 9, orderedIds: [8, 10], stayIds: [], reservationIds: [], budgetItemIds: [], mirrors: [],
boundaries: null, endDate: '2026-07-02', trip: { id: 5, end_date: '2026-07-02' },
};
it('404 when the day is not on the trip, before anything is removed', () => {
const removal = removalSvc();
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>), removal).remove(user, '5', '9')))
.toEqual({ status: 404, body: { error: 'Day not found' } });
expect(removal.remove).not.toHaveBeenCalled();
});
it('400 with the service sentence for the last day, and nothing is announced', () => {
const removal = removalSvc({ remove: vi.fn(() => { throw new DayDeleteError('A trip needs at least one day.'); }) });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial<DaysService>);
expect(thrown(() => new DaysController(svc, removal).remove(user, '5', '9', 'sock')))
.toEqual({ status: 400, body: { error: 'A trip needs at least one day.' } });
expect(removal.announce).not.toHaveBeenCalled();
});
it('rethrows anything else unchanged', () => {
const boom = new Error('db is down');
const removal = removalSvc({ remove: vi.fn(() => { throw boom; }) });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }) } as Partial<DaysService>);
expect(() => new DaysController(svc, removal).remove(user, '5', '9')).toThrow(boom);
});
it('removes as the caller, announces with and without the socket, and answers with the trip', () => {
const broadcast = vi.fn();
const removal = removalSvc({ remove: vi.fn().mockReturnValue(removed) });
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial<DaysService>);
expect(new DaysController(svc, removal).remove(user, '5', '9', 'sock'))
.toEqual({ success: true, trip: { id: 5, end_date: '2026-07-02' } });
expect(removal.remove).toHaveBeenCalledWith('5', '9', { userId: 1, socketId: 'sock' });
const [tripId, sentRemoval, senders] = vi.mocked(removal.announce).mock.calls[0];
expect([tripId, sentRemoval, senders.socketId]).toEqual(['5', removed, 'sock']);
senders.all('budget:deleted', { itemId: 3 });
senders.others('day:deleted', { dayId: 9 });
expect(broadcast.mock.calls).toEqual([
['5', 'budget:deleted', { itemId: 3 }, undefined],
['5', 'day:deleted', { dayId: 9 }, 'sock'],
]);
});
});
});
describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes route)', () => {
// The legacy in-controller length guard moved to the global ZodValidationPipe
// (day-notes.dto.ts over the @trek/shared schemas) — like the old guard it
// rejects before the trip-access check. The pipe path is exercised end-to-end
// in tests/e2e/days.e2e.test.ts; here we pin the caps on the DTO schemas.
it('DTO schemas carry the length caps and moveDayNote nulls', () => {
expect(DayNoteCreateDto.schema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
// The body cap moved to 2000 with #1629 (formatted Markdown); the title did not.
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(2001) }).success).toBe(false);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: 'y'.repeat(2000) }).success).toBe(true);
expect(DayNoteCreateDto.schema.safeParse({ text: 'ok', time: null, icon: null }).success).toBe(true);
expect(DayNoteUpdateDto.schema.safeParse({ time: 'y'.repeat(2001) }).success).toBe(false);
expect(DayNoteUpdateDto.schema.safeParse({ time: null }).success).toBe(true);
expect(DayNoteUpdateDto.schema.safeParse({ color: '#dc2626' }).success).toBe(true);
});
// Trip access and day_edit are TripAccessGuard's now (trip-access.guard.test.ts);
// what stays here is what this handler decides for itself.
it('404 day, 400 empty text, then creates', () => {
expect(thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<DayNotesService>)).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 404, body: { error: 'Day not found' } });
expect(thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockReturnValue(true) } as Partial<DayNotesService>)).create(user, '5', '3', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Text required' } });
const create = vi.fn().mockReturnValue({ id: 7 }); const broadcast = vi.fn();
const svc = notesSvc({ dayExists: vi.fn().mockReturnValue(true), create, broadcast } as Partial<DayNotesService>);
expect(new DayNotesController(svc).create(user, '5', '3', { text: 'Lunch', time: '12:00' }, 'sock')).toEqual({ note: { id: 7 } });
expect(create).toHaveBeenCalledWith('3', '5', 'Lunch', '12:00', undefined, undefined, undefined);
expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:created', { dayId: 3, note: { id: 7 } }, 'sock');
});
it('GET / returns notes; PUT/DELETE 404 when the note is missing', () => {
const svc = notesSvc({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<DayNotesService>);
expect(new DayNotesController(svc).list(user, '5', '3')).toEqual({ notes: [{ id: 1 }] });
expect(thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockReturnValue(undefined) } as Partial<DayNotesService>)).update(user, '5', '3', '9', { text: 'x' }))).toEqual({ status: 404, body: { error: 'Note not found' } });
expect(thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockReturnValue(undefined) } as Partial<DayNotesService>)).remove(user, '5', '3', '9'))).toEqual({ status: 404, body: { error: 'Note not found' } });
});
it('PUT/DELETE update + delete a note with broadcasts', () => {
const update = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
const u = notesSvc({ getNote: vi.fn().mockReturnValue({ id: 9 }), update, broadcast } as Partial<DayNotesService>);
expect(new DayNotesController(u).update(user, '5', '3', '9', { text: 'x' }, 'sock')).toEqual({ note: { id: 9 } });
expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:updated', { dayId: 3, note: { id: 9 } }, 'sock');
const remove = vi.fn(); const b2 = vi.fn();
const d = notesSvc({ getNote: vi.fn().mockReturnValue({ id: 9 }), remove, broadcast: b2 } as Partial<DayNotesService>);
expect(new DayNotesController(d).remove(user, '5', '3', '9', 'sock')).toEqual({ success: true });
expect(b2).toHaveBeenCalledWith('5', 'dayNote:deleted', { noteId: 9, dayId: 3 }, 'sock');
});
});