mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
398 lines
18 KiB
TypeScript
398 lines
18 KiB
TypeScript
import { DayNotesController } from '../../../src/nest/day-notes/day-notes.controller';
|
|
import { DayNoteCreateDto, DayNoteUpdateDto } from '../../../src/nest/day-notes/day-notes.dto';
|
|
import type { DayNotesService } from '../../../src/nest/day-notes/day-notes.service';
|
|
import { DayDeleteError, type DayRemoval, type DayRemovalService } from '../../../src/nest/days/day-removal.service';
|
|
import { DaysController } from '../../../src/nest/days/days.controller';
|
|
import { DayCreateDto } from '../../../src/nest/days/days.dto';
|
|
import type { DayReorderDto } from '../../../src/nest/days/days.dto';
|
|
import {
|
|
DayReorderError,
|
|
DayAppendError,
|
|
NO_DATES_MESSAGE,
|
|
type DatedDayAppend,
|
|
} from '../../../src/nest/days/days.service';
|
|
import type { DaysService } from '../../../src/nest/days/days.service';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException } from '@nestjs/common';
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
|
const trip = { user_id: 1 };
|
|
|
|
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
|
|
try {
|
|
await 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)', async () => {
|
|
const svc = daysSvc({ list: vi.fn().mockResolvedValue({ days: [{ id: 1 }] }) } as Partial<DaysService>);
|
|
expect(await new DaysController(svc, removalSvc()).list(user, '5')).toEqual({ days: [{ id: 1 }] });
|
|
});
|
|
|
|
it('POST / creates + broadcasts', async () => {
|
|
const create = vi.fn().mockResolvedValue({ id: 9 });
|
|
const broadcast = vi.fn();
|
|
expect(
|
|
await 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', async () => {
|
|
const insert = vi.fn().mockResolvedValue({ id: 12 });
|
|
const create = vi.fn();
|
|
const broadcast = vi.fn();
|
|
const getTripForViewer = vi.fn().mockResolvedValue({ id: 5, end_date: '2026-07-04' });
|
|
const svc = daysSvc({ insert, create, broadcast, getTripForViewer } as Partial<DaysService>);
|
|
expect(await 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.mockResolvedValue(undefined);
|
|
await 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 unknown 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', async () => {
|
|
const appendDated = vi.fn().mockResolvedValue(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(
|
|
await 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', async () => {
|
|
const announceDatedAppend = vi.fn();
|
|
const svc = daysSvc({
|
|
appendDated: vi.fn(() => Promise.reject(new DayAppendError(NO_DATES_MESSAGE))),
|
|
announceDatedAppend,
|
|
} as Partial<DaysService>);
|
|
expect(
|
|
await 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', async () => {
|
|
const boom = new Error('db is down');
|
|
const svc = daysSvc({ appendDated: vi.fn(() => Promise.reject(boom)) } as Partial<DaysService>);
|
|
await expect(new DaysController(svc, removalSvc()).create(user, '5', { dated: true })).rejects.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', async () => {
|
|
expect(
|
|
await 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', async () => {
|
|
expect(
|
|
await 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', async () => {
|
|
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(await 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', async () => {
|
|
const boom = new Error('db is down');
|
|
const reorder = vi.fn(() => {
|
|
throw boom;
|
|
});
|
|
const svc = daysSvc({ reorder } as Partial<DaysService>);
|
|
await expect(new DaysController(svc, removalSvc()).reorder(user, '5', { orderedIds: [1, 2] })).rejects.toThrow(
|
|
boom,
|
|
);
|
|
});
|
|
|
|
it('reorders and broadcasts day:reordered', async () => {
|
|
const reorder = vi.fn();
|
|
const broadcast = vi.fn();
|
|
const svc = daysSvc({ reorder, broadcast } as Partial<DaysService>);
|
|
expect(await 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', async () => {
|
|
expect(
|
|
await thrown(() =>
|
|
new DaysController(
|
|
daysSvc({ getDay: vi.fn().mockResolvedValue(undefined) } as Partial<DaysService>),
|
|
removalSvc(),
|
|
).update(user, '5', '9', {}),
|
|
),
|
|
).toEqual({ status: 404, body: { error: 'Day not found' } });
|
|
const update = vi.fn().mockResolvedValue({ id: 9, title: 'T' });
|
|
const svc = daysSvc({ getDay: vi.fn().mockResolvedValue({ id: 9 }), update } as Partial<DaysService>);
|
|
expect(await 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', async () => {
|
|
const removal = removalSvc();
|
|
expect(
|
|
await thrown(() =>
|
|
new DaysController(
|
|
daysSvc({ getDay: vi.fn().mockResolvedValue(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', async () => {
|
|
const removal = removalSvc({
|
|
remove: vi.fn(() => Promise.reject(new DayDeleteError('A trip needs at least one day.'))),
|
|
});
|
|
const svc = daysSvc({ getDay: vi.fn().mockResolvedValue({ id: 9 }) } as Partial<DaysService>);
|
|
expect(await 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', async () => {
|
|
const boom = new Error('db is down');
|
|
const removal = removalSvc({ remove: vi.fn(() => Promise.reject(boom)) });
|
|
const svc = daysSvc({ getDay: vi.fn().mockResolvedValue({ id: 9 }) } as Partial<DaysService>);
|
|
await expect(new DaysController(svc, removal).remove(user, '5', '9')).rejects.toThrow(boom);
|
|
});
|
|
|
|
it('removes as the caller, announces with and without the socket, and answers with the trip', async () => {
|
|
const broadcast = vi.fn();
|
|
const removal = removalSvc({ remove: vi.fn().mockResolvedValue(removed) });
|
|
const svc = daysSvc({ getDay: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial<DaysService>);
|
|
|
|
expect(await 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', async () => {
|
|
expect(
|
|
await thrown(() =>
|
|
new DayNotesController(
|
|
notesSvc({ dayExists: vi.fn().mockResolvedValue(false) } as Partial<DayNotesService>),
|
|
).create(user, '5', '3', { text: 'ok' }),
|
|
),
|
|
).toEqual({ status: 404, body: { error: 'Day not found' } });
|
|
expect(
|
|
await thrown(() =>
|
|
new DayNotesController(
|
|
notesSvc({ dayExists: vi.fn().mockResolvedValue(true) } as Partial<DayNotesService>),
|
|
).create(user, '5', '3', { text: ' ' }),
|
|
),
|
|
).toEqual({ status: 400, body: { error: 'Text required' } });
|
|
const create = vi.fn().mockResolvedValue({ id: 7 });
|
|
const broadcast = vi.fn();
|
|
const svc = notesSvc({ dayExists: vi.fn().mockResolvedValue(true), create, broadcast } as Partial<DayNotesService>);
|
|
expect(await 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', async () => {
|
|
const svc = notesSvc({ list: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<DayNotesService>);
|
|
expect(await new DayNotesController(svc).list(user, '5', '3')).toEqual({ notes: [{ id: 1 }] });
|
|
expect(
|
|
await thrown(() =>
|
|
new DayNotesController(
|
|
notesSvc({ getNote: vi.fn().mockResolvedValue(undefined) } as Partial<DayNotesService>),
|
|
).update(user, '5', '3', '9', { text: 'x' }),
|
|
),
|
|
).toEqual({ status: 404, body: { error: 'Note not found' } });
|
|
expect(
|
|
await thrown(() =>
|
|
new DayNotesController(
|
|
notesSvc({ getNote: vi.fn().mockResolvedValue(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', async () => {
|
|
const update = vi.fn().mockResolvedValue({ id: 9 });
|
|
const broadcast = vi.fn();
|
|
const u = notesSvc({
|
|
getNote: vi.fn().mockResolvedValue({ id: 9 }),
|
|
update,
|
|
broadcast,
|
|
} as Partial<DayNotesService>);
|
|
expect(await 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().mockResolvedValue({ id: 9 }),
|
|
remove,
|
|
broadcast: b2,
|
|
} as Partial<DayNotesService>);
|
|
expect(await new DayNotesController(d).remove(user, '5', '3', '9', 'sock')).toEqual({ success: true });
|
|
expect(b2).toHaveBeenCalledWith('5', 'dayNote:deleted', { noteId: 9, dayId: 3 }, 'sock');
|
|
});
|
|
});
|