mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
d95aa49c7f
TripsService injected fourteen other services. Almost all of them were there for two read aggregates - the MCP trip summary and the offline bundle - and for the member roster, not for anything the write path does. Reading is what fans out; writing is not. Membership goes to trip-members/: listMembers, addMember, removeMember, transferOwnership and the four guest methods, plus the six routes, which keep their paths. It is a NEW module, not the trip-membership/ leaf next door: deleting a guest erases their plugin data and re-splits their expenses, so it needs auth and budget, and AuthModule already imports that leaf. Giving it those dependencies there would have closed a hard cycle. This one is a sink - nothing imports it back except trips. The read aggregates go to trip-read-model/, which is where the nine-service fan-out now lives. What is left is the write core plus day generation, on eight parameters instead of fourteen: database, reservations, days, permissions, budget, vacay, realtime, unsplash. The last four are all reachable from updateTrip's date-shift transaction, which is why the count does not fall to three. Trips still reaches budget through ReservationsModule, so trips.bridge.ts survives this. Saying otherwise would have been the easy claim to make and it would not have been true.
315 lines
19 KiB
TypeScript
315 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { HttpException } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
|
|
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
|
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
|
|
const { isDemoEmail } = vi.hoisted(() => ({ isDemoEmail: vi.fn(() => false) }));
|
|
vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail }));
|
|
// Injected stub since the unsplash fold (was a path mock of the service module),
|
|
// so the controller test never hits the network. isUnsplashCoverUrl keeps its
|
|
// real host-based logic.
|
|
const unsplashStub = {
|
|
isUnsplashCoverUrl: (v: unknown) => typeof v === 'string' && v.startsWith('https://images.unsplash.com/'),
|
|
saveUnsplashCover: vi.fn().mockResolvedValue('mock-cover.jpg'),
|
|
} as unknown as UnsplashService;
|
|
|
|
import { TripsController } from '../../../src/nest/trips/trips.controller';
|
|
import type { TripsService } from '../../../src/nest/trips/trips.service';
|
|
import type { CalendarService } from '../../../src/nest/calendar/calendar.service';
|
|
import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
|
|
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
|
|
import type { User } from '../../../src/types';
|
|
import { activeTripResponseSchema, tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
|
|
|
|
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
|
const req = { headers: {} } as Request;
|
|
|
|
// AuditService is constructor-injected since the auditLog DI migration; the
|
|
// wrapper keeps the historical construction sites positional.
|
|
const writeAudit = vi.fn();
|
|
const audit = { writeAudit } as unknown as AuditService;
|
|
// calendar is optional so the call sites that never reach the ICS route stay as
|
|
// they were; the two that do pass a stub.
|
|
// calendar and readModel are optional so the call sites that reach neither the
|
|
// ICS route nor the bundle stay as they were; the ones that do pass a stub.
|
|
const tc = (s: TripsService, calendar?: Partial<CalendarService>, readModel?: Partial<TripReadModelService>) =>
|
|
new TripsController(s, audit, new RuntimeEnvService(), unsplashStub, (calendar ?? {}) as CalendarService, (readModel ?? {}) as TripReadModelService);
|
|
|
|
function svc(o: Partial<TripsService> = {}): TripsService {
|
|
return {
|
|
canAccessTrip: vi.fn().mockReturnValue({ user_id: 1 }),
|
|
can: vi.fn().mockReturnValue(true),
|
|
broadcast: vi.fn(),
|
|
notifyInvite: vi.fn(),
|
|
...o,
|
|
} as unknown as TripsService;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
async function thrownAsync(fn: () => Promise<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');
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
describe('TripsController (parity with the legacy /api/trips route)', () => {
|
|
it('GET / lists for the user with the archived flag', () => {
|
|
const list = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
expect(tc(svc({ list } as Partial<TripsService>)).list(user, '1')).toEqual({ trips: [{ id: 1 }] });
|
|
expect(list).toHaveBeenCalledWith(1, 1);
|
|
});
|
|
|
|
it('GET / defaults the archived flag to 0 when not "1"', () => {
|
|
const list = vi.fn().mockReturnValue([]);
|
|
const c = tc(svc({ list } as Partial<TripsService>));
|
|
c.list(user, undefined);
|
|
expect(list).toHaveBeenLastCalledWith(1, 0);
|
|
c.list(user, '0');
|
|
expect(list).toHaveBeenLastCalledWith(1, 0);
|
|
});
|
|
|
|
describe('GET /active (startup destination)', () => {
|
|
it('narrows the row to the contract shape and drops the sort helper', () => {
|
|
const activeTrip = vi.fn().mockReturnValue({
|
|
id: 7, title: 'Japan', start_date: '2026-09-01', end_date: '2026-09-14', relevance: 1,
|
|
});
|
|
const res = tc(svc({ activeTrip } as Partial<TripsService>)).active(user);
|
|
expect(res).toEqual({ trip: { id: 7, title: 'Japan', start_date: '2026-09-01', end_date: '2026-09-14' } });
|
|
expect(activeTripResponseSchema.safeParse(res).success).toBe(true);
|
|
expect(activeTrip).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('answers null instead of 404 when the user has no trip, so the caller can fall back', () => {
|
|
const res = tc(svc({ activeTrip: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).active(user);
|
|
expect(res).toEqual({ trip: null });
|
|
expect(activeTripResponseSchema.safeParse(res).success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('POST / (create)', () => {
|
|
it('403 without trip_create; a missing title 400s in the ZodValidationPipe', () => {
|
|
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
|
|
// The hand-rolled 'Title is required' 400 moved into the global pipe
|
|
// (trips DTO ratchet) — the schema rejects a missing/empty title.
|
|
expect(tripCreateRequestSchema.safeParse({}).success).toBe(false);
|
|
expect(tripCreateRequestSchema.safeParse({ title: '' }).success).toBe(false);
|
|
});
|
|
|
|
it('infers end_date from start_date (+6 days) and creates', () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
|
tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', start_date: '2026-07-01' }, req);
|
|
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07' }));
|
|
});
|
|
|
|
it('400 when end_date precedes start_date', () => {
|
|
expect(thrown(() => tc(svc()).create(user, { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' }, req))).toEqual({
|
|
status: 400, body: { error: 'End date must be after start date' },
|
|
});
|
|
});
|
|
|
|
it('infers start_date from end_date (-6 days) and parses day_count', () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
|
tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req);
|
|
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07', day_count: 40 }));
|
|
});
|
|
|
|
it('clamps a non-numeric day_count to the default of 7', () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
|
tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T', day_count: 'abc' }, req);
|
|
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 }));
|
|
});
|
|
|
|
it('logs the reminder when reminderDays is set', () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 });
|
|
expect(tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).toEqual({ trip: { id: 9 } });
|
|
});
|
|
});
|
|
|
|
it('GET /:id 404 when missing', () => {
|
|
expect(thrown(() => tc(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
});
|
|
|
|
it('GET /:id returns the trip when present', () => {
|
|
const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial<TripsService>);
|
|
expect(tc(s).get(user, '9')).toEqual({ trip: { id: 9 } });
|
|
});
|
|
|
|
describe('PUT /:id', () => {
|
|
it('404 when no access; 403 on archive without trip_archive', async () => {
|
|
expect(await thrownAsync(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).update(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_archive') });
|
|
expect(await thrownAsync(() => tc(s).update(user, '9', { is_archived: 1 }, req))).toEqual({ status: 403, body: { error: 'No permission to archive/unarchive this trip' } });
|
|
});
|
|
|
|
it('updates, audits a change and broadcasts', async () => {
|
|
const update = vi.fn().mockReturnValue({ updatedTrip: { id: 9 }, changes: { title: { oldValue: 'a', newValue: 'b' } }, newTitle: 'b', newReminder: 0, oldReminder: 0 });
|
|
const broadcast = vi.fn();
|
|
const s = svc({ update, broadcast } as Partial<TripsService>);
|
|
expect(await tc(s).update(user, '9', { title: 'b' }, req, 'sock')).toEqual({ trip: { id: 9 } });
|
|
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9 } }, 'sock');
|
|
});
|
|
|
|
it('403 on cover_image without trip_cover_upload', async () => {
|
|
const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_cover_upload') });
|
|
expect(await thrownAsync(() => tc(s).update(user, '9', { cover_image: '/x.jpg' }, req))).toEqual({ status: 403, body: { error: 'No permission to change cover image' } });
|
|
});
|
|
|
|
it('403 on an edit field without trip_edit', async () => {
|
|
const s = svc({ can: vi.fn().mockImplementation((a: string) => a !== 'trip_edit') });
|
|
expect(await thrownAsync(() => tc(s).update(user, '9', { title: 'b' }, req))).toEqual({ status: 403, body: { error: 'No permission to edit this trip' } });
|
|
});
|
|
|
|
it('admin edit logs the owner and reminder changes', async () => {
|
|
const update = vi.fn().mockReturnValue({
|
|
updatedTrip: { id: 9 }, changes: { title: { oldValue: 'a', newValue: 'b' } }, newTitle: 'b',
|
|
ownerEmail: 'owner@x.y', isAdminEdit: true, newReminder: 5, oldReminder: 0,
|
|
});
|
|
const s = svc({ update } as Partial<TripsService>);
|
|
expect(await tc(s).update(user, '9', { title: 'b' }, req)).toEqual({ trip: { id: 9 } });
|
|
});
|
|
|
|
it('logs when a reminder is removed', async () => {
|
|
const update = vi.fn().mockReturnValue({
|
|
updatedTrip: { id: 9 }, changes: {}, newTitle: 'b', newReminder: 0, oldReminder: 5,
|
|
});
|
|
const s = svc({ update } as Partial<TripsService>);
|
|
expect(await tc(s).update(user, '9', { reminder_days: 0 }, req)).toEqual({ trip: { id: 9 } });
|
|
});
|
|
|
|
it('maps a NotFoundError to 404 and a ValidationError to 400', async () => {
|
|
const nf = svc({ update: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) } as Partial<TripsService>);
|
|
expect(await thrownAsync(() => tc(nf).update(user, '9', { title: 'b' }, req))).toEqual({ status: 404, body: { error: 'gone' } });
|
|
const ve = svc({ update: vi.fn().mockImplementation(() => { throw new ValidationError('bad'); }) } as Partial<TripsService>);
|
|
expect(await thrownAsync(() => tc(ve).update(user, '9', { title: 'b' }, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
|
});
|
|
|
|
it('re-throws an unknown error from update', async () => {
|
|
const s = svc({ update: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial<TripsService>);
|
|
await expect(tc(s).update(user, '9', { title: 'b' }, req)).rejects.toThrow('boom');
|
|
});
|
|
|
|
it('#1277: internalises an Unsplash cover hot-link into uploads/covers before saving', async () => {
|
|
const update = vi.fn().mockReturnValue({ updatedTrip: { id: 9 }, changes: {}, newTitle: 'b', newReminder: 0, oldReminder: 0 });
|
|
const deleteOldCover = vi.fn();
|
|
const s = svc({ update, deleteOldCover, getRaw: vi.fn().mockReturnValue({ cover_image: null }) } as Partial<TripsService>);
|
|
await tc(s).update(user, '9', { cover_image: 'https://images.unsplash.com/photo-123?w=1080' }, req);
|
|
// The handler downloads the cover and rewrites cover_image to a local path
|
|
// before delegating to update(); on download failure it would have thrown 502.
|
|
const savedBody = update.mock.calls[0][2] as { cover_image: string };
|
|
expect(savedBody.cover_image).toMatch(/^\/uploads\/covers\/.+\.(jpg|png|webp|gif)$/);
|
|
});
|
|
});
|
|
|
|
describe('POST /:id/copy', () => {
|
|
it('403 without trip_create, 404 without access', () => {
|
|
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', {}, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
|
|
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
});
|
|
|
|
it('copies + returns the new trip', () => {
|
|
const s = svc({ copy: vi.fn().mockReturnValue(42), getCopiedTrip: vi.fn().mockReturnValue({ id: 42 }) } as Partial<TripsService>);
|
|
expect(tc(s).copy(user, '9', { title: 'Copy' }, req)).toEqual({ trip: { id: 42 } });
|
|
});
|
|
});
|
|
|
|
describe('DELETE /:id', () => {
|
|
it('404 when no owner, 403 without trip_delete', () => {
|
|
expect(thrown(() => tc(svc({ getOwner: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).remove(user, '9', req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 1 }), can: vi.fn().mockReturnValue(false) } as Partial<TripsService>);
|
|
expect(thrown(() => tc(s).remove(user, '9', req))).toEqual({ status: 403, body: { error: 'No permission to delete this trip' } });
|
|
});
|
|
|
|
it('deletes, audits and broadcasts', () => {
|
|
const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: false }); const broadcast = vi.fn();
|
|
const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 1 }), remove, broadcast } as Partial<TripsService>);
|
|
expect(tc(s).remove(user, '9', req, 'sock')).toEqual({ success: true });
|
|
expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, 'sock');
|
|
});
|
|
|
|
it('admin delete logs the owner', () => {
|
|
const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' });
|
|
const broadcast = vi.fn();
|
|
const s = svc({ getOwner: vi.fn().mockReturnValue({ user_id: 2 }), remove, broadcast } as Partial<TripsService>);
|
|
expect(tc(s).remove(user, '9', req)).toEqual({ success: true });
|
|
expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, undefined);
|
|
});
|
|
});
|
|
|
|
it('GET /:id/bundle 404 then aggregates', () => {
|
|
expect(thrown(() => tc(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).bundle(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
const bundle = vi.fn().mockReturnValue({ trip: { id: 9 }, days: [] });
|
|
const s = svc({ get: vi.fn().mockReturnValue({ user_id: 1 }) } as Partial<TripsService>);
|
|
expect(tc(s, undefined, { bundle }).bundle(user, '9')).toEqual({ trip: { id: 9 }, days: [] });
|
|
});
|
|
|
|
describe('POST /:id/cover', () => {
|
|
const file = { filename: 'abc.jpg' } as Express.Multer.File;
|
|
it('404 without access, 403 without permission, 404 raw trip, 400 no file, else returns url', () => {
|
|
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).cover(user, '9', file))).toEqual({ status: 403, body: { error: 'No permission to change the cover image' } });
|
|
expect(thrown(() => tc(svc({ getRaw: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
expect(thrown(() => tc(svc({ getRaw: vi.fn().mockReturnValue({ cover_image: null }) } as Partial<TripsService>)).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } });
|
|
const deleteOldCover = vi.fn(); const updateCoverImage = vi.fn();
|
|
const s = svc({ getRaw: vi.fn().mockReturnValue({ cover_image: '/old.jpg' }), deleteOldCover, updateCoverImage } as Partial<TripsService>);
|
|
expect(tc(s).cover(user, '9', file)).toEqual({ cover_image: '/uploads/covers/abc.jpg' });
|
|
expect(deleteOldCover).toHaveBeenCalledWith('/old.jpg');
|
|
expect(updateCoverImage).toHaveBeenCalledWith('9', '/uploads/covers/abc.jpg');
|
|
});
|
|
|
|
it('403 in demo mode for a demo account', () => {
|
|
const prev = process.env.DEMO_MODE;
|
|
process.env.DEMO_MODE = 'true';
|
|
isDemoEmail.mockReturnValueOnce(true);
|
|
try {
|
|
expect(thrown(() => tc(svc()).cover(user, '9', file))).toEqual({
|
|
status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
|
|
});
|
|
} finally {
|
|
if (prev === undefined) delete process.env.DEMO_MODE;
|
|
else process.env.DEMO_MODE = prev;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GET /:id/export.ics', () => {
|
|
function makeRes() { return { setHeader: vi.fn(), send: vi.fn() } as never; }
|
|
it('404 without access, else sends the calendar with headers', () => {
|
|
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
const res = { setHeader: vi.fn(), send: vi.fn() };
|
|
const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: 'trip.ics' }) };
|
|
tc(svc(), cal).exportIcs(user, '9', res as never);
|
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/calendar; charset=utf-8');
|
|
expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="trip.ics"');
|
|
expect(res.send).toHaveBeenCalledWith('BEGIN:VCALENDAR');
|
|
});
|
|
|
|
it('maps a NotFoundError from the export to 404 and re-throws others', () => {
|
|
const nf = { exportICS: vi.fn().mockImplementation(() => { throw new NotFoundError('gone'); }) };
|
|
expect(thrown(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({ status: 404, body: { error: 'gone' } });
|
|
const other = { exportICS: vi.fn().mockImplementation(() => { throw new Error('boom'); }) };
|
|
expect(() => tc(svc(), other).exportIcs(user, '9', makeRes())).toThrow('boom');
|
|
});
|
|
});
|
|
|
|
it('POST /:id/copy maps a copy failure to 500', () => {
|
|
const s = svc({ copy: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial<TripsService>);
|
|
expect(thrown(() => tc(s).copy(user, '9', {}, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } });
|
|
});
|
|
});
|