mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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.
659 lines
29 KiB
TypeScript
659 lines
29 KiB
TypeScript
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import type { CalendarService } from '../../../src/nest/calendar/calendar.service';
|
|
import { DomainError } from '../../../src/nest/common/domain-error';
|
|
import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
|
|
import { TripsController, MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from '../../../src/nest/trips/trips.controller';
|
|
import type { TripsService } from '../../../src/nest/trips/trips.service';
|
|
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
|
|
import type { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException } from '@nestjs/common';
|
|
import {
|
|
MAX_TRIP_DAYS,
|
|
activeTripResponseSchema,
|
|
tripSearchResponseSchema,
|
|
tripCreateRequestSchema,
|
|
tripTransferOwnershipRequestSchema,
|
|
} from '@trek/shared';
|
|
|
|
import type { Request } from 'express';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
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;
|
|
|
|
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 storageStub = {
|
|
put: vi.fn().mockResolvedValue(undefined),
|
|
} as unknown as import('../../../src/nest/storage/storage.service').StorageService;
|
|
const tc = (s: TripsService, calendar?: Partial<CalendarService>, readModel?: Partial<TripReadModelService>) =>
|
|
new TripsController(
|
|
s,
|
|
audit,
|
|
new RuntimeEnvService(),
|
|
unsplashStub,
|
|
(calendar ?? {}) as CalendarService,
|
|
(readModel ?? {}) as TripReadModelService,
|
|
storageStub,
|
|
);
|
|
|
|
function svc(o: Partial<TripsService> = {}): TripsService {
|
|
return {
|
|
canAccessTrip: vi.fn().mockResolvedValue({ user_id: 1 }),
|
|
can: vi.fn().mockReturnValue(true),
|
|
broadcast: vi.fn(),
|
|
notifyInvite: vi.fn(),
|
|
...o,
|
|
} as unknown as TripsService;
|
|
}
|
|
|
|
// The DTO ratchet types create()'s body from the shared contract, where
|
|
// day_count is a number. Two cases below deliberately hand the handler a string,
|
|
// because that is what an unvalidated client sends and what the handler still
|
|
// guards against with Number(day_count) || 7. Naming the pre-pipe shape keeps
|
|
// those payloads exactly as they were instead of retyping them into the contract.
|
|
type CreateBody = Parameters<TripsController['create']>[1];
|
|
const prePipeCreateBody = (body: unknown) => body as CreateBody;
|
|
|
|
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)', async () => {
|
|
it('GET / lists for the user with the archived flag', async () => {
|
|
const list = vi.fn().mockReturnValue([{ id: 1 }]);
|
|
expect(await 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"', async () => {
|
|
const list = vi.fn().mockReturnValue([]);
|
|
const c = tc(svc({ list } as Partial<TripsService>));
|
|
await c.list(user, undefined);
|
|
expect(list).toHaveBeenLastCalledWith(1, 0);
|
|
await 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', async () => {
|
|
const activeTrip = vi.fn().mockReturnValue({
|
|
id: 7,
|
|
title: 'Japan',
|
|
start_date: '2026-09-01',
|
|
end_date: '2026-09-14',
|
|
relevance: 1,
|
|
});
|
|
const res = await 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', async () => {
|
|
const res = await 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('GET /search (#2190)', () => {
|
|
it('hands the query to the service, capped, and answers the contract shape', async () => {
|
|
const searchPlaces = vi.fn().mockResolvedValue([{ trip_id: 4, places: ['Diner'] }]);
|
|
const ctl = tc(svc({ searchPlaces } as Partial<TripsService>));
|
|
const res = await ctl.search(user, 'diner');
|
|
expect(res).toEqual({ matches: [{ trip_id: 4, places: ['Diner'] }] });
|
|
expect(tripSearchResponseSchema.safeParse(res).success).toBe(true);
|
|
await ctl.search(user, 'x'.repeat(300));
|
|
expect(searchPlaces).toHaveBeenLastCalledWith(1, 'x'.repeat(100));
|
|
await ctl.search(user, undefined);
|
|
expect(searchPlaces).toHaveBeenLastCalledWith(1, '');
|
|
});
|
|
});
|
|
|
|
describe('GET /cover-images/search', () => {
|
|
it('passes the caller through to the service and unwraps the photo list', async () => {
|
|
const searchCoverImages = vi.fn().mockResolvedValue({ photos: [{ id: 'p1' }] });
|
|
const s = svc({ searchCoverImages } as Partial<TripsService>);
|
|
expect(await tc(s).coverImages(user, 'kyoto')).toEqual({ photos: [{ id: 'p1' }] });
|
|
// The Unsplash key is resolved per user behind the service; dropping the id
|
|
// here would silently search every request with an admin's key.
|
|
expect(searchCoverImages).toHaveBeenCalledWith('kyoto', 1);
|
|
});
|
|
|
|
it('sends an absent query as "" so the service keeps owning the empty-query 400', async () => {
|
|
const searchCoverImages = vi.fn().mockRejectedValue(new DomainError(400, 'Search query is required'));
|
|
const s = svc({ searchCoverImages } as Partial<TripsService>);
|
|
expect(await thrownAsync(() => tc(s).coverImages(user, undefined))).toEqual({
|
|
status: 400,
|
|
body: { error: 'Search query is required' },
|
|
});
|
|
expect(searchCoverImages).toHaveBeenCalledWith('', 1);
|
|
});
|
|
|
|
it('keeps the upstream status instead of flattening it into the catch-all 500', async () => {
|
|
const s = svc({
|
|
searchCoverImages: vi.fn().mockRejectedValue(new DomainError(429, 'Rate limit reached')),
|
|
} as Partial<TripsService>);
|
|
// A 429 that arrives as a 500 makes the client retry immediately and burn
|
|
// the rest of the hourly Unsplash quota.
|
|
expect(await thrownAsync(() => tc(s).coverImages(user, 'kyoto'))).toEqual({
|
|
status: 429,
|
|
body: { error: 'Rate limit reached' },
|
|
});
|
|
});
|
|
|
|
it('maps an unexpected failure to a 500 that does not leak the cause', async () => {
|
|
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
try {
|
|
const s = svc({
|
|
searchCoverImages: vi.fn().mockRejectedValue(new Error('ECONNREFUSED api.unsplash.com')),
|
|
} as Partial<TripsService>);
|
|
expect(await thrownAsync(() => tc(s).coverImages(user, 'kyoto'))).toEqual({
|
|
status: 500,
|
|
body: { error: 'Error searching for cover images' },
|
|
});
|
|
} finally {
|
|
err.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('POST / (create)', () => {
|
|
it('403 without trip_create; a missing title 400s in the ZodValidationPipe', async () => {
|
|
expect(
|
|
await thrownAsync(() => 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('passes a single start date on as it is; the service fills in the week', async () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
|
await 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: null }));
|
|
});
|
|
|
|
it('400 when end_date precedes start_date', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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('passes a single end date on as it is and parses day_count', async () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
|
await tc(svc({ create } as Partial<TripsService>)).create(
|
|
user,
|
|
prePipeCreateBody({ title: 'T', end_date: '2026-07-07', day_count: '40' }),
|
|
req,
|
|
);
|
|
expect(create).toHaveBeenCalledWith(
|
|
1,
|
|
expect.objectContaining({ start_date: null, end_date: '2026-07-07', day_count: 40 }),
|
|
);
|
|
});
|
|
|
|
it('clamps a non-numeric day_count to the default of 7', async () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
|
await tc(svc({ create } as Partial<TripsService>)).create(
|
|
user,
|
|
prePipeCreateBody({ title: 'T', day_count: 'abc' }),
|
|
req,
|
|
);
|
|
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 }));
|
|
});
|
|
|
|
it('clamps day_count to MAX_TRIP_DAYS', async () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
|
await tc(svc({ create } as Partial<TripsService>)).create(
|
|
user,
|
|
{ title: 'T', day_count: MAX_TRIP_DAYS + 1 },
|
|
req,
|
|
);
|
|
expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: MAX_TRIP_DAYS }));
|
|
});
|
|
|
|
it('maps a ValidationError from create to 400 (range past MAX_TRIP_DAYS)', async () => {
|
|
const create = vi.fn().mockImplementation(() => {
|
|
throw new ValidationError(`A trip can span at most ${MAX_TRIP_DAYS} days`);
|
|
});
|
|
expect(
|
|
await thrownAsync(() =>
|
|
tc(svc({ create } as Partial<TripsService>)).create(
|
|
user,
|
|
{ title: 'T', start_date: '2026-01-01', end_date: '2036-01-01' },
|
|
req,
|
|
),
|
|
),
|
|
).toEqual({
|
|
status: 400,
|
|
body: { error: `A trip can span at most ${MAX_TRIP_DAYS} days` },
|
|
});
|
|
});
|
|
|
|
it('re-throws an unknown error from create', async () => {
|
|
const create = vi.fn().mockImplementation(() => {
|
|
throw new Error('boom');
|
|
});
|
|
await expect(tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).rejects.toThrow(
|
|
'boom',
|
|
);
|
|
});
|
|
|
|
it('logs the reminder when reminderDays is set', async () => {
|
|
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 3 });
|
|
expect(await tc(svc({ create } as Partial<TripsService>)).create(user, { title: 'T' }, req)).toEqual({
|
|
trip: { id: 9 },
|
|
});
|
|
});
|
|
});
|
|
|
|
it('GET /:id 404 when missing', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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', async () => {
|
|
const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial<TripsService>);
|
|
expect(await 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().mockResolvedValue(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', async () => {
|
|
expect(
|
|
await thrownAsync(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', {}, req)),
|
|
).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
|
|
expect(
|
|
await thrownAsync(() =>
|
|
tc(svc({ canAccessTrip: vi.fn().mockResolvedValue(undefined) })).copy(user, '9', {}, req),
|
|
),
|
|
).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
});
|
|
|
|
it('copies + returns the new trip', async () => {
|
|
const s = svc({
|
|
copy: vi.fn().mockReturnValue(42),
|
|
getCopiedTrip: vi.fn().mockReturnValue({ id: 42 }),
|
|
} as Partial<TripsService>);
|
|
expect(await tc(s).copy(user, '9', { title: 'Copy' }, req)).toEqual({ trip: { id: 42 } });
|
|
});
|
|
});
|
|
|
|
describe('DELETE /:id', () => {
|
|
it('404 when no owner, 403 without trip_delete', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(await thrownAsync(() => tc(s).remove(user, '9', req))).toEqual({
|
|
status: 403,
|
|
body: { error: 'No permission to delete this trip' },
|
|
});
|
|
});
|
|
|
|
it('404s for someone with no access, so the 403 is not an existence oracle', async () => {
|
|
const s = svc({
|
|
getOwner: vi.fn().mockResolvedValue({ user_id: 2 }),
|
|
canAccessTrip: vi.fn().mockResolvedValue(null),
|
|
remove: vi.fn(),
|
|
} as Partial<TripsService>);
|
|
expect(await thrownAsync(() => tc(s).remove(user, '9', req))).toEqual({
|
|
status: 404,
|
|
body: { error: 'Trip not found' },
|
|
});
|
|
expect(s.remove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('still lets an admin delete a trip they are not a member of', async () => {
|
|
const admin = { id: 1, role: 'admin', email: 'a@example.test' } as User;
|
|
const remove = vi.fn().mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: true, ownerEmail: 'owner@x.y' });
|
|
const s = svc({
|
|
getOwner: vi.fn().mockResolvedValue({ user_id: 2 }),
|
|
canAccessTrip: vi.fn().mockResolvedValue(null),
|
|
remove,
|
|
broadcast: vi.fn(),
|
|
} as Partial<TripsService>);
|
|
expect(await tc(s).remove(admin, '9', req)).toEqual({ success: true });
|
|
expect(remove).toHaveBeenCalledWith('9', 1, 'admin');
|
|
});
|
|
|
|
it('deletes, audits and broadcasts', async () => {
|
|
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(await 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', async () => {
|
|
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(await 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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(await 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 commits + returns url', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
tc(svc({ canAccessTrip: vi.fn().mockResolvedValue(undefined) })).cover(user, '9', file),
|
|
),
|
|
).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
expect(await thrownAsync(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).cover(user, '9', file))).toEqual({
|
|
status: 403,
|
|
body: { error: 'No permission to change the cover image' },
|
|
});
|
|
expect(
|
|
await thrownAsync(() =>
|
|
tc(svc({ getRaw: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).cover(user, '9', file),
|
|
),
|
|
).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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(await tc(s).cover(user, '9', file)).toEqual({ cover_image: '/uploads/covers/abc.jpg' });
|
|
expect(storageStub.put).toHaveBeenCalledWith('covers', 'abc.jpg', { tmpPath: undefined });
|
|
expect(deleteOldCover).toHaveBeenCalledWith('/old.jpg');
|
|
expect(updateCoverImage).toHaveBeenCalledWith('9', '/uploads/covers/abc.jpg');
|
|
});
|
|
|
|
it('403 in demo mode for a demo account', async () => {
|
|
const prev = process.env.DEMO_MODE;
|
|
process.env.DEMO_MODE = 'true';
|
|
isDemoEmail.mockReturnValueOnce(true);
|
|
try {
|
|
expect(await thrownAsync(() => 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;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* The multer options moved into trips.module.ts's MulterModule factory
|
|
* (storage engine + spool destination are covered by the storage-upload
|
|
* factory unit suite and TRIP-P01/P02 integration parity); the trips-owned
|
|
* piece that remains testable here is the exported cover fileFilter.
|
|
*/
|
|
it('accepts the four allowed image types and rejects svg, a faked extension and a non-image mime', () => {
|
|
const verdict = (originalname: string, mimetype: string) => {
|
|
const cb = vi.fn();
|
|
TRIP_COVER_FILE_FILTER!(req, { originalname, mimetype } as Express.Multer.File, cb);
|
|
return cb.mock.calls[0];
|
|
};
|
|
expect(verdict('a.jpg', 'image/jpeg')).toEqual([null, true]);
|
|
// The extension check is case-insensitive; phones hand up .JPG/.PNG.
|
|
expect(verdict('a.WEBP', 'image/webp')).toEqual([null, true]);
|
|
// svg passes the image/* prefix but runs script when the browser opens the
|
|
// stored cover, so it is rejected even under an allowed extension.
|
|
expect(verdict('logo.png', 'image/svg+xml')).toEqual([expect.any(Error)]);
|
|
// A binary renamed to an image mime must still fail on the extension.
|
|
expect(verdict('payload.exe', 'image/png')).toEqual([expect.any(Error)]);
|
|
expect(verdict('a.png', 'application/octet-stream')).toEqual([expect.any(Error)]);
|
|
// The quirk stays quirky: the rejection error carries NO statusCode, so
|
|
// the route answers 500 (pinned by TRIP-P04), not 400.
|
|
expect((verdict('logo.png', 'image/svg+xml')[0] as Error & { statusCode?: number }).statusCode).toBeUndefined();
|
|
expect(MAX_COVER_SIZE).toBe(20 * 1024 * 1024);
|
|
});
|
|
});
|
|
|
|
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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
tc(svc({ canAccessTrip: vi.fn().mockResolvedValue(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' }) };
|
|
await 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('folds a non-ASCII filename into an ASCII header with filename* instead of crashing setHeader (#2165)', async () => {
|
|
const res = { setHeader: vi.fn(), send: vi.fn() };
|
|
const cal = { exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: '沖縄.ics' }) };
|
|
await tc(svc(), cal).exportIcs(user, '9', res as never);
|
|
expect(res.setHeader).toHaveBeenCalledWith(
|
|
'Content-Disposition',
|
|
'attachment; filename="download.ics"; filename*=UTF-8\'\'%E6%B2%96%E7%B8%84.ics',
|
|
);
|
|
});
|
|
|
|
it('maps a NotFoundError from the export to 404 and re-throws others', async () => {
|
|
const nf = {
|
|
exportICS: vi.fn().mockImplementation(() => {
|
|
throw new NotFoundError('gone');
|
|
}),
|
|
};
|
|
expect(await thrownAsync(() => tc(svc(), nf).exportIcs(user, '9', makeRes()))).toEqual({
|
|
status: 404,
|
|
body: { error: 'gone' },
|
|
});
|
|
const other = {
|
|
exportICS: vi.fn().mockImplementation(() => {
|
|
throw new Error('boom');
|
|
}),
|
|
};
|
|
await expect(tc(svc(), other).exportIcs(user, '9', makeRes())).rejects.toThrow('boom');
|
|
});
|
|
});
|
|
|
|
it('POST /:id/copy maps a copy failure to 500', async () => {
|
|
const s = svc({
|
|
copy: vi.fn().mockImplementation(() => {
|
|
throw new Error('boom');
|
|
}),
|
|
} as Partial<TripsService>);
|
|
expect(await thrownAsync(() => tc(s).copy(user, '9', {}, req))).toEqual({
|
|
status: 500,
|
|
body: { error: 'Failed to copy trip' },
|
|
});
|
|
});
|
|
});
|