mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { AccommodationsController } from '../../../src/nest/reservations/accommodations.controller';
|
||||
import type { AccommodationsService } from '../../../src/nest/reservations/accommodations.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { user_id: 1 };
|
||||
const refs = { place_id: 2, start_day_id: 10, end_day_id: 11 };
|
||||
|
||||
function makeService(overrides: Partial<AccommodationsService> = {}): AccommodationsService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
validateRefs: vi.fn().mockReturnValue([]),
|
||||
...overrides,
|
||||
} as unknown as AccommodationsService;
|
||||
}
|
||||
|
||||
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('AccommodationsController (parity with the legacy accommodations sub-router)', () => {
|
||||
it('404 when trip not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new AccommodationsController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('GET / lists (no permission gate)', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).list(user, '5')).toEqual({ accommodations: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without day_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new AccommodationsController(svc).create(user, '5', refs))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('400 when refs are missing', () => {
|
||||
expect(thrown(() => new AccommodationsController(makeService()).create(user, '5', { place_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'place_id, start_day_id, and end_day_id are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 with the first validateRefs error message', () => {
|
||||
const svc = makeService({ validateRefs: vi.fn().mockReturnValue([{ field: 'place_id', message: 'Place not found' }]) } as Partial<AccommodationsService>);
|
||||
expect(thrown(() => new AccommodationsController(svc).create(user, '5', refs))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
});
|
||||
|
||||
it('creates and emits accommodation:created + reservation:created', () => {
|
||||
const create = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ create, broadcast } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({ accommodation: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', { accommodation: { id: 9 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', {}, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when the accommodation is missing', () => {
|
||||
const svc = makeService({ get: vi.fn().mockReturnValue(undefined) } as Partial<AccommodationsService>);
|
||||
expect(thrown(() => new AccommodationsController(svc).update(user, '5', '9', refs))).toEqual({ status: 404, body: { error: 'Accommodation not found' } });
|
||||
});
|
||||
|
||||
it('updates and broadcasts', () => {
|
||||
const get = vi.fn().mockReturnValue({ id: 9 });
|
||||
const update = vi.fn().mockReturnValue({ id: 9, notes: 'x' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ get, update, broadcast } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')).toEqual({ accommodation: { id: 9, notes: 'x' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', { accommodation: { id: 9, notes: 'x' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when missing', () => {
|
||||
const svc = makeService({ get: vi.fn().mockReturnValue(undefined) } as Partial<AccommodationsService>);
|
||||
expect(thrown(() => new AccommodationsController(svc).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Accommodation not found' } });
|
||||
});
|
||||
|
||||
it('emits the linked reservation/budget cascade then accommodation:deleted', () => {
|
||||
const get = vi.fn().mockReturnValue({ id: 9 });
|
||||
const remove = vi.fn().mockReturnValue({ linkedReservationId: 4, deletedBudgetItemId: 7 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ get, remove, broadcast } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 4 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException, NotFoundException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { AdminController } from '../../../src/nest/admin/admin.controller';
|
||||
import type { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User;
|
||||
const req = { headers: {} } as Request;
|
||||
|
||||
function svc(o: Partial<AdminService> = {}): AdminService {
|
||||
return { invalidateMcpSessions: vi.fn(), ...o } as unknown as AdminService;
|
||||
}
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() };
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => { delete process.env.NODE_ENV; });
|
||||
|
||||
describe('AdminController users', () => {
|
||||
it('lists, creates (201 + audit), maps an error', () => {
|
||||
expect(new AdminController(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
|
||||
expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
||||
const c = new AdminController(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial<AdminService>));
|
||||
expect(c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' }));
|
||||
});
|
||||
|
||||
it('update + delete audit and map errors', () => {
|
||||
expect(new AdminController(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial<AdminService>)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } });
|
||||
expect(thrown(() => new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial<AdminService>)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } });
|
||||
expect(new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial<AdminService>)).deleteUser(user, '2', req)).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController permissions + oidc + misc', () => {
|
||||
it('permissions: 400 without an object, else saves + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).savePermissions(user, {}, req))).toEqual({ status: 400, body: { error: 'permissions object required' } });
|
||||
const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }) } as Partial<AdminService>));
|
||||
expect(c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 } });
|
||||
});
|
||||
|
||||
it('permissions: includes skipped when present', () => {
|
||||
const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial<AdminService>));
|
||||
expect(c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'] });
|
||||
});
|
||||
|
||||
it('oidc update maps error, else audits', () => {
|
||||
expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'bad issuer', status: 400 }) } as Partial<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'bad issuer' } });
|
||||
expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).updateOidc(user, { issuer: 'https://idp' }, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('save-demo-baseline maps error, else returns message', () => {
|
||||
expect(thrown(() => new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
|
||||
expect(new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController invites + feature toggles', () => {
|
||||
it('invites: create 201 + audit, delete maps error', () => {
|
||||
const c = new AdminController(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial<AdminService>));
|
||||
expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' }));
|
||||
expect(thrown(() => new AdminController(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial<AdminService>)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } });
|
||||
});
|
||||
|
||||
it('places-photos: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesPhotos(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).updatePlacesPhotos(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('collab-features update invalidates MCP sessions + audits', () => {
|
||||
const invalidateMcpSessions = vi.fn();
|
||||
const c = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ chat: true }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
expect(c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true });
|
||||
expect(invalidateMcpSessions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController packing templates', () => {
|
||||
it('get 404, create 201, delete audits', () => {
|
||||
expect(thrown(() => new AdminController(svc({ getPackingTemplate: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial<AdminService>)).getPackingTemplate('9'))).toEqual({ status: 404, body: { error: 'not found' } });
|
||||
expect(new AdminController(svc({ createPackingTemplate: vi.fn().mockReturnValue({ id: 3, name: 'Beach' }) } as Partial<AdminService>)).createPackingTemplate(user, { name: 'Beach' })).toEqual({ id: 3, name: 'Beach' });
|
||||
expect(new AdminController(svc({ deletePackingTemplate: vi.fn().mockReturnValue({ name: 'Beach' }) } as Partial<AdminService>)).deletePackingTemplate(user, '3', req)).toEqual({ success: true });
|
||||
expect(new AdminController(svc({ createTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).createTemplateItem('3', '4', { name: 'Towel' })).toEqual({ id: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController addons + sessions + jwt + defaults', () => {
|
||||
it('addon update audits + invalidates MCP sessions', () => {
|
||||
const invalidateMcpSessions = vi.fn();
|
||||
const c = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, auditDetails: {} }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
expect(c.updateAddon(user, 'mcp', { enabled: true }, req)).toEqual({ addon: { id: 'mcp', enabled: true } });
|
||||
expect(invalidateMcpSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('oauth-sessions revoke audits; rotate-jwt maps error', () => {
|
||||
expect(new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).revokeOAuthSession(user, '3', req)).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
|
||||
expect(new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).rotateJwtSecret(user, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('default-user-settings: 400 on a non-object, else sets + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, [], req))).toEqual({ status: 400, body: { error: 'Object body required' } });
|
||||
const setAdminUserDefaults = vi.fn();
|
||||
const c = new AdminController(svc({ setAdminUserDefaults, getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>));
|
||||
expect(c.setDefaultUserSettings(user, { theme: 'dark' }, req)).toEqual({ theme: 'dark' });
|
||||
expect(setAdminUserDefaults).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController dev test-notification', () => {
|
||||
it('404 outside development', async () => {
|
||||
delete process.env.NODE_ENV;
|
||||
await expect(new AdminController(svc()).devTestNotification(user, {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('sends in development', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = await new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' });
|
||||
expect(res).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { AirportsController } from '../../../src/nest/airports/airports.controller';
|
||||
import type { AirportsService } from '../../../src/nest/airports/airports.service';
|
||||
import type { Airport } from '@trek/shared';
|
||||
|
||||
function makeController(svc: Partial<AirportsService>) {
|
||||
return new AirportsController(svc as AirportsService);
|
||||
}
|
||||
|
||||
const BER: Airport = {
|
||||
iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin',
|
||||
country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin',
|
||||
};
|
||||
|
||||
/** Run `fn`, expecting an HttpException; return its { status, body }. */
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('AirportsController (parity with the legacy /api/airports route)', () => {
|
||||
describe('GET /api/airports/search', () => {
|
||||
it('returns [] without calling the service when the query is absent', () => {
|
||||
const search = vi.fn();
|
||||
const res = makeController({ search }).search(undefined);
|
||||
expect(res).toEqual([]);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns [] for an empty query', () => {
|
||||
const search = vi.fn();
|
||||
expect(makeController({ search }).search('')).toEqual([]);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns [] when the query arrives as an array (Express typeof guard)', () => {
|
||||
const search = vi.fn();
|
||||
expect(makeController({ search }).search(['a', 'b'])).toEqual([]);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates a non-empty query to the service and returns its result', () => {
|
||||
const search = vi.fn().mockReturnValue([BER]);
|
||||
const res = makeController({ search }).search('ber');
|
||||
expect(res).toEqual([BER]);
|
||||
expect(search).toHaveBeenCalledWith('ber');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/airports/:iata', () => {
|
||||
it('returns the airport when found', () => {
|
||||
const findByIata = vi.fn().mockReturnValue(BER);
|
||||
expect(makeController({ findByIata }).findByIata('BER')).toEqual(BER);
|
||||
expect(findByIata).toHaveBeenCalledWith('BER');
|
||||
});
|
||||
|
||||
it('404 { error } with the exact legacy message when not found', () => {
|
||||
const findByIata = vi.fn().mockReturnValue(null);
|
||||
expect(thrown(() => makeController({ findByIata }).findByIata('ZZZ'))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'Airport not found' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
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), notifyPlaceCreated: 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)', () => {
|
||||
it('404 trip, then 404 day on GET', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).list(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).list(user, '5', '3'))).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('403 without day_edit; 404 place not found; then creates + hooks', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', '3', { place_id: 2 }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
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 notifyPlaceCreated = vi.fn();
|
||||
const s = svc({ createAssignment, broadcast, notifyPlaceCreated } 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(notifyPlaceCreated).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
});
|
||||
|
||||
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', [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', [2, 1], 'sock')).toEqual({ success: true });
|
||||
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 s = svc({ assignmentExistsInDay: vi.fn().mockReturnValue(true), deleteAssignment: vi.fn() } as Partial<AssignmentsService>);
|
||||
expect(new DayAssignmentsController(s).remove(user, '5', '3', '9')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssignmentOpsController (parity with the per-assignment op routes)', () => {
|
||||
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 } }); const broadcast = vi.fn();
|
||||
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), moveAssignment, broadcast } 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, 3);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:moved', { assignment: { id: 9 }, oldDayId: 3, newDayId: 4 }, 'sock');
|
||||
});
|
||||
|
||||
it('GET /:id/participants returns participants (access-only)', () => {
|
||||
const s = svc({ 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({ id: 9 }); 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(updateTime).toHaveBeenCalledWith('9', '10:00', undefined);
|
||||
});
|
||||
|
||||
it('PUT /:id/participants 400 not array, else sets + broadcasts', () => {
|
||||
expect(thrown(() => new AssignmentOpsController(svc()).setParticipants(user, '5', '9', 'no'))).toEqual({ status: 400, body: { error: 'user_ids must be an array' } });
|
||||
const setParticipants = vi.fn().mockReturnValue([{ user_id: 2 }]); const broadcast = vi.fn();
|
||||
expect(new AssignmentOpsController(svc({ setParticipants, broadcast } as Partial<AssignmentsService>)).setParticipants(user, '5', '9', [2], 'sock')).toEqual({ participants: [{ user_id: 2 }] });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:participants', { assignmentId: 9, participants: [{ user_id: 2 }] }, 'sock');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AtlasController } from '../../../src/nest/atlas/atlas.controller';
|
||||
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 8 } as User;
|
||||
|
||||
function makeController(svc: Partial<AtlasService>) {
|
||||
return new AtlasController(svc as AtlasService);
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return { setHeader: vi.fn() } as unknown as Response & { setHeader: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('AtlasController (parity with the legacy /api/addons/atlas route)', () => {
|
||||
it('GET /stats delegates with the user id', () => {
|
||||
const stats = vi.fn().mockReturnValue({ countries: 3 });
|
||||
expect(makeController({ stats }).stats(user)).toEqual({ countries: 3 });
|
||||
expect(stats).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
describe('GET /regions/geo', () => {
|
||||
it('returns an empty FeatureCollection without a cache header when no countries given', async () => {
|
||||
const regionGeo = vi.fn();
|
||||
const res = makeRes();
|
||||
const out = await makeController({ regionGeo }).regionGeo(undefined, res);
|
||||
expect(out).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
expect(regionGeo).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caches a non-empty result for a day', async () => {
|
||||
const regionGeo = vi.fn().mockResolvedValue({ type: 'FeatureCollection', features: [{ id: 1 }] });
|
||||
const res = makeRes();
|
||||
const out = await makeController({ regionGeo }).regionGeo('DE,FR', res);
|
||||
expect(out).toEqual({ type: 'FeatureCollection', features: [{ id: 1 }] });
|
||||
expect(regionGeo).toHaveBeenCalledWith(['DE', 'FR']);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=86400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('country', () => {
|
||||
it('GET /country/:code upper-cases the code', () => {
|
||||
const countryPlaces = vi.fn().mockReturnValue([]);
|
||||
makeController({ countryPlaces }).countryPlaces(user, 'de');
|
||||
expect(countryPlaces).toHaveBeenCalledWith(8, 'DE');
|
||||
});
|
||||
|
||||
it('POST mark returns success and upper-cases', () => {
|
||||
const markCountry = vi.fn();
|
||||
expect(makeController({ markCountry }).markCountry(user, 'de')).toEqual({ success: true });
|
||||
expect(markCountry).toHaveBeenCalledWith(8, 'DE');
|
||||
});
|
||||
|
||||
it('DELETE mark returns success', () => {
|
||||
const unmarkCountry = vi.fn();
|
||||
expect(makeController({ unmarkCountry }).unmarkCountry(user, 'FR')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('region', () => {
|
||||
it('400 when name or country_code is missing', () => {
|
||||
const markRegion = vi.fn();
|
||||
return thrown(() => makeController({ markRegion }).markRegion(user, 'by', undefined, 'DE')).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'name and country_code are required' } }));
|
||||
});
|
||||
|
||||
it('marks a region, upper-casing both codes', () => {
|
||||
const markRegion = vi.fn();
|
||||
expect(makeController({ markRegion }).markRegion(user, 'by', 'Bavaria', 'de')).toEqual({ success: true });
|
||||
expect(markRegion).toHaveBeenCalledWith(8, 'BY', 'Bavaria', 'DE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucket list', () => {
|
||||
it('GET wraps the items', () => {
|
||||
const bucketList = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(makeController({ bucketList }).bucketList(user)).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('400 on create with a blank name', () => {
|
||||
const createBucketItem = vi.fn();
|
||||
return thrown(() => makeController({ createBucketItem }).createBucketItem(user, { name: ' ' })).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'Name is required' } }));
|
||||
});
|
||||
|
||||
it('201-shape create returns { item }', () => {
|
||||
const createBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Tokyo' });
|
||||
expect(makeController({ createBucketItem }).createBucketItem(user, { name: 'Tokyo', lat: 35, lng: 139 }))
|
||||
.toEqual({ item: { id: 1, name: 'Tokyo' } });
|
||||
expect(createBucketItem).toHaveBeenCalledWith(8, { name: 'Tokyo', lat: 35, lng: 139, country_code: undefined, notes: undefined, target_date: undefined });
|
||||
});
|
||||
|
||||
it('404 on update of a missing item', () => {
|
||||
const updateBucketItem = vi.fn().mockReturnValue(null);
|
||||
return thrown(() => makeController({ updateBucketItem }).updateBucketItem(user, '9', { name: 'X' })).then((r) =>
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }));
|
||||
});
|
||||
|
||||
it('updates an existing item', () => {
|
||||
const updateBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Kyoto' });
|
||||
expect(makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Kyoto' }))
|
||||
.toEqual({ item: { id: 1, name: 'Kyoto' } });
|
||||
});
|
||||
|
||||
it('404 on delete of a missing item', () => {
|
||||
const deleteBucketItem = vi.fn().mockReturnValue(false);
|
||||
return thrown(() => makeController({ deleteBucketItem }).deleteBucketItem(user, '9')).then((r) =>
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }));
|
||||
});
|
||||
|
||||
it('deletes an existing item', () => {
|
||||
const deleteBucketItem = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ deleteBucketItem }).deleteBucketItem(user, '1')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
import { AuthPublicController } from '../../../src/nest/auth/auth-public.controller';
|
||||
import { AuthController } from '../../../src/nest/auth/auth.controller';
|
||||
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
|
||||
import type { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import { isDemoEmail } from '../../../src/services/demo';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
const req = { ip: '9.9.9.9', headers: {} } as Request;
|
||||
const res = {} as Response;
|
||||
|
||||
function asvc(o: Partial<AuthService> = {}): AuthService {
|
||||
return { setAuthCookie: vi.fn(), clearAuthCookie: vi.fn(), getAppUrl: vi.fn(() => 'https://x'), sendPasswordResetEmail: vi.fn(), ...o } as unknown as AuthService;
|
||||
}
|
||||
function rl(): RateLimitService { return new RateLimitService(); }
|
||||
|
||||
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());
|
||||
afterEach(() => { delete process.env.DEMO_MODE; });
|
||||
|
||||
describe('RateLimitService', () => {
|
||||
it('allows up to max then blocks within the window; buckets are isolated', () => {
|
||||
const s = rl();
|
||||
expect(s.check('login', 'ip', 2, 1000, 0)).toBe(true);
|
||||
expect(s.check('login', 'ip', 2, 1000, 10)).toBe(true);
|
||||
expect(s.check('login', 'ip', 2, 1000, 20)).toBe(false); // 3rd within window
|
||||
expect(s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket
|
||||
expect(s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthPublicController', () => {
|
||||
it('demo-login maps error, else sets the cookie + returns token/user', () => {
|
||||
expect(thrown(() => new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial<AuthService>), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } });
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.demoLogin(req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
|
||||
});
|
||||
|
||||
it('register audits + sets cookie; maps error', () => {
|
||||
expect(thrown(() => new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AuthService>), rl()).register({}, req, res))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.register' }));
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invite 429 when rate-limited', () => {
|
||||
const s = rl();
|
||||
s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet
|
||||
const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial<AuthService>), s);
|
||||
expect(c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null });
|
||||
});
|
||||
|
||||
it('login: mfa branch, success cookie, error mapping', async () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
|
||||
expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
||||
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password issues a reset email then returns the generic ok', async () => {
|
||||
const sendPasswordResetEmail = vi.fn().mockResolvedValue({ delivered: true });
|
||||
const c = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail } as Partial<AuthService>), rl());
|
||||
expect(await c.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true });
|
||||
expect(sendPasswordResetEmail).toHaveBeenCalledWith('a@b.c', 'https://x/reset-password?token=rt', 1);
|
||||
}, 10000);
|
||||
|
||||
it('reset-password: error audits a fail, mfa branch, success', () => {
|
||||
expect(thrown(() => new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial<AuthService>), rl()).resetPassword({}, req))).toEqual({ status: 400, body: { error: 'Invalid token' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' }));
|
||||
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ mfa_required: true });
|
||||
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('mfa/verify-login sets cookie + audits; logout clears cookie', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
const clearAuthCookie = vi.fn();
|
||||
expect(new AuthPublicController(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(req, res)).toEqual({ success: true });
|
||||
expect(clearAuthCookie).toHaveBeenCalledWith(res, req);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController (authenticated)', () => {
|
||||
it('GET /me 404 when missing, else returns the loaded user', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
|
||||
expect(new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), rl()).me(user)).toEqual({ user: { id: 1 } });
|
||||
});
|
||||
|
||||
it('change-password maps error, else audits', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial<AuthService>), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } });
|
||||
expect(new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' }));
|
||||
});
|
||||
|
||||
it('avatar 403 in demo mode, 400 without a file, else saves', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockReturnValue(true);
|
||||
expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } });
|
||||
vi.mocked(isDemoEmail).mockReturnValue(false);
|
||||
delete process.env.DEMO_MODE;
|
||||
expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } });
|
||||
const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/a.jpg' });
|
||||
expect(await new AuthController(asvc({ saveAvatar } as Partial<AuthService>), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File)).toEqual({ avatar: '/a.jpg' });
|
||||
});
|
||||
|
||||
it('mfa/setup awaits the QR promise, maps a generation failure to 500', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ok = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.resolve('<svg>') }) } as Partial<AuthService>), rl());
|
||||
expect(await ok.mfaSetup(user)).toEqual({ secret: 's', otpauth_url: 'o', qr_svg: '<svg>' });
|
||||
const fail = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => fail.mfaSetup(user))).toEqual({ status: 500, body: { error: 'Could not generate QR code' } });
|
||||
});
|
||||
|
||||
it('mfa/enable audits + returns backup codes; mcp-tokens create 201', () => {
|
||||
const enable = new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial<AuthService>), rl());
|
||||
expect(enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' }));
|
||||
const tok = new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) } as Partial<AuthService>), rl());
|
||||
expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' });
|
||||
});
|
||||
|
||||
it('resource-token 503 when unavailable, else returns the token payload', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue(null) } as Partial<AuthService>), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } });
|
||||
expect(new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial<AuthService>), rl()).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' });
|
||||
});
|
||||
|
||||
it('rate-limited account ops throw 429 once the bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
// exhaust the shared 'login' bucket for this ip (max 5)
|
||||
for (let i = 0; i < 5; i++) s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
// The controller imports the tmp-dir + size cap at module load.
|
||||
vi.mock('../../../src/services/backupService', () => ({ getUploadTmpDir: () => '/tmp', MAX_BACKUP_UPLOAD_SIZE: 1024 }));
|
||||
|
||||
import { BackupController } from '../../../src/nest/backup/backup.controller';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
import type { BackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'a@example.test' } as User;
|
||||
const req = { ip: '1.2.3.4', headers: {} } as Request;
|
||||
|
||||
function svc(o: Partial<BackupService> = {}): BackupService {
|
||||
return {
|
||||
listBackups: vi.fn().mockReturnValue([]),
|
||||
createBackup: vi.fn(),
|
||||
restoreFromZip: vi.fn(),
|
||||
getAutoSettings: vi.fn(),
|
||||
updateAutoSettings: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
isValidBackupFilename: vi.fn().mockReturnValue(true),
|
||||
backupFilePath: vi.fn().mockReturnValue('/b/x.zip'),
|
||||
backupFileExists: vi.fn().mockReturnValue(true),
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
rateWindow: 3600000,
|
||||
...o,
|
||||
} as unknown as BackupService;
|
||||
}
|
||||
|
||||
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());
|
||||
afterEach(() => { delete process.env.NODE_ENV; });
|
||||
|
||||
describe('AdminGuard (used by BackupController)', () => {
|
||||
function ctx(role?: string) {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }) } as never;
|
||||
}
|
||||
it('403 for a non-admin, passes for an admin', () => {
|
||||
expect(thrown(() => new AdminGuard().canActivate(ctx('user')))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
expect(new AdminGuard().canActivate(ctx('admin'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupController', () => {
|
||||
it('GET /list returns backups, 500 on error', () => {
|
||||
expect(new BackupController(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial<BackupService>)).list()).toEqual({ backups: [{ filename: 'a.zip' }] });
|
||||
expect(thrown(() => new BackupController(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).list())).toEqual({ status: 500, body: { error: 'Error loading backups' } });
|
||||
});
|
||||
|
||||
it('POST /create 429 when rate-limited, else creates + audits', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ checkRateLimit: vi.fn().mockReturnValue(false) })).create(user, req))).toEqual({ status: 429, body: { error: 'Too many backup requests. Please try again later.' } });
|
||||
const createBackup = vi.fn().mockResolvedValue({ filename: 'b.zip', size: 10 });
|
||||
const res = await new BackupController(svc({ createBackup } as Partial<BackupService>)).create(user, req);
|
||||
expect(res).toEqual({ success: true, backup: { filename: 'b.zip', size: 10 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.create', resource: 'b.zip' }));
|
||||
});
|
||||
|
||||
it('GET /download 400 invalid / 404 missing, else res.download', () => {
|
||||
const res = { download: vi.fn() } as unknown as Response;
|
||||
expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).download('x.zip', res))).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
||||
new BackupController(svc()).download('x.zip', res);
|
||||
expect(res.download).toHaveBeenCalledWith('/b/x.zip', 'x.zip');
|
||||
});
|
||||
|
||||
it('POST /restore maps the service status, else audits', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } });
|
||||
const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).restore(user, 'x.zip', req);
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' }));
|
||||
});
|
||||
|
||||
it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore success audits + reports', async () => {
|
||||
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
|
||||
const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, file, req);
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'up.zip' }));
|
||||
});
|
||||
|
||||
it('POST /upload-restore maps a failed restore status', async () => {
|
||||
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } });
|
||||
});
|
||||
|
||||
it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await thrownAsync(() => new BackupController(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial<BackupService>)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
|
||||
expect(thrown(() => new BackupController(svc({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.env.NODE_ENV = 'development';
|
||||
const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' });
|
||||
});
|
||||
|
||||
it('GET/PUT /auto-settings', () => {
|
||||
expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
|
||||
const res = new BackupController(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
|
||||
expect(res).toEqual({ settings: { enabled: true, interval: 'daily', keep_days: 7 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.auto_settings' }));
|
||||
});
|
||||
|
||||
it('DELETE /:filename 400/404, else deletes + audits', () => {
|
||||
expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).remove(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
||||
const deleteBackup = vi.fn();
|
||||
expect(new BackupController(svc({ deleteBackup } as Partial<BackupService>)).remove(user, 'x.zip', req)).toEqual({ success: true });
|
||||
expect(deleteBackup).toHaveBeenCalledWith('x.zip');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { BudgetController } from '../../../src/nest/budget/budget.controller';
|
||||
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { id: 5, user_id: 1 };
|
||||
|
||||
function makeService(overrides: Partial<BudgetService> = {}): BudgetService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
syncReservationPrice: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as BudgetService;
|
||||
}
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => {
|
||||
it('404 when the trip is not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new BudgetController(svc).list(user, '5'))).toEqual({
|
||||
status: 404, body: { error: 'Trip not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('GET / returns items', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('GET /summary/per-person + /settlement delegate', () => {
|
||||
const svc = makeService({
|
||||
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
|
||||
settlement: vi.fn().mockReturnValue({ transfers: [] }),
|
||||
} as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
expect(new BudgetController(svc).settlement(user, '5')).toEqual({ transfers: [] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).create(user, '5', { name: 'Hotel' }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 when name missing', () => {
|
||||
expect(thrown(() => new BudgetController(makeService()).create(user, '5', {}))).toEqual({
|
||||
status: 400, body: { error: 'Name is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates and broadcasts', () => {
|
||||
const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ create, broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when item missing', () => {
|
||||
const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs the reservation price when a linked item changes total_price', () => {
|
||||
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 });
|
||||
const syncReservationPrice = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ update, syncReservationPrice, broadcast } as Partial<BudgetService>);
|
||||
new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock');
|
||||
expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock');
|
||||
});
|
||||
|
||||
it('does not sync when the item has no linked reservation', () => {
|
||||
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 });
|
||||
const syncReservationPrice = vi.fn();
|
||||
const svc = makeService({ update, syncReservationPrice } as Partial<BudgetService>);
|
||||
new BudgetController(svc).update(user, '5', '9', { total_price: 250 });
|
||||
expect(syncReservationPrice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/members', () => {
|
||||
it('400 when user_ids is not an array', () => {
|
||||
expect(thrown(() => new BudgetController(makeService()).updateMembers(user, '5', '9', 'nope'))).toEqual({
|
||||
status: 400, body: { error: 'user_ids must be an array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ updateMembers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).updateMembers(user, '5', '9', [2, 3]))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates members and broadcasts persons count', () => {
|
||||
const updateMembers = vi.fn().mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateMembers, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).updateMembers(user, '5', '9', [2], 'sock');
|
||||
expect(res).toEqual({ members: [{ user_id: 2 }], item: { persons: 1 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:members-updated', { itemId: 9, members: [{ user_id: 2 }], persons: 1 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => {
|
||||
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', true, 'sock')).toEqual({ member: { user_id: 2, paid: 1 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when missing, success otherwise', () => {
|
||||
const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
const ok = makeService({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial<BudgetService>);
|
||||
expect(new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('PUT /reorder/items + /reorder/categories broadcast budget:reordered', () => {
|
||||
const reorderItems = vi.fn(); const reorderCategories = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = makeService({ reorderItems, reorderCategories, broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).reorderItems(user, '5', [3, 1], 'sock')).toEqual({ success: true });
|
||||
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
expect(new BudgetController(svc).reorderCategories(user, '5', ['food', 'fun'], 'sock')).toEqual({ success: true });
|
||||
expect(reorderCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { CategoriesController } from '../../../src/nest/categories/categories.controller';
|
||||
import type { CategoriesService } from '../../../src/nest/categories/categories.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { Category } from '@trek/shared';
|
||||
|
||||
const admin = { id: 1, role: 'admin' } as User;
|
||||
|
||||
function makeController(svc: Partial<CategoriesService>) {
|
||||
return new CategoriesController(svc as CategoriesService);
|
||||
}
|
||||
|
||||
const cat: Category = { id: 1, name: 'Food', color: '#fff', icon: '🍔' };
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('CategoriesController (parity with the legacy /api/categories route)', () => {
|
||||
it('GET / returns the category list wrapped in { categories }', () => {
|
||||
const list = vi.fn().mockReturnValue([cat]);
|
||||
expect(makeController({ list }).list()).toEqual({ categories: [cat] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('400 when name is missing', () => {
|
||||
const create = vi.fn();
|
||||
expect(thrown(() => makeController({ create }).create(admin, undefined))).toEqual({
|
||||
status: 400, body: { error: 'Category name is required' },
|
||||
});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates and returns { category }', () => {
|
||||
const create = vi.fn().mockReturnValue(cat);
|
||||
expect(makeController({ create }).create(admin, 'Food', '#fff', '🍔')).toEqual({ category: cat });
|
||||
expect(create).toHaveBeenCalledWith(1, 'Food', '#fff', '🍔');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when the category does not exist', () => {
|
||||
const getById = vi.fn().mockReturnValue(undefined);
|
||||
const update = vi.fn();
|
||||
expect(thrown(() => makeController({ getById, update }).update('9', 'X'))).toEqual({
|
||||
status: 404, body: { error: 'Category not found' },
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates and returns { category }', () => {
|
||||
const getById = vi.fn().mockReturnValue(cat);
|
||||
const update = vi.fn().mockReturnValue({ ...cat, name: 'Drinks' });
|
||||
expect(makeController({ getById, update }).update('1', 'Drinks')).toEqual({ category: { ...cat, name: 'Drinks' } });
|
||||
expect(update).toHaveBeenCalledWith('1', 'Drinks', undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when the category does not exist', () => {
|
||||
const getById = vi.fn().mockReturnValue(undefined);
|
||||
const remove = vi.fn();
|
||||
expect(thrown(() => makeController({ getById, remove }).remove('9'))).toEqual({
|
||||
status: 404, body: { error: 'Category not found' },
|
||||
});
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes and returns { success: true }', () => {
|
||||
const getById = vi.fn().mockReturnValue(cat);
|
||||
const remove = vi.fn();
|
||||
expect(makeController({ getById, remove }).remove('1')).toEqual({ success: true });
|
||||
expect(remove).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { CollabController } from '../../../src/nest/collab/collab.controller';
|
||||
import type { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function svc(o: Partial<CollabService> = {}): CollabService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
canUploadFiles: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
notifyCollab: vi.fn(),
|
||||
...o,
|
||||
} as unknown as CollabService;
|
||||
}
|
||||
|
||||
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('CollabController (parity with the legacy /api/trips/:tripId/collab route)', () => {
|
||||
describe('notes', () => {
|
||||
it('GET 404 without access, else lists', () => {
|
||||
expect(thrown(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).listNotes(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const s = svc({ listNotes: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<CollabService>);
|
||||
expect(new CollabController(s).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST 403 without collab_edit, 400 without title, else creates + broadcasts + notifies', () => {
|
||||
expect(thrown(() => new CollabController(svc({ canEdit: vi.fn().mockReturnValue(false) })).createNote(user, '5', { title: 'T' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new CollabController(svc()).createNote(user, '5', {}))).toEqual({ status: 400, body: { error: 'Title is required' } });
|
||||
const createNote = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const notifyCollab = vi.fn();
|
||||
const s = svc({ createNote, broadcast, notifyCollab } as Partial<CollabService>);
|
||||
expect(new CollabController(s).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({ note: { id: 9 } });
|
||||
expect(createNote).toHaveBeenCalledWith('5', 1, { title: 'T', content: 'c', category: undefined, color: undefined, website: undefined });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:created', { note: { id: 9 } }, 'sock');
|
||||
expect(notifyCollab).toHaveBeenCalledWith('5', user);
|
||||
});
|
||||
|
||||
it('PUT 404 when missing, else updates + broadcasts', () => {
|
||||
expect(thrown(() => new CollabController(svc({ updateNote: vi.fn().mockReturnValue(null) } as Partial<CollabService>)).updateNote(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ updateNote: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({ note: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE 404 when missing, else success + broadcasts', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deleteNote: vi.fn().mockReturnValue(false) } as Partial<CollabService>)).deleteNote(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ deleteNote: vi.fn().mockReturnValue(true), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:deleted', { noteId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('note files', () => {
|
||||
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
||||
it('403 without file_upload, 400 without file, 404 unknown note, else returns result', () => {
|
||||
expect(thrown(() => new CollabController(svc({ canUploadFiles: vi.fn().mockReturnValue(false) })).addNoteFile(user, '5', '9', file))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
||||
expect(thrown(() => new CollabController(svc()).addNoteFile(user, '5', '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
expect(thrown(() => new CollabController(svc({ addNoteFile: vi.fn().mockReturnValue(null) } as Partial<CollabService>)).addNoteFile(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ addNoteFile: vi.fn().mockReturnValue({ file: { id: 3 } }), getFormattedNoteById: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).addNoteFile(user, '5', '9', file, 'sock')).toEqual({ file: { id: 3 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE file 404 when missing, else success', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deleteNoteFile: vi.fn().mockReturnValue(false) } as Partial<CollabService>)).deleteNoteFile(user, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const s = svc({ deleteNoteFile: vi.fn().mockReturnValue(true), getFormattedNoteById: vi.fn().mockReturnValue({ id: 9 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('polls', () => {
|
||||
it('POST 400 without question / <2 options, else creates', () => {
|
||||
expect(thrown(() => new CollabController(svc()).createPoll(user, '5', {}))).toEqual({ status: 400, body: { error: 'Question is required' } });
|
||||
expect(thrown(() => new CollabController(svc()).createPoll(user, '5', { question: 'q', options: ['only'] }))).toEqual({ status: 400, body: { error: 'At least 2 options are required' } });
|
||||
const s = svc({ createPoll: vi.fn().mockReturnValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({ poll: { id: 7 } });
|
||||
});
|
||||
|
||||
it('vote maps not_found/closed/invalid_index, else broadcasts the poll', () => {
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 0))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'closed' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 0))).toEqual({ status: 400, body: { error: 'Poll is closed' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'invalid_index' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 9))).toEqual({ status: 400, body: { error: 'Invalid option index' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ votePoll: vi.fn().mockReturnValue({ poll: { id: 7 } }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).votePoll(user, '5', '7', 0, 'sock')).toEqual({ poll: { id: 7 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock');
|
||||
});
|
||||
|
||||
it('close 404 when missing, else broadcasts', () => {
|
||||
expect(thrown(() => new CollabController(svc({ closePoll: vi.fn().mockReturnValue(null) } as Partial<CollabService>)).closePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
const s = svc({ closePoll: vi.fn().mockReturnValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
|
||||
});
|
||||
|
||||
it('delete 404 when missing, else success', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deletePoll: vi.fn().mockReturnValue(false) } as Partial<CollabService>)).deletePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
const s = svc({ deletePoll: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deletePoll(user, '5', '7')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
it('POST 400 over 5000 chars (before access), 400 empty, 400 reply_not_found, else creates + notifies', () => {
|
||||
expect(thrown(() => new CollabController(svc()).createMessage(user, '5', { text: 'x'.repeat(5001) }))).toEqual({ status: 400, body: { error: 'text must be 5000 characters or less' } });
|
||||
expect(thrown(() => new CollabController(svc()).createMessage(user, '5', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Message text is required' } });
|
||||
expect(thrown(() => new CollabController(svc({ createMessage: vi.fn().mockReturnValue({ error: 'reply_not_found' }) } as Partial<CollabService>)).createMessage(user, '5', { text: 'hi', reply_to: 99 }))).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const notifyCollab = vi.fn();
|
||||
const s = svc({ createMessage: vi.fn().mockReturnValue({ message: { id: 3 } }), broadcast, notifyCollab } as Partial<CollabService>);
|
||||
expect(new CollabController(s).createMessage(user, '5', { text: 'hello' }, 'sock')).toEqual({ message: { id: 3 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:created', { message: { id: 3 } }, 'sock');
|
||||
expect(notifyCollab).toHaveBeenCalledWith('5', user, 'hello');
|
||||
});
|
||||
|
||||
it('react 400 without emoji, 404 unknown, else broadcasts reactions', () => {
|
||||
expect(thrown(() => new CollabController(svc()).react(user, '5', '3', ''))).toEqual({ status: 400, body: { error: 'Emoji is required' } });
|
||||
expect(thrown(() => new CollabController(svc({ reactMessage: vi.fn().mockReturnValue({ found: false, reactions: [] }) } as Partial<CollabService>)).react(user, '5', '3', '👍'))).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ reactMessage: vi.fn().mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).react(user, '5', '3', '👍', 'sock')).toEqual({ reactions: [{ emoji: '👍', count: 1 }] });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:reacted', { messageId: 3, reactions: [{ emoji: '👍', count: 1 }] }, 'sock');
|
||||
});
|
||||
|
||||
it('delete maps not_found (404) / not_owner (403), else success with username', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deleteMessage: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial<CollabService>)).deleteMessage(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
expect(thrown(() => new CollabController(svc({ deleteMessage: vi.fn().mockReturnValue({ error: 'not_owner' }) } as Partial<CollabService>)).deleteMessage(user, '5', '3'))).toEqual({ status: 403, body: { error: 'You can only delete your own messages' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ deleteMessage: vi.fn().mockReturnValue({ username: 'bob' }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deleteMessage(user, '5', '3', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:deleted', { messageId: 3, username: 'bob' }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link preview', () => {
|
||||
it('400 without url, maps an error result to 400, else returns the preview', async () => {
|
||||
expect(await thrownAsync(() => new CollabController(svc()).linkPreview(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
expect(await thrownAsync(() => new CollabController(svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial<CollabService>)).linkPreview(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'bad url' } });
|
||||
const s = svc({ linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }) } as Partial<CollabService>);
|
||||
expect(await new CollabController(s).linkPreview(user, '5', 'http://x')).toEqual({ title: 'T', description: null, image: null, url: 'http://x' });
|
||||
});
|
||||
|
||||
it('falls back to a null preview when the service throws', async () => {
|
||||
const s = svc({ linkPreview: vi.fn().mockRejectedValue(new Error('network')) } as Partial<CollabService>);
|
||||
expect(await new CollabController(s).linkPreview(user, '5', 'http://x')).toEqual({ title: null, description: null, image: null, url: 'http://x' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ConfigController } from '../../../src/nest/config/config.controller';
|
||||
import { DEFAULT_LANGUAGE } from '../../../src/config';
|
||||
|
||||
describe('ConfigController (parity with the legacy /api/config route)', () => {
|
||||
it('returns the server default language, like the legacy public route', () => {
|
||||
expect(new ConfigController().getConfig()).toEqual({ defaultLanguage: DEFAULT_LANGUAGE });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
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/days/day-notes.controller';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import type { DayNotesService } from '../../../src/nest/days/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 notesSvc(o: Partial<DayNotesService> = {}): DayNotesService {
|
||||
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DayNotesService;
|
||||
}
|
||||
|
||||
describe('DaysController (parity with the legacy /api/trips/:tripId/days route)', () => {
|
||||
it('404 when trip not accessible', () => {
|
||||
const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new DaysController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
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).list(user, '5')).toEqual({ days: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST / 403 without day_edit, then creates + broadcasts', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
|
||||
expect(new DaysController(daysSvc({ create, broadcast } as Partial<DaysService>)).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('PUT /:id 404 when the day is missing, else updates', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).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).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when missing, else success', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), remove: vi.fn() } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).remove(user, '5', '9')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes route)', () => {
|
||||
it('400 on an over-long text BEFORE the trip-access check (middleware order)', () => {
|
||||
const verifyTripAccess = vi.fn().mockReturnValue(undefined); // would 404 if reached
|
||||
const svc = notesSvc({ verifyTripAccess });
|
||||
expect(thrown(() => new DayNotesController(svc).create(user, '5', '3', { text: 'x'.repeat(501) }))).toEqual({
|
||||
status: 400, body: { error: 'text must be 500 characters or less' },
|
||||
});
|
||||
expect(verifyTripAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 on an over-long time', () => {
|
||||
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(151) }))).toEqual({
|
||||
status: 400, body: { error: 'time must be 150 characters or less' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 trip, 403 permission, 404 day, 400 empty text, then creates', () => {
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
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);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
import { FilesController } from '../../../src/nest/files/files.controller';
|
||||
import { FilesDownloadController } from '../../../src/nest/files/files-download.controller';
|
||||
import { PhotosController } from '../../../src/nest/photos/photos.controller';
|
||||
import type { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { PhotosService } from '../../../src/nest/photos/photos.service';
|
||||
import { isDemoEmail } from '../../../src/services/demo';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function fsvc(o: Partial<FilesService> = {}): FilesService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
||||
can: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
...o,
|
||||
} as unknown as FilesService;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => { delete process.env.DEMO_MODE; });
|
||||
|
||||
describe('FilesController (parity with the legacy /api/trips/:tripId/files route)', () => {
|
||||
it('GET / 404 without access, else lists with the trash flag', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const listFiles = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(new FilesController(fsvc({ listFiles } as Partial<FilesService>)).list(user, '5', 'true')).toEqual({ files: [{ id: 1 }] });
|
||||
expect(listFiles).toHaveBeenCalledWith('5', true);
|
||||
});
|
||||
|
||||
describe('POST / (upload)', () => {
|
||||
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
||||
it('403 in demo mode for a demo email', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockReturnValue(true);
|
||||
expect(thrown(() => new FilesController(fsvc()).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } });
|
||||
});
|
||||
it('403 without file_upload, 400 without a file, else creates + broadcasts', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
||||
expect(thrown(() => new FilesController(fsvc()).upload(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
const createFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const s = fsvc({ createFile, broadcast } as Partial<FilesService>);
|
||||
expect(new FilesController(s).upload(user, '5', file, { description: 'd' }, 'sock')).toEqual({ file: { id: 9 } });
|
||||
expect(createFile).toHaveBeenCalledWith('5', file, 1, { place_id: undefined, description: 'd', reservation_id: undefined });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'file:created', { file: { id: 9 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).update(user, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } });
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const updateFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, description: 'x' }), updateFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(new FilesController(s).update(user, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } });
|
||||
});
|
||||
|
||||
it('PATCH /:id/star 403/404, else toggles', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).star(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const toggleStarred = vi.fn().mockReturnValue({ id: 9, starred: 1 });
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, starred: 0 }), toggleStarred, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(new FilesController(s).star(user, '5', '9')).toEqual({ file: { id: 9, starred: 1 } });
|
||||
expect(toggleStarred).toHaveBeenCalledWith('9', 0);
|
||||
});
|
||||
|
||||
it('DELETE /:id soft-delete 403/404, else success', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).remove(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } });
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const softDeleteFile = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), softDeleteFile, broadcast } as Partial<FilesService>);
|
||||
expect(new FilesController(s).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'file:deleted', { fileId: 9 }, 'sock');
|
||||
});
|
||||
|
||||
it('POST /:id/restore 404 not in trash, else restores', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).restore(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } });
|
||||
const restoreFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), restoreFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(new FilesController(s).restore(user, '5', '9')).toEqual({ file: { id: 9 } });
|
||||
});
|
||||
|
||||
it('DELETE /:id/permanent 404 not in trash, else deletes', async () => {
|
||||
await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).permanent(user, '5', '9')).rejects.toBeInstanceOf(HttpException);
|
||||
const permanentDeleteFile = vi.fn().mockResolvedValue(undefined);
|
||||
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), permanentDeleteFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(await new FilesController(s).permanent(user, '5', '9')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('DELETE /trash/empty 403, else returns the count', async () => {
|
||||
await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).emptyTrash(user, '5')).rejects.toBeInstanceOf(HttpException);
|
||||
const s = fsvc({ emptyTrash: vi.fn().mockResolvedValue(3) } as Partial<FilesService>);
|
||||
expect(await new FilesController(s).emptyTrash(user, '5')).toEqual({ success: true, deleted: 3 });
|
||||
});
|
||||
|
||||
it('POST /:id/link 404 unknown file, else links', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).link(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const createFileLink = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), createFileLink } as Partial<FilesService>);
|
||||
expect(new FilesController(s).link(user, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('DELETE /:id/link/:linkId removes the link; GET /:id/links lists', () => {
|
||||
const deleteFileLink = vi.fn();
|
||||
expect(new FilesController(fsvc({ deleteFileLink } as Partial<FilesService>)).unlink(user, '5', '9', '3')).toEqual({ success: true });
|
||||
expect(deleteFileLink).toHaveBeenCalledWith('3', '9');
|
||||
const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<FilesService>);
|
||||
expect(new FilesController(s).links(user, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilesDownloadController', () => {
|
||||
function dsvc(o: Partial<FilesService> = {}): FilesService {
|
||||
return {
|
||||
authenticateDownload: vi.fn().mockReturnValue({ userId: 1 }),
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
||||
getFileById: vi.fn().mockReturnValue({ filename: 'x.pdf', original_name: 'x.pdf' }),
|
||||
resolveFilePath: vi.fn().mockReturnValue({ resolved: 'C:/nope/x.pdf', safe: true }),
|
||||
...o,
|
||||
} as unknown as FilesService;
|
||||
}
|
||||
const req = { headers: {}, query: {} } as Request;
|
||||
const res = { setHeader: vi.fn(), sendFile: vi.fn() } as unknown as Response;
|
||||
|
||||
it('maps the auth error from authenticateDownload', () => {
|
||||
const s = dsvc({ authenticateDownload: vi.fn().mockReturnValue({ error: 'Authentication required', status: 401 }) });
|
||||
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 401, body: { error: 'Authentication required' } });
|
||||
});
|
||||
it('404 without trip access, 404 unknown file, 403 on an unsafe path', () => {
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ getFileById: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: '/x', safe: false }) })).download(req, res, '5', '9'))).toEqual({ status: 403, body: { error: 'Forbidden' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('PhotosController', () => {
|
||||
const user2 = { id: 1 } as User;
|
||||
function psvc(o: Partial<PhotosService> = {}): PhotosService {
|
||||
return { canAccess: vi.fn().mockReturnValue(true), stream: vi.fn().mockResolvedValue(undefined), info: vi.fn(), ...o } as unknown as PhotosService;
|
||||
}
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
|
||||
it('400 on a non-finite id, 403 without access', async () => {
|
||||
await expect(new PhotosController(psvc()).thumbnail(user2, 'abc', res)).rejects.toMatchObject({ status: 400 });
|
||||
await expect(new PhotosController(psvc({ canAccess: vi.fn().mockReturnValue(false) })).original(user2, '5', res)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('streams thumbnail/original', async () => {
|
||||
const stream = vi.fn().mockResolvedValue(undefined);
|
||||
const c = new PhotosController(psvc({ stream }));
|
||||
await c.thumbnail(user2, '5', res);
|
||||
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'thumbnail');
|
||||
await c.original(user2, '5', res);
|
||||
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'original');
|
||||
});
|
||||
|
||||
it('info writes the data, maps a service error', async () => {
|
||||
const okRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ data: { id: '5' } }) })).info(user2, '5', okRes);
|
||||
expect(okRes.json).toHaveBeenCalledWith({ id: '5' });
|
||||
const errRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ error: { status: 404, message: 'Photo not found' } }) })).info(user2, '5', errRes);
|
||||
expect(errRes.status).toHaveBeenCalledWith(404);
|
||||
expect(errRes.json).toHaveBeenCalledWith({ error: 'Photo not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { CallHandler, ExecutionContext } from '@nestjs/common';
|
||||
import { of, lastValueFrom } from 'rxjs';
|
||||
import { IdempotencyInterceptor } from '../../../src/nest/common/idempotency.interceptor';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
type ReqShape = {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
path?: string;
|
||||
user?: { id: number };
|
||||
};
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status: vi.fn((code: number) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
json: vi.fn((body: unknown) => body),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
function ctx(req: ReqShape, res: ReturnType<typeof makeRes>): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function handler(result: unknown): CallHandler & { handle: ReturnType<typeof vi.fn> } {
|
||||
return { handle: vi.fn(() => of(result)) };
|
||||
}
|
||||
|
||||
function makeDb(overrides: Partial<DatabaseService> = {}): DatabaseService {
|
||||
return { get: vi.fn(), run: vi.fn(), ...overrides } as unknown as DatabaseService;
|
||||
}
|
||||
|
||||
describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middleware)', () => {
|
||||
it('passes a GET through without touching the store', async () => {
|
||||
const db = makeDb();
|
||||
const h = handler('weather');
|
||||
const out = await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(ctx({ method: 'GET', headers: {} }, makeRes()), h),
|
||||
);
|
||||
expect(out).toBe('weather');
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes a mutating request without a key through', async () => {
|
||||
const db = makeDb();
|
||||
const h = handler('done');
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: {}, user: { id: 1 } }, makeRes()), h),
|
||||
);
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through when there is no authenticated user', async () => {
|
||||
const db = makeDb();
|
||||
const h = handler('done');
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' } }, makeRes()), h),
|
||||
);
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an over-long key with the exact legacy 400 body', () => {
|
||||
const db = makeDb();
|
||||
const h = handler('done');
|
||||
const run = () =>
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'x'.repeat(129) }, user: { id: 1 } }, makeRes()),
|
||||
h,
|
||||
);
|
||||
expect(run).toThrow(HttpException);
|
||||
try {
|
||||
run();
|
||||
} catch (err) {
|
||||
const e = err as HttpException;
|
||||
expect(e.getStatus()).toBe(400);
|
||||
expect(e.getResponse()).toEqual({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' });
|
||||
}
|
||||
expect(h.handle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replays a cached response and skips the handler', async () => {
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue({ status_code: 201, response_body: '{"id":5}' }) });
|
||||
const res = makeRes();
|
||||
const h = handler('should-not-run');
|
||||
const out = await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
expect(out).toEqual({ id: 5 });
|
||||
expect(h.handle).not.toHaveBeenCalled();
|
||||
expect(db.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('idempotency_keys'),
|
||||
'k', 1, 'POST', '/api/categories',
|
||||
);
|
||||
});
|
||||
|
||||
it('captures a successful JSON response under the key', async () => {
|
||||
const run = vi.fn();
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const h = handler({ created: true });
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
// Simulate Nest serialising the handler result through the wrapped res.json.
|
||||
res.statusCode = 201;
|
||||
res.json({ created: true });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT OR IGNORE INTO idempotency_keys'),
|
||||
'k', 1, 'POST', '/api/categories', 201, '{"created":true}', expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cache a non-2xx response', async () => {
|
||||
const run = vi.fn();
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const h = handler({ error: 'bad' });
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
res.statusCode = 400;
|
||||
res.json({ error: 'bad' });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { JourneyController } from '../../../src/nest/journey/journey.controller';
|
||||
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
|
||||
import { JourneyAddonGuard } from '../../../src/nest/journey/journey-addon.guard';
|
||||
import type { JourneyService } from '../../../src/nest/journey/journey.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function svc(o: Partial<JourneyService> = {}): JourneyService {
|
||||
return { journeyAddonEnabled: vi.fn().mockReturnValue(true), ...o } as unknown as JourneyService;
|
||||
}
|
||||
|
||||
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('JourneyAddonGuard', () => {
|
||||
it('404 when the addon is disabled, passes when enabled', () => {
|
||||
expect(thrown(() => new JourneyAddonGuard(svc({ journeyAddonEnabled: vi.fn().mockReturnValue(false) })).canActivate())).toEqual({ status: 404, body: { error: 'Journey addon is not enabled' } });
|
||||
expect(new JourneyAddonGuard(svc()).canActivate()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyController', () => {
|
||||
it('GET / lists; POST / 400 without title, else creates', () => {
|
||||
expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).list(user)).toEqual({ journeys: [{ id: 1 }] });
|
||||
expect(thrown(() => new JourneyController(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
|
||||
const createJourney = vi.fn().mockReturnValue({ id: 9 });
|
||||
expect(new JourneyController(svc({ createJourney } as Partial<JourneyService>)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
|
||||
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] });
|
||||
});
|
||||
|
||||
it('GET /suggestions + /available-trips', () => {
|
||||
expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
|
||||
expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
|
||||
});
|
||||
|
||||
it('PATCH/DELETE entries map 404', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
|
||||
expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
|
||||
expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
|
||||
expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('provider-photos: batch, single 400/403, success', () => {
|
||||
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>);
|
||||
expect(new JourneyController(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
|
||||
expect(thrown(() => new JourneyController(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
|
||||
});
|
||||
|
||||
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
|
||||
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
|
||||
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>));
|
||||
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
|
||||
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
|
||||
});
|
||||
|
||||
it('unlink photo (204) maps 404; delete photo 404 then unlinks file', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
|
||||
expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7')).toBeUndefined();
|
||||
expect(thrown(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
|
||||
expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('gallery upload 400 no files / 403 not allowed, else returns photos', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
|
||||
expect(thrown(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('GET/PATCH/DELETE /:id map 404', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).get(user, '9')).toEqual({ id: 9 });
|
||||
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
});
|
||||
|
||||
it('trips: POST 400 without trip_id / 403, DELETE 403', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
|
||||
expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('entries under journey: list 404, create 400/404, reorder 400/403', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
|
||||
expect(thrown(() => new JourneyController(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(thrown(() => new JourneyController(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
|
||||
expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('contributors: add 400/403, update 403, remove 403', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
|
||||
expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('preferences 403, share-link get/set/delete', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
|
||||
expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
|
||||
expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('entry photo upload mirrors to Immich only when opted in', async () => {
|
||||
const addPhoto = vi.fn().mockReturnValue({ id: 5 });
|
||||
const uploadToImmich = vi.fn().mockResolvedValue('immich-1');
|
||||
const setPhotoProvider = vi.fn();
|
||||
const s = svc({ addPhoto, immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich, setPhotoProvider } as Partial<JourneyService>);
|
||||
const res = await new JourneyController(s).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
|
||||
expect(setPhotoProvider).toHaveBeenCalledWith(5, 'immich', 'immich-1', 1);
|
||||
expect(res).toEqual({ photos: [{ id: 5, provider: 'immich', asset_id: 'immich-1', owner_id: 1 }] });
|
||||
|
||||
const noOptIn = svc({ addPhoto: vi.fn().mockReturnValue({ id: 6 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), uploadToImmich } as Partial<JourneyService>);
|
||||
await new JourneyController(noOptIn).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
|
||||
expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyPublicController', () => {
|
||||
it('GET /:token 404 / json', () => {
|
||||
expect(thrown(() => new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get('tok'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
expect(new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>)).get('tok')).toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it('photo proxy 404 on invalid token, else streams', async () => {
|
||||
expect(await thrownAsync(() => new JourneyPublicController(svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).photo('tok', '7', 'thumbnail', {} as Response))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
const streamPhoto = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue({ ownerId: 2 }), streamPhoto } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).photo('tok', '7', 'original', {} as Response);
|
||||
expect(streamPhoto).toHaveBeenCalledWith({}, 2, 7, 'original');
|
||||
});
|
||||
|
||||
it('legacy photo proxy: 404 invalid token, immich path streams', async () => {
|
||||
expect(await thrownAsync(() => new JourneyPublicController(svc({ validateShareTokenForAsset: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).legacyPhoto('tok', 'immich', 'a1', '2', 'thumbnail', {} as Response))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
const streamImmichAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamImmichAsset } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).legacyPhoto('tok', 'immich', 'a1', '2', 'original', {} as Response);
|
||||
expect(streamImmichAsset).toHaveBeenCalledWith({}, 5, 'a1', 'original', 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
const { createReadStream } = vi.hoisted(() => ({ createReadStream: vi.fn() }));
|
||||
vi.mock('node:fs', () => ({ createReadStream }));
|
||||
|
||||
import { MapsController } from '../../../src/nest/maps/maps.controller';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 3 } as User;
|
||||
|
||||
function makeController(svc: Partial<MapsService>) {
|
||||
return new MapsController(svc as MapsService);
|
||||
}
|
||||
|
||||
/** Run an async handler, expecting an HttpException; return its { status, body }. */
|
||||
async function thrown(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 the handler to throw');
|
||||
}
|
||||
|
||||
function withError(status: number, message: string): Error {
|
||||
return Object.assign(new Error(message), { status });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
describe('POST /search', () => {
|
||||
it('400 when query is missing', async () => {
|
||||
expect(await thrown(() => makeController({}).search(user, undefined))).toEqual({
|
||||
status: 400, body: { error: 'Search query is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the service result', async () => {
|
||||
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
||||
const res = await makeController({ search }).search(user, 'berlin', 'de');
|
||||
expect(res).toEqual({ places: [], source: 'osm' });
|
||||
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de');
|
||||
});
|
||||
|
||||
it('maps a service error to its status + message', async () => {
|
||||
const search = vi.fn().mockRejectedValue(withError(429, 'Rate limited'));
|
||||
expect(await thrown(() => makeController({ search }).search(user, 'x'))).toEqual({
|
||||
status: 429, body: { error: 'Rate limited' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /autocomplete', () => {
|
||||
it('returns the disabled envelope when the kill-switch is off', async () => {
|
||||
const autocomplete = vi.fn();
|
||||
const res = await makeController({ autocompleteDisabled: () => true, autocomplete }).autocomplete(user, 'be');
|
||||
expect(res).toEqual({ suggestions: [], source: 'disabled' });
|
||||
expect(autocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 when input is missing or not a string', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
expect(await thrown(() => c.autocomplete(user, undefined))).toEqual({ status: 400, body: { error: 'Input is required' } });
|
||||
expect(await thrown(() => c.autocomplete(user, 123 as unknown as string))).toEqual({ status: 400, body: { error: 'Input is required' } });
|
||||
});
|
||||
|
||||
it('400 when input is too long', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
expect(await thrown(() => c.autocomplete(user, 'x'.repeat(201)))).toEqual({
|
||||
status: 400, body: { error: 'Input too long (max 200 chars)' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 on a malformed locationBias', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
const bad = { low: { lat: 1, lng: NaN }, high: { lat: 2, lng: 3 } };
|
||||
expect(await thrown(() => c.autocomplete(user, 'be', undefined, bad))).toEqual({
|
||||
status: 400, body: { error: 'Invalid locationBias: low and high must have finite lat and lng' },
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates a valid request', async () => {
|
||||
const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'osm' });
|
||||
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
||||
await makeController({ autocompleteDisabled: () => false, autocomplete }).autocomplete(user, 'be', 'en', bias);
|
||||
expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /details/:placeId', () => {
|
||||
it('returns the disabled envelope when off', async () => {
|
||||
const res = await makeController({ detailsDisabled: () => true }).details(user, 'p1');
|
||||
expect(res).toEqual({ place: null, disabled: true });
|
||||
});
|
||||
|
||||
it('uses the expanded lookup when expand is set', async () => {
|
||||
const detailsExpanded = vi.fn().mockResolvedValue({ place: { id: 'p1' } });
|
||||
const details = vi.fn();
|
||||
await makeController({ detailsDisabled: () => false, detailsExpanded, details })
|
||||
.details(user, 'p1', 'full', 'de', '1');
|
||||
expect(detailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true);
|
||||
expect(details).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the plain lookup without expand', async () => {
|
||||
const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } });
|
||||
await makeController({ detailsDisabled: () => false, details }).details(user, 'p1', undefined, 'de');
|
||||
expect(details).toHaveBeenCalledWith(3, 'p1', 'de');
|
||||
});
|
||||
|
||||
it('maps a service error', async () => {
|
||||
const details = vi.fn().mockRejectedValue(withError(404, 'Not found'));
|
||||
expect(await thrown(() => makeController({ detailsDisabled: () => false, details }).details(user, 'p1'))).toEqual({
|
||||
status: 404, body: { error: 'Not found' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /place-photo/:placeId', () => {
|
||||
it('returns { photoUrl: null } when photos are disabled (non-coords)', async () => {
|
||||
const photo = vi.fn();
|
||||
const res = await makeController({ photosDisabled: () => true, photo }).placePhoto(user, 'p1', '1', '2');
|
||||
expect(res).toEqual({ photoUrl: null });
|
||||
expect(photo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bypasses the kill-switch for coords: ids', async () => {
|
||||
const photo = vi.fn().mockResolvedValue({ photoUrl: 'u', attribution: null });
|
||||
await makeController({ photosDisabled: () => true, photo }).placePhoto(user, 'coords:1,2', '1', '2', 'Spot');
|
||||
expect(photo).toHaveBeenCalledWith(3, 'coords:1,2', 1, 2, 'Spot');
|
||||
});
|
||||
|
||||
it('maps a service error', async () => {
|
||||
const photo = vi.fn().mockRejectedValue(withError(404, 'No photo available'));
|
||||
expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({
|
||||
status: 404, body: { error: 'No photo available' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /place-photo/:placeId/bytes', () => {
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
status: vi.fn(function (this: unknown, c: number) { (res as { statusCode: number }).statusCode = c; return res; }),
|
||||
json: vi.fn(),
|
||||
set: vi.fn(),
|
||||
type: vi.fn(),
|
||||
};
|
||||
return res as unknown as Response & { status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; type: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
beforeEach(() => createReadStream.mockReset());
|
||||
|
||||
it('404 when the photo is not cached', () => {
|
||||
const res = makeRes();
|
||||
makeController({ photoBytesPath: () => null }).placePhotoBytes('p1', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
|
||||
expect(createReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams the cached file with image/jpeg + an immutable cache header on a hit', () => {
|
||||
const stream = { on: vi.fn().mockReturnThis(), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = makeRes();
|
||||
makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res);
|
||||
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
expect(res.type).toHaveBeenCalledWith('image/jpeg');
|
||||
expect(createReadStream).toHaveBeenCalledWith('/cache/p1.jpg');
|
||||
expect(stream.pipe).toHaveBeenCalledWith(res);
|
||||
});
|
||||
|
||||
it('falls back to 404 when the read stream errors', () => {
|
||||
let onError: () => void = () => {};
|
||||
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = makeRes();
|
||||
makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res);
|
||||
onError();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /reverse', () => {
|
||||
it('400 when lat/lng missing', async () => {
|
||||
expect(await thrown(() => makeController({}).reverse(undefined, '2'))).toEqual({
|
||||
status: 400, body: { error: 'lat and lng required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the reverse result', async () => {
|
||||
const reverse = vi.fn().mockResolvedValue({ name: 'Spot', address: 'Street 1' });
|
||||
expect(await makeController({ reverse }).reverse('1', '2', 'de')).toEqual({ name: 'Spot', address: 'Street 1' });
|
||||
});
|
||||
|
||||
it('swallows a failure into an empty result (no error)', async () => {
|
||||
const reverse = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
expect(await makeController({ reverse }).reverse('1', '2')).toEqual({ name: null, address: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /resolve-url', () => {
|
||||
it('400 when url missing or not a string', async () => {
|
||||
expect(await thrown(() => makeController({}).resolveUrl(undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
});
|
||||
|
||||
it('returns the resolved coordinates', async () => {
|
||||
const resolveUrl = vi.fn().mockResolvedValue({ lat: 1, lng: 2, name: null, address: null });
|
||||
expect(await makeController({ resolveUrl }).resolveUrl('https://maps.app.goo.gl/x')).toEqual({ lat: 1, lng: 2, name: null, address: null });
|
||||
});
|
||||
|
||||
it('maps a service error, defaulting to 400', async () => {
|
||||
const resolveUrl = vi.fn().mockRejectedValue(new Error('Failed to resolve URL'));
|
||||
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to resolve URL' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { NotificationsController } from '../../../src/nest/notifications/notifications.controller';
|
||||
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const MASKED = '••••••••';
|
||||
const user = { id: 4, role: 'user', email: 'u@example.test' } as User;
|
||||
const admin = { id: 1, role: 'admin', email: 'admin@example.test' } as User;
|
||||
|
||||
function makeController(svc: Partial<NotificationsService>) {
|
||||
return new NotificationsController(svc as NotificationsService);
|
||||
}
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('NotificationsController (parity with the legacy /api/notifications route)', () => {
|
||||
describe('preferences', () => {
|
||||
it('GET returns the matrix for the user', () => {
|
||||
const getPreferences = vi.fn().mockReturnValue({ preferences: {} });
|
||||
expect(makeController({ getPreferences }).getPreferences(user)).toEqual({ preferences: {} });
|
||||
expect(getPreferences).toHaveBeenCalledWith(4, 'user');
|
||||
});
|
||||
|
||||
it('PUT saves then returns the refreshed matrix', () => {
|
||||
const setPreferences = vi.fn();
|
||||
const getPreferences = vi.fn().mockReturnValue({ preferences: { a: { inapp: true } } });
|
||||
const body = { a: { inapp: true } };
|
||||
expect(makeController({ setPreferences, getPreferences }).setPreferences(user, body)).toEqual({ preferences: { a: { inapp: true } } });
|
||||
expect(setPreferences).toHaveBeenCalledWith(4, body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test-smtp', () => {
|
||||
it('403 { error: Admin only } for a non-admin (distinct from AdminGuard wording)', async () => {
|
||||
const testSmtp = vi.fn();
|
||||
expect(await thrown(() => makeController({ testSmtp }).testSmtp(user))).toEqual({
|
||||
status: 403, body: { error: 'Admin only' },
|
||||
});
|
||||
expect(testSmtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the admin\'s own email when none given', async () => {
|
||||
const testSmtp = vi.fn().mockResolvedValue({ success: true });
|
||||
await makeController({ testSmtp }).testSmtp(admin);
|
||||
expect(testSmtp).toHaveBeenCalledWith('admin@example.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('test-webhook', () => {
|
||||
it('uses the provided url', async () => {
|
||||
const testWebhook = vi.fn().mockResolvedValue({ success: true });
|
||||
await makeController({ testWebhook }).testWebhook(user, 'https://hooks.example/x');
|
||||
expect(testWebhook).toHaveBeenCalledWith('https://hooks.example/x');
|
||||
});
|
||||
|
||||
it('falls back to the saved user url when the masked placeholder is sent', async () => {
|
||||
const testWebhook = vi.fn().mockResolvedValue({ success: true });
|
||||
const userWebhookUrl = vi.fn().mockReturnValue('https://saved.example/u');
|
||||
await makeController({ testWebhook, userWebhookUrl }).testWebhook(user, MASKED);
|
||||
expect(userWebhookUrl).toHaveBeenCalledWith(4);
|
||||
expect(testWebhook).toHaveBeenCalledWith('https://saved.example/u');
|
||||
});
|
||||
|
||||
it('400 when no url is configured', async () => {
|
||||
const userWebhookUrl = vi.fn().mockReturnValue(null);
|
||||
expect(await thrown(() => makeController({ userWebhookUrl }).testWebhook(user, undefined))).toEqual({
|
||||
status: 400, body: { error: 'No webhook URL configured' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 on an invalid url', async () => {
|
||||
expect(await thrown(() => makeController({}).testWebhook(user, 'not a url'))).toEqual({
|
||||
status: 400, body: { error: 'Invalid URL' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('test-ntfy', () => {
|
||||
it('400 when no topic can be resolved', async () => {
|
||||
const userNtfyConfig = vi.fn().mockReturnValue(null);
|
||||
const adminNtfyConfig = vi.fn().mockReturnValue({ server: null, token: null });
|
||||
expect(await thrown(() => makeController({ userNtfyConfig, adminNtfyConfig }).testNtfy(user))).toEqual({
|
||||
status: 400, body: { error: 'No ntfy topic configured' },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves topic/server/token with fallbacks and reuses a saved token for the placeholder', async () => {
|
||||
const testNtfy = vi.fn().mockResolvedValue({ success: true });
|
||||
const userNtfyConfig = vi.fn().mockReturnValue({ topic: 'saved-topic', server: 'https://ntfy.me', token: 'saved-token' });
|
||||
const adminNtfyConfig = vi.fn().mockReturnValue({ server: null, token: null });
|
||||
await makeController({ testNtfy, userNtfyConfig, adminNtfyConfig }).testNtfy(user, undefined, undefined, MASKED);
|
||||
expect(testNtfy).toHaveBeenCalledWith({ topic: 'saved-topic', server: 'https://ntfy.me', token: 'saved-token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-app list + counts', () => {
|
||||
it('clamps limit to 50 and defaults offset/unread', () => {
|
||||
const listInApp = vi.fn().mockReturnValue({ notifications: [], total: 0, unread_count: 0 });
|
||||
makeController({ listInApp }).listInApp(user, '100', '5', 'true');
|
||||
expect(listInApp).toHaveBeenCalledWith(4, { limit: 50, offset: 5, unreadOnly: true });
|
||||
});
|
||||
|
||||
it('defaults limit to 20 when absent/non-numeric', () => {
|
||||
const listInApp = vi.fn().mockReturnValue({ notifications: [], total: 0, unread_count: 0 });
|
||||
makeController({ listInApp }).listInApp(user, undefined, undefined, undefined);
|
||||
expect(listInApp).toHaveBeenCalledWith(4, { limit: 20, offset: 0, unreadOnly: false });
|
||||
});
|
||||
|
||||
it('GET unread-count wraps the number', () => {
|
||||
const unreadCount = vi.fn().mockReturnValue(7);
|
||||
expect(makeController({ unreadCount }).unreadCount(user)).toEqual({ count: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk + single mutations', () => {
|
||||
it('read-all returns success + count', () => {
|
||||
const markAllRead = vi.fn().mockReturnValue(3);
|
||||
expect(makeController({ markAllRead }).readAll(user)).toEqual({ success: true, count: 3 });
|
||||
});
|
||||
|
||||
it('delete-all returns success + count', () => {
|
||||
const deleteAll = vi.fn().mockReturnValue(5);
|
||||
expect(makeController({ deleteAll }).deleteAll(user)).toEqual({ success: true, count: 5 });
|
||||
});
|
||||
|
||||
it('400 on a non-numeric id', () => {
|
||||
const markRead = vi.fn();
|
||||
return thrown(() => makeController({ markRead }).markRead(user, 'abc')).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } }));
|
||||
});
|
||||
|
||||
it('404 when mark-read finds nothing', async () => {
|
||||
const markRead = vi.fn().mockReturnValue(false);
|
||||
expect(await thrown(() => makeController({ markRead }).markRead(user, '9'))).toEqual({
|
||||
status: 404, body: { error: 'Not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('mark-read success', () => {
|
||||
const markRead = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ markRead }).markRead(user, '5')).toEqual({ success: true });
|
||||
expect(markRead).toHaveBeenCalledWith(5, 4);
|
||||
});
|
||||
|
||||
it('delete single success', () => {
|
||||
const deleteOne = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ deleteOne }).deleteOne(user, '5')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('respond', () => {
|
||||
it('400 on an invalid response value', async () => {
|
||||
expect(await thrown(() => makeController({}).respond(user, '5', 'maybe'))).toEqual({
|
||||
status: 400, body: { error: 'response must be "positive" or "negative"' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 with the service error when the response fails', async () => {
|
||||
const respond = vi.fn().mockResolvedValue({ success: false, error: 'Already responded' });
|
||||
expect(await thrown(() => makeController({ respond }).respond(user, '5', 'positive'))).toEqual({
|
||||
status: 400, body: { error: 'Already responded' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns success + the updated notification', async () => {
|
||||
const respond = vi.fn().mockResolvedValue({ success: true, notification: { id: 5, response: 'positive' } });
|
||||
expect(await makeController({ respond }).respond(user, '5', 'positive')).toEqual({
|
||||
success: true, notification: { id: 5, response: 'positive' },
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(5, 4, 'positive');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logWarn: vi.fn() }));
|
||||
|
||||
import { OauthPublicController } from '../../../src/nest/oauth/oauth-public.controller';
|
||||
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
||||
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
|
||||
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
function osvc(o: Partial<OauthService> = {}): OauthService {
|
||||
return { mcpEnabled: vi.fn().mockReturnValue(true), mcpSafeUrl: vi.fn().mockReturnValue('https://app'), ...o } as unknown as OauthService;
|
||||
}
|
||||
function rl(): RateLimitService { return new RateLimitService(); }
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: 200, headers: {} as Record<string, string>, body: undefined as unknown, ended: false,
|
||||
status: vi.fn((c: number) => { res.statusCode = c; return res; }),
|
||||
json: vi.fn((b: unknown) => { res.body = b; return res; }),
|
||||
set: vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }),
|
||||
end: vi.fn(() => { res.ended = true; return res; }),
|
||||
};
|
||||
return res as unknown as Response & { statusCode: number; headers: Record<string, string>; body: unknown; ended: boolean };
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
const user = { id: 1, email: 'u@example.test' } as User;
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('OauthPublicController /token', () => {
|
||||
function reqWith(body: Record<string, string>): Request { return { ip: '7.7.7.7', body } as Request; }
|
||||
|
||||
it('404 (empty) when MCP is disabled', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).token(reqWith({}), res);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.ended).toBe(true);
|
||||
});
|
||||
|
||||
it('sets no-store headers + 401 without client_id', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).token(reqWith({}), res);
|
||||
expect(res.headers['Cache-Control']).toBe('no-store');
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
});
|
||||
|
||||
it('authorization_code: invalid_grant on a bad code, success issues tokens', () => {
|
||||
const bad = makeRes();
|
||||
new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(null) }), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), bad);
|
||||
expect(bad.statusCode).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
|
||||
|
||||
const ok = makeRes();
|
||||
const svc = osvc({
|
||||
consumeAuthCode: vi.fn().mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }),
|
||||
authenticateClient: vi.fn().mockReturnValue({ id: 'c' }),
|
||||
verifyPKCE: vi.fn().mockReturnValue(true),
|
||||
issueTokens: vi.fn().mockReturnValue({ access_token: 'at', token_type: 'Bearer' }),
|
||||
});
|
||||
new OauthPublicController(svc, rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }), ok);
|
||||
expect(ok.body).toEqual({ access_token: 'at', token_type: 'Bearer' });
|
||||
});
|
||||
|
||||
it('authorization_code: maps client_id / redirect_uri / resource mismatches + pkce + client auth', () => {
|
||||
const base = { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' };
|
||||
const mk = (pending: Record<string, unknown>, extra: Partial<OauthService> = {}, body = base) => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc({ consumeAuthCode: vi.fn().mockReturnValue(pending), authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), verifyPKCE: vi.fn().mockReturnValue(true), ...extra }), rl()).token(reqWith(body), res);
|
||||
return res;
|
||||
};
|
||||
expect(mk({ clientId: 'OTHER', redirectUri: 'u', userId: 1 }).statusCode).toBe(400); // client_id mismatch
|
||||
expect(mk({ clientId: 'c', redirectUri: 'OTHER', userId: 1 }).statusCode).toBe(400); // redirect_uri mismatch
|
||||
expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, resource: 'https://a' }, {}, { ...base, resource: 'https://b' }).statusCode).toBe(400); // resource mismatch
|
||||
expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1 }, { authenticateClient: vi.fn().mockReturnValue(null) }).statusCode).toBe(401); // bad client secret
|
||||
expect(mk({ clientId: 'c', redirectUri: 'u', userId: 1, codeChallenge: 'cc' }, { verifyPKCE: vi.fn().mockReturnValue(false) }).statusCode).toBe(400); // pkce fail
|
||||
});
|
||||
|
||||
it('authorization_code: 400 when code/redirect/verifier missing', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'authorization_code', client_id: 'c' }), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
|
||||
});
|
||||
|
||||
it('refresh_token: 400 without a refresh_token, maps a service error, success', () => {
|
||||
const miss = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c' }), miss);
|
||||
expect(miss.statusCode).toBe(400);
|
||||
const err = makeRes();
|
||||
new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ error: 'invalid_grant', status: 400 }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), err);
|
||||
expect(err.body).toEqual({ error: 'invalid_grant', error_description: 'Refresh token is invalid or expired' });
|
||||
const ok = makeRes();
|
||||
new OauthPublicController(osvc({ refreshTokens: vi.fn().mockReturnValue({ tokens: { access_token: 'new' } }) }), rl()).token(reqWith({ grant_type: 'refresh_token', client_id: 'c', refresh_token: 'rt' }), ok);
|
||||
expect(ok.body).toEqual({ access_token: 'new' });
|
||||
});
|
||||
|
||||
it('client_credentials: 401 without secret, invalid_scope for a disallowed scope', () => {
|
||||
const noSecret = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c' }), noSecret);
|
||||
expect(noSecret.statusCode).toBe(401);
|
||||
const badScope = makeRes();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a"]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a zzz' }), badScope);
|
||||
expect(badScope.statusCode).toBe(400);
|
||||
expect(badScope.body).toEqual({ error: 'invalid_scope', error_description: 'Scopes not allowed for this client: zzz' });
|
||||
});
|
||||
|
||||
it('client_credentials: unauthorized_client for a public client, else issues a token', () => {
|
||||
const pub = makeRes();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ is_public: true, user_id: null, allows_client_credentials: false, allowed_scopes: '[]' }) }), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), pub);
|
||||
expect(pub.statusCode).toBe(400);
|
||||
expect(pub.body).toEqual({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
|
||||
const ok = makeRes();
|
||||
new OauthPublicController(osvc({
|
||||
authenticateClient: vi.fn().mockReturnValue({ is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }),
|
||||
issueClientCredentialsToken: vi.fn().mockReturnValue({ access_token: 'cc_at' }),
|
||||
}), rl()).token(reqWith({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's' }), ok);
|
||||
expect(ok.body).toEqual({ access_token: 'cc_at' });
|
||||
});
|
||||
|
||||
it('unsupported grant -> 400', () => {
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).token(reqWith({ grant_type: 'password', client_id: 'c' }), res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'unsupported_grant_type', error_description: 'Unsupported grant_type: password' });
|
||||
});
|
||||
|
||||
it('429 when the token bucket is exhausted (per ip|client)', () => {
|
||||
const s = rl();
|
||||
for (let i = 0; i < 30; i++) s.check('oauth_token', '7.7.7.7|c', 30, 60000, Date.now());
|
||||
const res = makeRes();
|
||||
new OauthPublicController(osvc(), s).token(reqWith({ client_id: 'c' }), res);
|
||||
expect(res.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OauthPublicController /userinfo + /revoke', () => {
|
||||
it('userinfo: 401 challenge without a Bearer, returns claims with a valid token', () => {
|
||||
const r1 = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).userinfo(undefined, r1);
|
||||
expect(r1.statusCode).toBe(401);
|
||||
expect(r1.headers['WWW-Authenticate']).toBe('Bearer realm="TREK MCP"');
|
||||
const r2 = makeRes();
|
||||
new OauthPublicController(osvc({ getUserByAccessToken: vi.fn().mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }) }), rl()).userinfo('Bearer tok', r2);
|
||||
expect(r2.body).toEqual({ sub: '1', email: 'a@b.c', email_verified: true, preferred_username: 'u' });
|
||||
});
|
||||
|
||||
it('revoke: 400 without token/client, always 200 once authenticated', () => {
|
||||
const r1 = makeRes();
|
||||
new OauthPublicController(osvc(), rl()).revoke({ ip: '1', body: { client_id: 'c' } } as Request, r1);
|
||||
expect(r1.statusCode).toBe(400);
|
||||
const r2 = makeRes();
|
||||
const revokeToken = vi.fn();
|
||||
new OauthPublicController(osvc({ authenticateClient: vi.fn().mockReturnValue({ id: 'c' }), revokeToken }), rl()).revoke({ ip: '1', body: { token: 't', client_id: 'c' } } as Request, r2);
|
||||
expect(r2.statusCode).toBe(200);
|
||||
expect(r2.body).toEqual({});
|
||||
expect(revokeToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OauthApiController', () => {
|
||||
const req = { ip: '1.2.3.4', user: undefined as unknown } as Request;
|
||||
function makeRes2() { const r = { statusCode: 200, ended: false, status: vi.fn((c: number) => { r.statusCode = c; return r; }), end: vi.fn(() => { r.ended = true; }) }; return r as unknown as Response & { statusCode: number; ended: boolean }; }
|
||||
|
||||
it('validate: 404 empty when MCP off, loginRequired when anonymous + valid', () => {
|
||||
const off = makeRes2();
|
||||
new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).validate({ ...req } as Request, {}, off);
|
||||
expect(off.statusCode).toBe(404);
|
||||
expect(off.ended).toBe(true);
|
||||
const anon = makeRes2();
|
||||
const r = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true }) }), rl()).validate({ ...req, user: undefined } as Request, {}, anon);
|
||||
expect(r).toEqual({ valid: true, loginRequired: true });
|
||||
});
|
||||
|
||||
it('authorize: denied returns a redirect with access_denied, approved issues a code', () => {
|
||||
const denied = new OauthApiController(osvc(), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false }, req);
|
||||
expect((denied as { redirect: string }).redirect).toContain('error=access_denied');
|
||||
const svc = osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue('the_code') });
|
||||
const ok = new OauthApiController(svc, rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req);
|
||||
expect((ok as { redirect: string }).redirect).toContain('code=the_code');
|
||||
});
|
||||
|
||||
it('clients/sessions: 403 when MCP off, else CRUD', () => {
|
||||
expect(thrown(() => new OauthApiController(osvc({ mcpEnabled: vi.fn().mockReturnValue(false) }), rl()).listClients(user))).toEqual({ status: 403, body: { error: 'MCP is not enabled' } });
|
||||
expect(new OauthApiController(osvc({ listOAuthClients: vi.fn().mockReturnValue([{ id: 'c1' }]) }), rl()).listClients(user)).toEqual({ clients: [{ id: 'c1' }] });
|
||||
expect(new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ client_id: 'c1', client_secret: 's' }) }), rl()).createClient(user, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({ client_id: 'c1', client_secret: 's' });
|
||||
expect(new OauthApiController(osvc({ deleteOAuthClient: vi.fn().mockReturnValue({}) }), rl()).deleteClient(user, 'c1', req)).toEqual({ success: true });
|
||||
expect(new OauthApiController(osvc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }), rl()).listSessions(user)).toEqual({ sessions: [{ id: 1 }] });
|
||||
expect(new OauthApiController(osvc({ revokeSession: vi.fn().mockReturnValue({}) }), rl()).revokeSession(user, '1', req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('rotate maps a service error, else returns the new secret', () => {
|
||||
expect(thrown(() => new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ error: 'not_found', status: 404 }) }), rl()).rotateClient(user, 'c1', req))).toEqual({ status: 404, body: { error: 'not_found' } });
|
||||
expect(new OauthApiController(osvc({ rotateOAuthClientSecret: vi.fn().mockReturnValue({ client_secret: 'new' }) }), rl()).rotateClient(user, 'c1', req)).toEqual({ client_secret: 'new' });
|
||||
});
|
||||
|
||||
it('validate: anonymous + invalid returns a generic error; create maps a service error', () => {
|
||||
const res = makeRes2();
|
||||
const anon = new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'x' }) }), rl()).validate({ ...req, user: undefined } as Request, {}, res);
|
||||
expect(anon).toEqual({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
|
||||
expect(thrown(() => new OauthApiController(osvc({ createOAuthClient: vi.fn().mockReturnValue({ error: 'invalid_redirect_uri', status: 400 }) }), rl()).createClient(user, { name: 'X', allowed_scopes: ['a'] }, req))).toEqual({ status: 400, body: { error: 'invalid_redirect_uri' } });
|
||||
});
|
||||
|
||||
it('authorize: 400 when re-validation fails, 503 when the auth code cannot be issued', () => {
|
||||
expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: false, error: 'invalid_scope', error_description: 'bad' }) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 400, body: { error: 'invalid_scope', error_description: 'bad' } });
|
||||
expect(thrown(() => new OauthApiController(osvc({ validateAuthorizeRequest: vi.fn().mockReturnValue({ valid: true, scopes: ['s'], resource: null }), saveConsent: vi.fn(), createAuthCode: vi.fn().mockReturnValue(null) }), rl()).authorize(user, { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }, req))).toEqual({ status: 503, body: { error: 'server_error', error_description: 'Authorization server is temporarily unavailable' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { OidcController } from '../../../src/nest/oidc/oidc.controller';
|
||||
import type { OidcService } from '../../../src/nest/oidc/oidc.service';
|
||||
|
||||
function svc(o: Partial<OidcService> = {}): OidcService {
|
||||
return {
|
||||
oidcLoginEnabled: vi.fn().mockReturnValue(true),
|
||||
getOidcConfig: vi.fn().mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }),
|
||||
getAppUrl: vi.fn().mockReturnValue('https://app'),
|
||||
discover: vi.fn().mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' }),
|
||||
createState: vi.fn().mockReturnValue({ state: 'st', codeChallenge: 'cc' }),
|
||||
consumeState: vi.fn().mockReturnValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv', inviteToken: undefined }),
|
||||
exchangeCodeForToken: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
findOrCreateUser: vi.fn(),
|
||||
touchLastLogin: vi.fn(),
|
||||
generateToken: vi.fn().mockReturnValue('jwt'),
|
||||
createAuthCode: vi.fn().mockReturnValue('ac'),
|
||||
consumeAuthCode: vi.fn(),
|
||||
frontendUrl: vi.fn((p: string) => 'https://app' + p),
|
||||
setAuthCookie: vi.fn(),
|
||||
...o,
|
||||
} as unknown as OidcService;
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
redirectedTo: '' as string,
|
||||
body: undefined as unknown,
|
||||
status: vi.fn((c: number) => { res.statusCode = c; return res; }),
|
||||
json: vi.fn((b: unknown) => { res.body = b; return res; }),
|
||||
redirect: vi.fn((u: string) => { res.redirectedTo = u; }),
|
||||
};
|
||||
return res as unknown as Response & { statusCode: number; redirectedTo: string; body: unknown };
|
||||
}
|
||||
|
||||
const req = { query: {}, headers: {} } as Request;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => { delete process.env.NODE_ENV; });
|
||||
|
||||
describe('OidcController /login', () => {
|
||||
it('403 when SSO is disabled', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).login(req, res);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'SSO login is disabled.' });
|
||||
});
|
||||
|
||||
it('400 when not configured', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ getOidcConfig: vi.fn().mockReturnValue(null) })).login(req, res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'OIDC not configured' });
|
||||
});
|
||||
|
||||
it('redirects to the provider authorize endpoint with PKCE params', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc()).login(req, res);
|
||||
expect(res.redirect).toHaveBeenCalled();
|
||||
expect(res.redirectedTo).toContain('https://idp/auth?');
|
||||
expect(res.redirectedTo).toContain('code_challenge=cc');
|
||||
expect(res.redirectedTo).toContain('code_challenge_method=S256');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OidcController /callback', () => {
|
||||
it('redirects with sso_disabled when SSO is off', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).callback('c', 's', undefined, res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=sso_disabled');
|
||||
});
|
||||
|
||||
it('redirects with the provider error', async () => {
|
||||
const res = makeRes();
|
||||
await new OidcController(svc()).callback(undefined, undefined, 'access_denied', res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=access_denied');
|
||||
});
|
||||
|
||||
it('redirects missing_params / invalid_state', async () => {
|
||||
const r1 = makeRes();
|
||||
await new OidcController(svc()).callback(undefined, 's', undefined, r1);
|
||||
expect(r1.redirectedTo).toBe('https://app/login?oidc_error=missing_params');
|
||||
const r2 = makeRes();
|
||||
await new OidcController(svc({ consumeState: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, r2);
|
||||
expect(r2.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
|
||||
});
|
||||
|
||||
it('rejects a missing id_token, then completes with an auth code on success', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const noId = makeRes();
|
||||
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at' }) })).callback('c', 's', undefined, noId);
|
||||
expect(noId.redirectedTo).toBe('https://app/login?oidc_error=no_id_token');
|
||||
|
||||
const ok = makeRes();
|
||||
const c = new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
|
||||
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
|
||||
}));
|
||||
await c.callback('c', 's', undefined, ok);
|
||||
expect(ok.redirectedTo).toBe('https://app/login?oidc_code=ac');
|
||||
});
|
||||
|
||||
it('rejects a userinfo subject mismatch', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = makeRes();
|
||||
const c = new OidcController(svc({
|
||||
exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }),
|
||||
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
|
||||
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'OTHER' }),
|
||||
}));
|
||||
await c.callback('c', 's', undefined, res);
|
||||
expect(res.redirectedTo).toBe('https://app/login?oidc_error=subject_mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OidcController /exchange', () => {
|
||||
it('400 without a code, 400 on an invalid code, else sets the cookie + returns the token', () => {
|
||||
const r1 = makeRes();
|
||||
new OidcController(svc()).exchange(undefined, req, r1);
|
||||
expect(r1.statusCode).toBe(400);
|
||||
expect(r1.body).toEqual({ error: 'Code required' });
|
||||
|
||||
const r2 = makeRes();
|
||||
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ error: 'invalid_code' }) })).exchange('x', req, r2);
|
||||
expect(r2.statusCode).toBe(400);
|
||||
expect(r2.body).toEqual({ error: 'invalid_code' });
|
||||
|
||||
const r3 = makeRes();
|
||||
const setAuthCookie = vi.fn();
|
||||
new OidcController(svc({ consumeAuthCode: vi.fn().mockReturnValue({ token: 'jwt' }), setAuthCookie })).exchange('x', req, r3);
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(r3, 'jwt', req);
|
||||
expect(r3.body).toEqual({ token: 'jwt' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { PackingController } from '../../../src/nest/packing/packing.controller';
|
||||
import type { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { id: 5, user_id: 1 };
|
||||
|
||||
/** Service mock with trip access granted + edit allowed by default. */
|
||||
function makeService(overrides: Partial<PackingService> = {}): PackingService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
notifyTagged: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as PackingService;
|
||||
}
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('PackingController (parity with the legacy /api/trips/:tripId/packing route)', () => {
|
||||
it('404 when the trip is not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new PackingController(svc).list(user, '5'))).toEqual({
|
||||
status: 404, body: { error: 'Trip not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('GET / returns items for an accessible trip', () => {
|
||||
const svc = makeService({ listItems: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST / (create)', () => {
|
||||
it('403 without packing_edit permission', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new PackingController(svc).create(user, '5', { name: 'Socks' }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 when name missing', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new PackingController(svc).create(user, '5', {}))).toEqual({
|
||||
status: 400, body: { error: 'Item name is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an item and broadcasts', () => {
|
||||
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Socks' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createItem, broadcast } as Partial<PackingService>);
|
||||
expect(new PackingController(svc).create(user, '5', { name: 'Socks' }, 'sock')).toEqual({ item: { id: 9, name: 'Socks' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: { id: 9, name: 'Socks' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /import', () => {
|
||||
it('400 when items is not a non-empty array', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new PackingController(svc).importItems(user, '5', []))).toEqual({
|
||||
status: 400, body: { error: 'items must be a non-empty array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('imports and broadcasts per item', () => {
|
||||
const bulkImport = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ bulkImport, broadcast } as Partial<PackingService>);
|
||||
const res = new PackingController(svc).importItems(user, '5', [{ name: 'a' }, { name: 'b' }], 'sock');
|
||||
expect(res).toEqual({ items: [{ id: 1 }, { id: 2 }], count: 2 });
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id (update)', () => {
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ updateItem: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates, forwards changed keys, and broadcasts', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ id: 9, name: 'X' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateItem, broadcast } as Partial<PackingService>);
|
||||
new PackingController(svc).update(user, '5', '9', { name: 'X', checked: true }, 'sock');
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ name: 'X', checked: true }), ['name', 'checked']);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:updated', { item: { id: 9, name: 'X' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bags', () => {
|
||||
it('400 on bag create with blank name', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new PackingController(svc).createBag(user, '5', { name: ' ' }))).toEqual({
|
||||
status: 400, body: { error: 'Name is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 on bag update when missing', () => {
|
||||
const svc = makeService({ updateBag: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).updateBag(user, '5', '3', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Bag not found' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('templates', () => {
|
||||
it('404 when applying a missing/empty template (POST stays 200 otherwise)', () => {
|
||||
const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).applyTemplate(user, '5', 't1'))).toEqual({
|
||||
status: 404, body: { error: 'Template not found or empty' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 saving a template with no items', () => {
|
||||
const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
|
||||
expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', 'My template'))).toEqual({
|
||||
status: 400, body: { error: 'No items to save' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('category assignees', () => {
|
||||
it('updates assignees, broadcasts and fires the tag notification', () => {
|
||||
const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
const notifyTagged = vi.fn();
|
||||
const svc = makeService({ updateCategoryAssignees, broadcast, notifyTagged } as Partial<PackingService>);
|
||||
const res = new PackingController(svc).updateCategoryAssignees(user, '5', 'Clothes', [2], 'sock');
|
||||
expect(res).toEqual({ assignees: [{ user_id: 2 }] });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:assignees', { category: 'Clothes', assignees: [{ user_id: 2 }] }, 'sock');
|
||||
expect(notifyTagged).toHaveBeenCalledWith('5', user, 'Clothes', [2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { pk } = vi.hoisted(() => ({
|
||||
pk: {
|
||||
verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(), deleteItem: vi.fn(),
|
||||
bulkImport: vi.fn(), listBags: vi.fn(), createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(),
|
||||
applyTemplate: vi.fn(), saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(),
|
||||
updateCategoryAssignees: vi.fn(), reorderItems: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/packingService', () => pk);
|
||||
|
||||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
|
||||
function svc() {
|
||||
return new PackingService();
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('PackingService (wrapper delegation + helpers)', () => {
|
||||
it('canEdit delegates to checkPermission with packing_edit', () => {
|
||||
svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never);
|
||||
expect(checkPermission).toHaveBeenCalledWith('packing_edit', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('broadcast forwards to the websocket helper', () => {
|
||||
svc().broadcast('5', 'packing:created', { item: 1 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'packing:created', { item: 1 }, 'sock');
|
||||
});
|
||||
|
||||
it('forwards every item/bag/template/assignee call to the legacy service', () => {
|
||||
const s = svc();
|
||||
s.verifyTripAccess('5', 1); expect(pk.verifyTripAccess).toHaveBeenCalledWith('5', 1);
|
||||
s.listItems('5'); expect(pk.listItems).toHaveBeenCalledWith('5');
|
||||
s.createItem('5', { name: 'a' }); expect(pk.createItem).toHaveBeenCalledWith('5', { name: 'a' });
|
||||
s.updateItem('5', '2', { name: 'b' } as never, ['name']); expect(pk.updateItem).toHaveBeenCalledWith('5', '2', { name: 'b' }, ['name']);
|
||||
s.deleteItem('5', '2'); expect(pk.deleteItem).toHaveBeenCalledWith('5', '2');
|
||||
s.bulkImport('5', [{ name: 'x' }] as never); expect(pk.bulkImport).toHaveBeenCalledWith('5', [{ name: 'x' }]);
|
||||
s.reorderItems('5', [3, 1] as never); expect(pk.reorderItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
s.listBags('5'); expect(pk.listBags).toHaveBeenCalledWith('5');
|
||||
s.createBag('5', { name: 'Bag' }); expect(pk.createBag).toHaveBeenCalledWith('5', { name: 'Bag' });
|
||||
s.updateBag('5', '2', { name: 'B' } as never, ['name']); expect(pk.updateBag).toHaveBeenCalledWith('5', '2', { name: 'B' }, ['name']);
|
||||
s.deleteBag('5', '2'); expect(pk.deleteBag).toHaveBeenCalledWith('5', '2');
|
||||
s.setBagMembers('5', '2', [1, 2]); expect(pk.setBagMembers).toHaveBeenCalledWith('5', '2', [1, 2]);
|
||||
s.applyTemplate('5', 't1'); expect(pk.applyTemplate).toHaveBeenCalledWith('5', 't1');
|
||||
s.saveAsTemplate('5', 1, 'Tpl'); expect(pk.saveAsTemplate).toHaveBeenCalledWith('5', 1, 'Tpl');
|
||||
s.getCategoryAssignees('5'); expect(pk.getCategoryAssignees).toHaveBeenCalledWith('5');
|
||||
s.updateCategoryAssignees('5', 'Clothes', [2]); expect(pk.updateCategoryAssignees).toHaveBeenCalledWith('5', 'Clothes', [2]);
|
||||
});
|
||||
|
||||
describe('notifyTagged', () => {
|
||||
it('does nothing when no users are tagged', () => {
|
||||
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', []);
|
||||
svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', 'nope');
|
||||
expect(dbMock.prepare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires the notification when users are tagged (fire-and-forget, no throw)', () => {
|
||||
expect(() => svc().notifyTagged('5', { id: 1, email: 'a@b.c' } as never, 'Clothes', [2, 3])).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { PlacesController } from '../../../src/nest/places/places.controller';
|
||||
import type { PlacesService } from '../../../src/nest/places/places.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<PlacesService> = {}): PlacesService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(),
|
||||
onCreated: vi.fn(), onUpdated: vi.fn(), onDeleted: vi.fn(),
|
||||
...o,
|
||||
} as unknown as PlacesService;
|
||||
}
|
||||
|
||||
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.spyOn(console, 'error').mockImplementation(() => {}));
|
||||
|
||||
describe('PlacesController (parity with the legacy /api/trips/:tripId/places route)', () => {
|
||||
it('GET / lists with filters; 404 when trip not accessible', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const list = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(new PlacesController(svc({ list } as Partial<PlacesService>)).list(user, '5', 'beach', 'cat', 'tag')).toEqual({ places: [{ id: 1 }] });
|
||||
expect(list).toHaveBeenCalledWith('5', { search: 'beach', category: 'cat', tag: 'tag' });
|
||||
});
|
||||
|
||||
describe('POST / (create)', () => {
|
||||
it('400 on an over-long name (length guard before permission)', () => {
|
||||
const canEdit = vi.fn().mockReturnValue(false); // would 403 if reached
|
||||
expect(thrown(() => new PlacesController(svc({ canEdit })).create(user, '5', { name: 'x'.repeat(201) }))).toEqual({
|
||||
status: 400, body: { error: 'name must be 200 characters or less' },
|
||||
});
|
||||
expect(canEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 without place_edit, 400 without name, then creates + hooks', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', { name: 'Spot' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new PlacesController(svc()).create(user, '5', {}))).toEqual({ status: 400, body: { error: 'Place name is required' } });
|
||||
const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const onCreated = vi.fn();
|
||||
const s = svc({ create, broadcast, onCreated } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).create(user, '5', { name: 'Spot' }, 'sock')).toEqual({ place: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'place:created', { place: { id: 9 } }, 'sock');
|
||||
expect(onCreated).toHaveBeenCalledWith('5', 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /import/gpx', () => {
|
||||
const file = { buffer: Buffer.from('gpx'), originalname: 'r.gpx' } as Express.Multer.File;
|
||||
it('400 without a file', () => {
|
||||
expect(thrown(() => new PlacesController(svc()).importGpx(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
});
|
||||
it('400 when all import types are disabled', () => {
|
||||
expect(thrown(() => new PlacesController(svc()).importGpx(user, '5', file, { importWaypoints: 'false', importRoutes: 'false', importTracks: 'false' }))).toEqual({
|
||||
status: 400, body: { error: 'No import types selected' },
|
||||
});
|
||||
});
|
||||
it('400 when the GPX yields nothing', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ importGpx: vi.fn().mockReturnValue(null) } as Partial<PlacesService>)).importGpx(user, '5', file, {}))).toEqual({
|
||||
status: 400, body: { error: 'No matching places found in GPX file' },
|
||||
});
|
||||
});
|
||||
it('imports and broadcasts per place', () => {
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ importGpx: vi.fn().mockReturnValue({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 }), broadcast } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).importGpx(user, '5', file, {}, 'sock')).toEqual({ places: [{ id: 1 }, { id: 2 }], count: 2, skipped: 0 });
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /import/google-list + naver-list', () => {
|
||||
it('400 without a url', async () => {
|
||||
expect(await thrownAsync(() => new PlacesController(svc()).importGoogle(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
});
|
||||
it('maps a service { error, status } to the same response', async () => {
|
||||
const s = svc({ importGoogleList: vi.fn().mockResolvedValue({ error: 'List is empty', status: 400 }) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(s).importGoogle(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'List is empty' } });
|
||||
});
|
||||
it('imports a naver list and returns the count + listName', async () => {
|
||||
const s = svc({ importNaverList: vi.fn().mockResolvedValue({ places: [{ id: 1 }], listName: 'Trip', skipped: 2 }), broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).importNaver(user, '5', 'http://x')).toEqual({ places: [{ id: 1 }], count: 1, listName: 'Trip', skipped: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /bulk-delete', () => {
|
||||
it('400 when ids is not an array of numbers', () => {
|
||||
expect(thrown(() => new PlacesController(svc()).bulkDelete(user, '5', ['a']))).toEqual({ status: 400, body: { error: 'ids must be an array of numbers' } });
|
||||
});
|
||||
it('returns empty for an empty list without touching the service', () => {
|
||||
const removeMany = vi.fn();
|
||||
expect(new PlacesController(svc({ removeMany } as Partial<PlacesService>)).bulkDelete(user, '5', [])).toEqual({ deleted: [], count: 0 });
|
||||
expect(removeMany).not.toHaveBeenCalled();
|
||||
});
|
||||
it('deletes, fires hooks + broadcasts per deleted id', () => {
|
||||
const removeMany = vi.fn().mockReturnValue([1, 2]); const onDeleted = vi.fn(); const broadcast = vi.fn();
|
||||
const s = svc({ removeMany, onDeleted, broadcast } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).bulkDelete(user, '5', [1, 2], 'sock')).toEqual({ deleted: [1, 2], count: 2 });
|
||||
expect(onDeleted).toHaveBeenCalledTimes(2);
|
||||
expect(broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /:id 404 when missing', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<PlacesService>)).get(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
});
|
||||
|
||||
it('PUT /:id 404 when missing, else updates + hooks', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ update: vi.fn().mockReturnValue(null) } as Partial<PlacesService>)).update(user, '5', '9', { name: 'X' }))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
const update = vi.fn().mockReturnValue({ id: 9 }); const onUpdated = vi.fn(); const broadcast = vi.fn();
|
||||
const s = svc({ update, onUpdated, broadcast } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).update(user, '5', '9', { name: 'X' }, 'sock')).toEqual({ place: { id: 9 } });
|
||||
expect(onUpdated).toHaveBeenCalledWith(9);
|
||||
});
|
||||
|
||||
it('DELETE /:id fires the hook then 404 / success', () => {
|
||||
const onDeleted = vi.fn();
|
||||
expect(thrown(() => new PlacesController(svc({ remove: vi.fn().mockReturnValue(false), onDeleted } as Partial<PlacesService>)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
expect(onDeleted).toHaveBeenCalledWith(9);
|
||||
const s = svc({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).remove(user, '5', '9')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('GET /:id/image maps service error + returns photos', async () => {
|
||||
const s = svc({ searchImage: vi.fn().mockResolvedValue({ photos: [{ url: 'x' }] }) } as Partial<PlacesService>);
|
||||
expect(await new PlacesController(s).image(user, '5', '9')).toEqual({ photos: [{ url: 'x' }] });
|
||||
const e = svc({ searchImage: vi.fn().mockResolvedValue({ error: 'No key', status: 400 }) } as Partial<PlacesService>);
|
||||
expect(await thrownAsync(() => new PlacesController(e).image(user, '5', '9'))).toEqual({ status: 400, body: { error: 'No key' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { ReservationsController } from '../../../src/nest/reservations/reservations.controller';
|
||||
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { id: 5, user_id: 1 };
|
||||
|
||||
function makeService(overrides: Partial<ReservationsService> = {}): ReservationsService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
syncBudgetOnCreate: vi.fn(),
|
||||
syncBudgetOnUpdate: vi.fn(),
|
||||
notifyBookingChange: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as ReservationsService;
|
||||
}
|
||||
|
||||
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('ReservationsController (parity with the legacy /api/trips/:tripId/reservations route)', () => {
|
||||
it('404 when trip not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new ReservationsController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('GET / returns reservations', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<ReservationsService>);
|
||||
expect(new ReservationsController(svc).list(user, '5')).toEqual({ reservations: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without permission', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new ReservationsController(svc).create(user, '5', { title: 'Hotel' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('400 without a title', () => {
|
||||
expect(thrown(() => new ReservationsController(makeService()).create(user, '5', {}))).toEqual({ status: 400, body: { error: 'Title is required' } });
|
||||
});
|
||||
|
||||
it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => {
|
||||
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true });
|
||||
const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn();
|
||||
const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial<ReservationsService>);
|
||||
const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } };
|
||||
expect(new ReservationsController(svc).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock');
|
||||
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user, 'Hotel', 'lodging');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /positions', () => {
|
||||
it('400 when positions is not an array', () => {
|
||||
expect(thrown(() => new ReservationsController(makeService()).updatePositions(user, '5', { positions: 'no' }))).toEqual({ status: 400, body: { error: 'positions must be an array' } });
|
||||
});
|
||||
|
||||
it('updates positions and broadcasts', () => {
|
||||
const updatePositions = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = makeService({ updatePositions, broadcast } as Partial<ReservationsService>);
|
||||
const positions = [{ id: 1, day_plan_position: 0 }];
|
||||
expect(new ReservationsController(svc).updatePositions(user, '5', { positions, day_id: 3 }, 'sock')).toEqual({ success: true });
|
||||
expect(updatePositions).toHaveBeenCalledWith('5', positions, 3);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:positions', { positions, day_id: 3 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when the reservation is missing', () => {
|
||||
const svc = makeService({ getReservation: vi.fn().mockReturnValue(undefined) } as Partial<ReservationsService>);
|
||||
expect(thrown(() => new ReservationsController(svc).update(user, '5', '9', { title: 'X' }))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
|
||||
});
|
||||
|
||||
it('updates, syncs budget with current fallbacks, broadcasts + notifies', () => {
|
||||
const getReservation = vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' });
|
||||
const update = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationChanged: true });
|
||||
const broadcast = vi.fn(); const syncBudgetOnUpdate = vi.fn(); const notifyBookingChange = vi.fn();
|
||||
const svc = makeService({ getReservation, update, broadcast, syncBudgetOnUpdate, notifyBookingChange } as Partial<ReservationsService>);
|
||||
new ReservationsController(svc).update(user, '5', '9', { create_budget_entry: { total_price: 50 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', {}, 'sock');
|
||||
expect(syncBudgetOnUpdate).toHaveBeenCalledWith('5', '9', '', undefined, 'Old', 'lodging', { total_price: 50 }, 'sock');
|
||||
expect(notifyBookingChange).toHaveBeenCalledWith('5', user, 'Old', 'lodging');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when nothing deleted', () => {
|
||||
const svc = makeService({ remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }) } as Partial<ReservationsService>);
|
||||
expect(thrown(() => new ReservationsController(svc).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
|
||||
});
|
||||
|
||||
it('broadcasts the accommodation + budget cascade then reservation:deleted', () => {
|
||||
const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: 3 }, accommodationDeleted: true, deletedBudgetItemId: 7 });
|
||||
const broadcast = vi.fn(); const notifyBookingChange = vi.fn();
|
||||
const svc = makeService({ remove, broadcast, notifyBookingChange } as Partial<ReservationsService>);
|
||||
expect(new ReservationsController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 3 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the service reaches into directly.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
|
||||
const { resv } = vi.hoisted(() => ({
|
||||
resv: {
|
||||
verifyTripAccess: vi.fn(), listReservations: vi.fn(), createReservation: vi.fn(), updatePositions: vi.fn(),
|
||||
getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/reservationService', () => resv);
|
||||
|
||||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
|
||||
function svc() {
|
||||
return new ReservationsService();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('ReservationsService', () => {
|
||||
it('canEdit delegates to checkPermission with reservation_edit', () => {
|
||||
svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never);
|
||||
expect(checkPermission).toHaveBeenCalledWith('reservation_edit', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('list/create/getReservation/remove delegate to the legacy service', () => {
|
||||
resv.listReservations.mockReturnValue([{ id: 1 }]);
|
||||
expect(svc().list('5')).toEqual([{ id: 1 }]);
|
||||
svc().create('5', { title: 'X' } as never);
|
||||
expect(resv.createReservation).toHaveBeenCalledWith('5', { title: 'X' });
|
||||
svc().getReservation('9', '5');
|
||||
expect(resv.getReservation).toHaveBeenCalledWith('9', '5');
|
||||
svc().remove('9', '5');
|
||||
expect(resv.deleteReservation).toHaveBeenCalledWith('9', '5');
|
||||
});
|
||||
|
||||
describe('syncBudgetOnCreate', () => {
|
||||
it('does nothing without a positive price', () => {
|
||||
svc().syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', undefined, 'sock');
|
||||
svc().syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 0 }, 'sock');
|
||||
expect(budget.linkBudgetItemToReservation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('links a budget item and broadcasts budget:created', () => {
|
||||
budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 });
|
||||
svc().syncBudgetOnCreate('5', 9, 'Hotel', 'lodging', { total_price: 200, category: 'Lodging' }, 'sock');
|
||||
expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, { name: 'Hotel', category: 'Lodging', total_price: 200 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 7 } }, 'sock');
|
||||
});
|
||||
|
||||
it('falls back to type then "Other" for the category and swallows errors', () => {
|
||||
budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); });
|
||||
expect(() => svc().syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncBudgetOnUpdate', () => {
|
||||
it('deletes the linked item when the price is cleared', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 });
|
||||
svc().syncBudgetOnUpdate('5', '9', 'Hotel', 'lodging', 'Hotel', 'lodging', undefined, 'sock');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith(7, '5');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
||||
});
|
||||
|
||||
it('updates an existing linked item when a price is provided', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 7 }); // existing lookup
|
||||
budget.updateBudgetItem.mockReturnValue({ id: 7 });
|
||||
svc().syncBudgetOnUpdate('5', '9', 'New', 'lodging', 'Old', 'lodging', { total_price: 80 }, 'sock');
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith(7, '5', { name: 'New', category: 'lodging', total_price: 80 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 7 } }, 'sock');
|
||||
});
|
||||
|
||||
it('creates + links a new item when none exists, using the current title fallback', () => {
|
||||
dbMock._stmt.get.mockReturnValue(undefined); // no existing
|
||||
budget.createBudgetItem.mockReturnValue({ id: 9 });
|
||||
svc().syncBudgetOnUpdate('5', '9', '', undefined, 'Old title', 'flight', { total_price: 120 }, 'sock');
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Old title', category: 'flight', total_price: 120 });
|
||||
expect(dbMock._stmt.run).toHaveBeenCalled(); // UPDATE budget_items SET reservation_id
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, reservation_id: 9 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('notifyBookingChange resolves without throwing (fire-and-forget)', () => {
|
||||
expect(() => svc().notifyBookingChange('5', { id: 1, email: 'a@b.c' } as never, 'Hotel', 'lodging')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { SettingsController } from '../../../src/nest/settings/settings.controller';
|
||||
import type { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function svc(o: Partial<SettingsService> = {}): SettingsService {
|
||||
return { getUserSettings: vi.fn(), upsertSetting: vi.fn(), bulkUpsertSettings: vi.fn(), ...o } as unknown as SettingsService;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('SettingsController', () => {
|
||||
it('GET / returns the settings', () => {
|
||||
expect(new SettingsController(svc({ getUserSettings: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<SettingsService>)).list(user)).toEqual({ settings: { theme: 'dark' } });
|
||||
});
|
||||
|
||||
it('PUT / 400 without a key', () => {
|
||||
expect(thrown(() => new SettingsController(svc()).upsert(user, {}))).toEqual({ status: 400, body: { error: 'Key is required' } });
|
||||
});
|
||||
|
||||
it('PUT / no-ops on the masked sentinel without writing', () => {
|
||||
const upsertSetting = vi.fn();
|
||||
const c = new SettingsController(svc({ upsertSetting } as Partial<SettingsService>));
|
||||
expect(c.upsert(user, { key: 'immich_api_key', value: '••••••••' })).toEqual({ success: true, key: 'immich_api_key', unchanged: true });
|
||||
expect(upsertSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PUT / writes a real value', () => {
|
||||
const upsertSetting = vi.fn();
|
||||
const c = new SettingsController(svc({ upsertSetting } as Partial<SettingsService>));
|
||||
expect(c.upsert(user, { key: 'theme', value: 'dark' })).toEqual({ success: true, key: 'theme', value: 'dark' });
|
||||
expect(upsertSetting).toHaveBeenCalledWith(1, 'theme', 'dark');
|
||||
});
|
||||
|
||||
it('POST /bulk 400 without an object, 500 on a write error, else returns the count', () => {
|
||||
expect(thrown(() => new SettingsController(svc()).bulk(user, {}))).toEqual({ status: 400, body: { error: 'Settings object is required' } });
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(thrown(() => new SettingsController(svc({ bulkUpsertSettings: vi.fn(() => { throw new Error('db'); }) } as Partial<SettingsService>)).bulk(user, { settings: { a: 1 } }))).toEqual({ status: 500, body: { error: 'Error saving settings' } });
|
||||
expect(new SettingsController(svc({ bulkUpsertSettings: vi.fn().mockReturnValue(3) } as Partial<SettingsService>)).bulk(user, { settings: { a: 1, b: 2, c: 3 } })).toEqual({ success: true, updated: 3 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { TripShareController, SharedController } from '../../../src/nest/share/share.controller';
|
||||
import type { ShareService } from '../../../src/nest/share/share.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function svc(o: Partial<ShareService> = {}): ShareService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
||||
canManage: vi.fn().mockReturnValue(true),
|
||||
...o,
|
||||
} as unknown as ShareService;
|
||||
}
|
||||
|
||||
function res() {
|
||||
const r = { statusCode: 200, status: vi.fn((c: number) => { r.statusCode = c; return r; }) };
|
||||
return r as unknown as Response & { statusCode: number };
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TripShareController', () => {
|
||||
it('POST 404 without access, 403 without share_manage', () => {
|
||||
expect(thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).create(user, '5', {}, res()))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).create(user, '5', {}, res()))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('POST answers 201 on create, 200 on update', () => {
|
||||
const createdRes = res();
|
||||
const c1 = new TripShareController(svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: true }) } as Partial<ShareService>));
|
||||
expect(c1.create(user, '5', { share_map: true }, createdRes)).toEqual({ token: 't' });
|
||||
expect(createdRes.statusCode).toBe(201);
|
||||
|
||||
const updatedRes = res();
|
||||
const c2 = new TripShareController(svc({ createOrUpdate: vi.fn().mockReturnValue({ token: 't', created: false }) } as Partial<ShareService>));
|
||||
expect(c2.create(user, '5', {}, updatedRes)).toEqual({ token: 't' });
|
||||
expect(updatedRes.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('GET 404 without access, returns info or a null token', () => {
|
||||
expect(thrown(() => new TripShareController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).get(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(new TripShareController(svc({ get: vi.fn().mockReturnValue({ token: 't' }) } as Partial<ShareService>)).get(user, '5')).toEqual({ token: 't' });
|
||||
expect(new TripShareController(svc({ get: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).get(user, '5')).toEqual({ token: null });
|
||||
});
|
||||
|
||||
it('DELETE 403 without share_manage, else removes', () => {
|
||||
expect(thrown(() => new TripShareController(svc({ canManage: vi.fn().mockReturnValue(false) })).remove(user, '5'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
const remove = vi.fn();
|
||||
expect(new TripShareController(svc({ remove } as Partial<ShareService>)).remove(user, '5')).toEqual({ success: true });
|
||||
expect(remove).toHaveBeenCalledWith('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharedController', () => {
|
||||
it('404 for an invalid token, else returns the snapshot', () => {
|
||||
expect(thrown(() => new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue(null) } as Partial<ShareService>)).read('bad'))).toEqual({ status: 404, body: { error: 'Invalid or expired link' } });
|
||||
expect(new SharedController(svc({ getSharedTripData: vi.fn().mockReturnValue({ trip: { id: 9 } }) } as Partial<ShareService>)).read('tok')).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,69 @@ describe('strangler toggle', () => {
|
||||
else process.env.NEST_PREFIXES = original;
|
||||
});
|
||||
|
||||
it('defaults to the migrated prefixes (/api/_nest + /api/weather) when NEST_PREFIXES is unset', () => {
|
||||
it('defaults to the migrated prefixes when NEST_PREFIXES is unset', () => {
|
||||
delete process.env.NEST_PREFIXES;
|
||||
expect(getNestPrefixes()).toEqual(['/api/_nest', '/api/weather']);
|
||||
expect(getNestPrefixes()).toEqual([
|
||||
'/api/_nest',
|
||||
'/api/weather',
|
||||
'/api/airports',
|
||||
'/api/config',
|
||||
'/api/system-notices',
|
||||
'/api/maps',
|
||||
'/api/categories',
|
||||
'/api/tags',
|
||||
'/api/notifications',
|
||||
'/api/addons/atlas',
|
||||
'/api/addons/vacay',
|
||||
'/api/trips/:tripId/packing',
|
||||
'/api/trips/:tripId/todo',
|
||||
'/api/trips/:tripId/budget',
|
||||
'/api/trips/:tripId/reservations',
|
||||
'/api/trips/:tripId/accommodations',
|
||||
'/api/trips/:tripId/days',
|
||||
'/api/trips/:tripId/assignments',
|
||||
'/api/trips/:tripId/places',
|
||||
'/api/trips/:tripId/collab',
|
||||
'/api/trips/:tripId/files',
|
||||
'/api/photos',
|
||||
'/api/journeys',
|
||||
'/api/public/journey',
|
||||
'/api/shared',
|
||||
'/api/settings',
|
||||
'/api/backup',
|
||||
'/api/auth/app-config',
|
||||
'/api/auth/demo-login',
|
||||
'/api/auth/invite',
|
||||
'/api/auth/register',
|
||||
'/api/auth/login',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/me',
|
||||
'/api/auth/logout',
|
||||
'/api/auth/avatar',
|
||||
'/api/auth/users',
|
||||
'/api/auth/validate-keys',
|
||||
'/api/auth/app-settings',
|
||||
'/api/auth/travel-stats',
|
||||
'/api/auth/mfa',
|
||||
'/api/auth/mcp-tokens',
|
||||
'/api/auth/ws-token',
|
||||
'/api/auth/resource-token',
|
||||
'/api/auth/oidc',
|
||||
'/api/oauth',
|
||||
'/oauth/token',
|
||||
'/oauth/userinfo',
|
||||
'/oauth/revoke',
|
||||
'/api/admin',
|
||||
'/api/trips/:tripId/share-link',
|
||||
'/api/trips|',
|
||||
'/api/trips/:tripId|',
|
||||
'/api/trips/:tripId/members',
|
||||
'/api/trips/:tripId/cover',
|
||||
'/api/trips/:tripId/copy',
|
||||
'/api/trips/:tripId/bundle',
|
||||
'/api/trips/:tripId/export.ics',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
|
||||
@@ -30,4 +90,56 @@ describe('strangler toggle', () => {
|
||||
expect(match('/api/_nestxyz')).toBe(false);
|
||||
expect(match('/api/health')).toBe(false);
|
||||
});
|
||||
|
||||
it('exact prefixes (trailing |) match the path only, not sub-paths', () => {
|
||||
const match = makeNestPathMatcher(['/api/trips|', '/api/trips/:tripId|', '/api/trips/:tripId/members']);
|
||||
expect(match('/api/trips')).toBe(true);
|
||||
expect(match('/api/trips/5')).toBe(true);
|
||||
expect(match('/api/trips/5/members')).toBe(true);
|
||||
expect(match('/api/trips/5/members/2')).toBe(true);
|
||||
// Not-yet-migrated nested mounts stay on Express:
|
||||
expect(match('/api/trips/5/collab')).toBe(false);
|
||||
expect(match('/api/trips/5/files')).toBe(false);
|
||||
expect(match('/api/trips/5/cover')).toBe(false);
|
||||
});
|
||||
|
||||
it('routes auth sub-paths via their own explicit prefixes (no broad /api/auth catch-all)', () => {
|
||||
// The account prefixes alone must NOT swallow the separately-mounted oidc flow:
|
||||
const accountOnly = makeNestPathMatcher(['/api/auth/login', '/api/auth/me', '/api/auth/mfa', '/api/auth/mcp-tokens']);
|
||||
expect(accountOnly('/api/auth/login')).toBe(true);
|
||||
expect(accountOnly('/api/auth/me/password')).toBe(true);
|
||||
expect(accountOnly('/api/auth/mfa/verify-login')).toBe(true);
|
||||
expect(accountOnly('/api/auth/mcp-tokens/abc')).toBe(true);
|
||||
expect(accountOnly('/api/auth/oidc')).toBe(false);
|
||||
expect(accountOnly('/api/auth/oidc/callback')).toBe(false);
|
||||
// oidc is matched only by its own prefix (A2):
|
||||
const withOidc = makeNestPathMatcher(['/api/auth/oidc']);
|
||||
expect(withOidc('/api/auth/oidc/login')).toBe(true);
|
||||
expect(withOidc('/api/auth/oidc/callback')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes the OAuth public endpoints to Nest but leaves the SDK mounts on Express (A3)', () => {
|
||||
const match = makeNestPathMatcher(['/oauth/token', '/oauth/userinfo', '/oauth/revoke', '/api/oauth']);
|
||||
expect(match('/oauth/token')).toBe(true);
|
||||
expect(match('/oauth/userinfo')).toBe(true);
|
||||
expect(match('/oauth/revoke')).toBe(true);
|
||||
expect(match('/api/oauth/clients')).toBe(true);
|
||||
expect(match('/api/oauth/authorize/validate')).toBe(true);
|
||||
// The MCP SDK handlers must stay on Express:
|
||||
expect(match('/oauth/authorize')).toBe(false);
|
||||
expect(match('/oauth/register')).toBe(false);
|
||||
expect(match('/oauth/consent')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a pattern prefix with :param without capturing sibling routes', () => {
|
||||
const match = makeNestPathMatcher(['/api/trips/:tripId/packing']);
|
||||
expect(match('/api/trips/5/packing')).toBe(true);
|
||||
expect(match('/api/trips/5/packing/bags')).toBe(true);
|
||||
expect(match('/api/trips/abc/packing/123')).toBe(true);
|
||||
// Sibling trip routes stay on Express:
|
||||
expect(match('/api/trips/5/days')).toBe(false);
|
||||
expect(match('/api/trips/5/places')).toBe(false);
|
||||
expect(match('/api/trips/5')).toBe(false);
|
||||
expect(match('/api/trips/5/packingx')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { SystemNoticesController } from '../../../src/nest/system-notices/system-notices.controller';
|
||||
import type { SystemNoticesService } from '../../../src/nest/system-notices/system-notices.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { SystemNoticeDto } from '@trek/shared';
|
||||
|
||||
function makeController(svc: Partial<SystemNoticesService>) {
|
||||
return new SystemNoticesController(svc as SystemNoticesService);
|
||||
}
|
||||
|
||||
const user = { id: 7 } as User;
|
||||
|
||||
const notice: SystemNoticeDto = {
|
||||
id: 'welcome', display: 'modal', severity: 'info',
|
||||
titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true,
|
||||
};
|
||||
|
||||
/** Run `fn`, expecting an HttpException; return its { status, body }. */
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('SystemNoticesController (parity with the legacy /api/system-notices route)', () => {
|
||||
describe('GET /active', () => {
|
||||
it('returns the evaluated notices for the current user', () => {
|
||||
const getActiveFor = vi.fn().mockReturnValue([notice]);
|
||||
expect(makeController({ getActiveFor }).active(user)).toEqual([notice]);
|
||||
expect(getActiveFor).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/dismiss', () => {
|
||||
it('returns nothing (204) when the dismiss succeeds', () => {
|
||||
const dismiss = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ dismiss }).dismiss(user, 'welcome')).toBeUndefined();
|
||||
expect(dismiss).toHaveBeenCalledWith(7, 'welcome');
|
||||
});
|
||||
|
||||
it('404 { error: NOTICE_NOT_FOUND } when the id is unknown', () => {
|
||||
const dismiss = vi.fn().mockReturnValue(false);
|
||||
expect(thrown(() => makeController({ dismiss }).dismiss(user, 'nope'))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'NOTICE_NOT_FOUND' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { TagsController } from '../../../src/nest/tags/tags.controller';
|
||||
import type { TagsService } from '../../../src/nest/tags/tags.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { Tag } from '@trek/shared';
|
||||
|
||||
const user = { id: 5 } as User;
|
||||
|
||||
function makeController(svc: Partial<TagsService>) {
|
||||
return new TagsController(svc as TagsService);
|
||||
}
|
||||
|
||||
const tag: Tag = { id: 1, user_id: 5, name: 'Beach', color: '#10b981' };
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('TagsController (parity with the legacy /api/tags route)', () => {
|
||||
it('GET / returns the caller\'s tags wrapped in { tags }', () => {
|
||||
const list = vi.fn().mockReturnValue([tag]);
|
||||
expect(makeController({ list }).list(user)).toEqual({ tags: [tag] });
|
||||
expect(list).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('400 when name is missing', () => {
|
||||
const create = vi.fn();
|
||||
expect(thrown(() => makeController({ create }).create(user, undefined))).toEqual({
|
||||
status: 400, body: { error: 'Tag name is required' },
|
||||
});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a tag for the caller', () => {
|
||||
const create = vi.fn().mockReturnValue(tag);
|
||||
expect(makeController({ create }).create(user, 'Beach', '#10b981')).toEqual({ tag });
|
||||
expect(create).toHaveBeenCalledWith(5, 'Beach', '#10b981');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when the tag is not owned by the caller', () => {
|
||||
const getByIdAndUser = vi.fn().mockReturnValue(undefined);
|
||||
const update = vi.fn();
|
||||
expect(thrown(() => makeController({ getByIdAndUser, update }).update(user, '9', 'X'))).toEqual({
|
||||
status: 404, body: { error: 'Tag not found' },
|
||||
});
|
||||
expect(getByIdAndUser).toHaveBeenCalledWith('9', 5);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates an owned tag', () => {
|
||||
const getByIdAndUser = vi.fn().mockReturnValue(tag);
|
||||
const update = vi.fn().mockReturnValue({ ...tag, name: 'Hike' });
|
||||
expect(makeController({ getByIdAndUser, update }).update(user, '1', 'Hike')).toEqual({ tag: { ...tag, name: 'Hike' } });
|
||||
expect(update).toHaveBeenCalledWith('1', 'Hike', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when the tag is not owned by the caller', () => {
|
||||
const getByIdAndUser = vi.fn().mockReturnValue(undefined);
|
||||
const remove = vi.fn();
|
||||
expect(thrown(() => makeController({ getByIdAndUser, remove }).remove(user, '9'))).toEqual({
|
||||
status: 404, body: { error: 'Tag not found' },
|
||||
});
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes an owned tag', () => {
|
||||
const getByIdAndUser = vi.fn().mockReturnValue(tag);
|
||||
const remove = vi.fn();
|
||||
expect(makeController({ getByIdAndUser, remove }).remove(user, '1')).toEqual({ success: true });
|
||||
expect(remove).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { TodoController } from '../../../src/nest/todo/todo.controller';
|
||||
import type { TodoService } from '../../../src/nest/todo/todo.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { id: 5, user_id: 1 };
|
||||
|
||||
function makeService(overrides: Partial<TodoService> = {}): TodoService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TodoService;
|
||||
}
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
describe('TodoController (parity with the legacy /api/trips/:tripId/todo route)', () => {
|
||||
it('404 when the trip is not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new TodoController(svc).list(user, '5'))).toEqual({
|
||||
status: 404, body: { error: 'Trip not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('GET / returns items', () => {
|
||||
const svc = makeService({ listItems: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without permission', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new TodoController(svc).create(user, '5', { name: 'Pack' }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 when name missing', () => {
|
||||
expect(thrown(() => new TodoController(makeService()).create(user, '5', {}))).toEqual({
|
||||
status: 400, body: { error: 'Item name is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates and broadcasts', () => {
|
||||
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Pack' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createItem, broadcast } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).create(user, '5', { name: 'Pack', priority: 2 }, 'sock')).toEqual({ item: { id: 9, name: 'Pack' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:created', { item: { id: 9, name: 'Pack' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when item missing', () => {
|
||||
const svc = makeService({ updateItem: vi.fn().mockReturnValue(null) } as Partial<TodoService>);
|
||||
expect(thrown(() => new TodoController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates, forwards changed keys, broadcasts', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateItem, broadcast } as Partial<TodoService>);
|
||||
new TodoController(svc).update(user, '5', '9', { checked: true }, 'sock');
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ checked: true }), ['checked']);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:updated', { item: { id: 9 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when item missing', () => {
|
||||
const svc = makeService({ deleteItem: vi.fn().mockReturnValue(false) } as Partial<TodoService>);
|
||||
expect(thrown(() => new TodoController(svc).remove(user, '5', '9'))).toEqual({
|
||||
status: 404, body: { error: 'Item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes and broadcasts', () => {
|
||||
const deleteItem = vi.fn().mockReturnValue(true);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteItem, broadcast } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:deleted', { itemId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /reorder succeeds with permission', () => {
|
||||
const reorderItems = vi.fn();
|
||||
const svc = makeService({ reorderItems } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).reorder(user, '5', [3, 1, 2])).toEqual({ success: true });
|
||||
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]);
|
||||
});
|
||||
|
||||
describe('category assignees', () => {
|
||||
it('GET returns assignees', () => {
|
||||
const svc = makeService({ getCategoryAssignees: vi.fn().mockReturnValue([{ user_id: 2 }]) } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).categoryAssignees(user, '5')).toEqual({ assignees: [{ user_id: 2 }] });
|
||||
});
|
||||
|
||||
it('PUT updates, decodes the category and broadcasts', () => {
|
||||
const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateCategoryAssignees, broadcast } as Partial<TodoService>);
|
||||
new TodoController(svc).updateCategoryAssignees(user, '5', 'To%20Buy', [2], 'sock');
|
||||
expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'To Buy', [2]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:assignees', { category: 'To Buy', assignees: [{ user_id: 2 }] }, 'sock');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() }));
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
import { TripsController } from '../../../src/nest/trips/trips.controller';
|
||||
import type { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const req = { headers: {} } as Request;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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(new TripsController(svc({ list } as Partial<TripsService>)).list(user, '1')).toEqual({ trips: [{ id: 1 }] });
|
||||
expect(list).toHaveBeenCalledWith(1, 1);
|
||||
});
|
||||
|
||||
describe('POST / (create)', () => {
|
||||
it('403 without trip_create, 400 without title', () => {
|
||||
expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
|
||||
expect(thrown(() => new TripsController(svc()).create(user, {}, req))).toEqual({ status: 400, body: { error: 'Title is required' } });
|
||||
});
|
||||
|
||||
it('infers end_date from start_date (+6 days) and creates', () => {
|
||||
const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
||||
new TripsController(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(() => new TripsController(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('GET /:id 404 when missing', () => {
|
||||
expect(thrown(() => new TripsController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when no access; 403 on archive without trip_archive', () => {
|
||||
expect(thrown(() => new TripsController(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(thrown(() => new TripsController(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', () => {
|
||||
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(new TripsController(s).update(user, '9', { title: 'b' }, req, 'sock')).toEqual({ trip: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /:id/copy', () => {
|
||||
it('403 without trip_create, 404 without access', () => {
|
||||
expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', undefined, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
|
||||
expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', undefined, 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(new TripsController(s).copy(user, '9', 'Copy', req)).toEqual({ trip: { id: 42 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when no owner, 403 without trip_delete', () => {
|
||||
expect(thrown(() => new TripsController(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(() => new TripsController(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(new TripsController(s).remove(user, '9', req, 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('9', 'trip:deleted', { id: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('members', () => {
|
||||
it('GET 404 without access, else owner+members+current_user_id', () => {
|
||||
expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).members(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const s = svc({ listMembers: vi.fn().mockReturnValue({ owner: { id: 1 }, members: [] }) } as Partial<TripsService>);
|
||||
expect(new TripsController(s).members(user, '9')).toEqual({ owner: { id: 1 }, members: [], current_user_id: 1 });
|
||||
});
|
||||
|
||||
it('POST 403 without member_manage, else adds + notifies', () => {
|
||||
expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 403, body: { error: 'No permission to manage members' } });
|
||||
const addMember = vi.fn().mockReturnValue({ member: { id: 2, email: 'bob@x.y' }, targetUserId: 2, tripTitle: 'T' });
|
||||
const notifyInvite = vi.fn();
|
||||
const s = svc({ addMember, notifyInvite } as Partial<TripsService>);
|
||||
expect(new TripsController(s).addMember(user, '9', 'bob@x.y')).toEqual({ member: { id: 2, email: 'bob@x.y' } });
|
||||
expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y');
|
||||
});
|
||||
|
||||
it('DELETE self needs no permission; removing others needs member_manage', () => {
|
||||
const removeMember = vi.fn();
|
||||
const s = svc({ can: vi.fn().mockReturnValue(false), removeMember } as Partial<TripsService>);
|
||||
// self-removal (targetId === user.id) bypasses the permission check
|
||||
expect(new TripsController(s).removeMember(user, '9', '1')).toEqual({ success: true });
|
||||
expect(thrown(() => new TripsController(s).removeMember(user, '9', '2'))).toEqual({ status: 403, body: { error: 'No permission to remove members' } });
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /:id/bundle 404 then aggregates', () => {
|
||||
expect(thrown(() => new TripsController(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 }), bundle } as Partial<TripsService>);
|
||||
expect(new TripsController(s).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(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new TripsController(svc({ can: vi.fn().mockReturnValue(false) })).cover(user, '9', file))).toEqual({ status: 403, body: { error: 'No permission to change the cover image' } });
|
||||
expect(thrown(() => new TripsController(svc({ getRaw: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).cover(user, '9', file))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new TripsController(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(new TripsController(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');
|
||||
});
|
||||
});
|
||||
|
||||
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(() => new TripsController(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 s = svc({ exportICS: vi.fn().mockReturnValue({ ics: 'BEGIN:VCALENDAR', filename: 'trip.ics' }) } as Partial<TripsService>);
|
||||
new TripsController(s).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('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(() => new TripsController(s).copy(user, '9', undefined, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(() => ({ id: 42 })), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn(() => ({ user_id: 1 })) }));
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { tripSvc } = vi.hoisted(() => ({
|
||||
tripSvc: {
|
||||
listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(),
|
||||
getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(),
|
||||
listMembers: vi.fn(() => ({ owner: { id: 1 }, members: [] })), addMember: vi.fn(), removeMember: vi.fn(),
|
||||
exportICS: vi.fn(), copyTripById: vi.fn(), TRIP_SELECT: 'SELECT * FROM trips t',
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/tripService', () => tripSvc);
|
||||
vi.mock('../../../src/services/dayService', () => ({ listDays: () => ({ days: [1] }), listAccommodations: () => [] }));
|
||||
vi.mock('../../../src/services/placeService', () => ({ listPlaces: () => [] }));
|
||||
vi.mock('../../../src/services/packingService', () => ({ listItems: () => [] }));
|
||||
vi.mock('../../../src/services/todoService', () => ({ listItems: () => [] }));
|
||||
vi.mock('../../../src/services/budgetService', () => ({ listBudgetItems: () => [] }));
|
||||
vi.mock('../../../src/services/reservationService', () => ({ listReservations: () => [] }));
|
||||
vi.mock('../../../src/services/fileService', () => ({ listFiles: () => [] }));
|
||||
|
||||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
|
||||
function svc() { return new TripsService(); }
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => {
|
||||
it('delegates the simple wrappers to tripService', () => {
|
||||
const s = svc();
|
||||
s.list(1, 0); expect(tripSvc.listTrips).toHaveBeenCalledWith(1, 0);
|
||||
s.create(1, { title: 'T' } as never); expect(tripSvc.createTrip).toHaveBeenCalledWith(1, { title: 'T' });
|
||||
s.get('9', 1); expect(tripSvc.getTrip).toHaveBeenCalledWith('9', 1);
|
||||
s.getRaw('9'); expect(tripSvc.getTripRaw).toHaveBeenCalledWith('9');
|
||||
s.getOwner('9'); expect(tripSvc.getTripOwner).toHaveBeenCalledWith('9');
|
||||
s.update('9', 1, {} as never, 'user'); expect(tripSvc.updateTrip).toHaveBeenCalledWith('9', 1, {}, 'user');
|
||||
s.remove('9', 1, 'user'); expect(tripSvc.deleteTrip).toHaveBeenCalledWith('9', 1, 'user');
|
||||
s.deleteOldCover('/old.jpg'); expect(tripSvc.deleteOldCover).toHaveBeenCalledWith('/old.jpg');
|
||||
s.updateCoverImage('9', '/n.jpg'); expect(tripSvc.updateCoverImage).toHaveBeenCalledWith('9', '/n.jpg');
|
||||
s.copy('9', 1, 'C'); expect(tripSvc.copyTripById).toHaveBeenCalledWith('9', 1, 'C');
|
||||
s.listMembers('9', 1); expect(tripSvc.listMembers).toHaveBeenCalledWith('9', 1);
|
||||
s.addMember('9', 'b@x.y', 1, 1); expect(tripSvc.addMember).toHaveBeenCalledWith('9', 'b@x.y', 1, 1);
|
||||
s.removeMember('9', 2); expect(tripSvc.removeMember).toHaveBeenCalledWith('9', 2);
|
||||
s.exportICS('9'); expect(tripSvc.exportICS).toHaveBeenCalledWith('9');
|
||||
});
|
||||
|
||||
it('can() delegates to checkPermission; broadcast forwards', () => {
|
||||
svc().can('trip_edit', 'user', 1, 1, false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('trip_edit', 'user', 1, 1, false);
|
||||
svc().broadcast('9', 'trip:updated', { a: 1 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { a: 1 }, 'sock');
|
||||
});
|
||||
|
||||
it('getCopiedTrip re-reads via the TRIP_SELECT query', () => {
|
||||
expect(svc().getCopiedTrip(42, 1)).toEqual({ id: 42 });
|
||||
expect(dbMock.prepare).toHaveBeenCalledWith(expect.stringContaining('SELECT * FROM trips t'));
|
||||
});
|
||||
|
||||
it('bundle aggregates every sub-collection + the member list', () => {
|
||||
const result = svc().bundle('9', { user_id: 1 });
|
||||
expect(result).toMatchObject({ trip: { user_id: 1 }, days: [1], places: [], members: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('notifyInvite is fire-and-forget (no throw)', () => {
|
||||
expect(() => svc().notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { VacayController } from '../../../src/nest/vacay/vacay.controller';
|
||||
import type { VacayService } from '../../../src/nest/vacay/vacay.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', email: 'u@example.test', role: 'user' } as User;
|
||||
|
||||
function makeController(svc: Partial<VacayService>) {
|
||||
return new VacayController(svc as VacayService);
|
||||
}
|
||||
|
||||
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 the handler to throw');
|
||||
}
|
||||
|
||||
// Default plan helpers shared by most handlers.
|
||||
const planBase = { getActivePlanId: vi.fn().mockReturnValue(10), getActivePlan: vi.fn().mockReturnValue({ id: 10 }) };
|
||||
|
||||
describe('VacayController (parity with the legacy /api/addons/vacay route)', () => {
|
||||
it('GET /plan delegates getPlanData', () => {
|
||||
const getPlanData = vi.fn().mockReturnValue({ plan: { id: 10 } });
|
||||
expect(makeController({ getPlanData }).getPlan(user)).toEqual({ plan: { id: 10 } });
|
||||
});
|
||||
|
||||
it('PUT /plan forwards the socket id', async () => {
|
||||
const updatePlan = vi.fn().mockResolvedValue({ ok: true });
|
||||
await makeController({ ...planBase, updatePlan }).updatePlan(user, { foo: 1 }, 'sock-1');
|
||||
expect(updatePlan).toHaveBeenCalledWith(10, { foo: 1 }, 'sock-1');
|
||||
});
|
||||
|
||||
describe('holiday calendars', () => {
|
||||
it('400 when region missing', () => {
|
||||
return thrown(() => makeController({ ...planBase }).addHolidayCalendar(user, {})).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'region required' } }));
|
||||
});
|
||||
|
||||
it('creates a calendar', () => {
|
||||
const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' });
|
||||
const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock');
|
||||
expect(res).toEqual({ calendar: { id: 1, region: 'DE-BY' } });
|
||||
expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock');
|
||||
});
|
||||
|
||||
it('404 on update of a missing calendar', () => {
|
||||
const updateHolidayCalendar = vi.fn().mockReturnValue(null);
|
||||
return thrown(() => makeController({ ...planBase, updateHolidayCalendar }).updateHolidayCalendar(user, '9', {})).then((r) =>
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Calendar not found' } }));
|
||||
});
|
||||
|
||||
it('404 on delete of a missing calendar', () => {
|
||||
const deleteHolidayCalendar = vi.fn().mockReturnValue(false);
|
||||
return thrown(() => makeController({ ...planBase, deleteHolidayCalendar }).deleteHolidayCalendar(user, '9')).then((r) =>
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Calendar not found' } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('color', () => {
|
||||
it('403 when the target user is not in the plan', () => {
|
||||
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
return thrown(() => makeController({ ...planBase, getPlanUsers }).setColor(user, { color: '#fff', target_user_id: 99 })).then((r) =>
|
||||
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
|
||||
});
|
||||
|
||||
it('sets the colour for an in-plan user', () => {
|
||||
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
const setUserColor = vi.fn();
|
||||
expect(makeController({ ...planBase, getPlanUsers, setUserColor }).setColor(user, { color: '#fff' }, 'sock')).toEqual({ success: true });
|
||||
expect(setUserColor).toHaveBeenCalledWith(1, 10, '#fff', 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invites', () => {
|
||||
it('400 when user_id missing', () => {
|
||||
return thrown(() => makeController({ ...planBase }).invite(user, undefined)).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }));
|
||||
});
|
||||
|
||||
it('maps a sendInvite error to its status', () => {
|
||||
const sendInvite = vi.fn().mockReturnValue({ error: 'Already in a plan', status: 409 });
|
||||
return thrown(() => makeController({ ...planBase, sendInvite }).invite(user, 2)).then((r) =>
|
||||
expect(r).toEqual({ status: 409, body: { error: 'Already in a plan' } }));
|
||||
});
|
||||
|
||||
it('sends an invite', () => {
|
||||
const sendInvite = vi.fn().mockReturnValue({});
|
||||
expect(makeController({ ...planBase, sendInvite }).invite(user, 2)).toEqual({ success: true });
|
||||
expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2);
|
||||
});
|
||||
|
||||
it('maps an acceptInvite error', () => {
|
||||
const acceptInvite = vi.fn().mockReturnValue({ error: 'Invite not found', status: 404 });
|
||||
return thrown(() => makeController({ acceptInvite }).acceptInvite(user, 5)).then((r) =>
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Invite not found' } }));
|
||||
});
|
||||
|
||||
it('decline / cancel / dissolve return success', () => {
|
||||
const declineInvite = vi.fn(); const cancelInvite = vi.fn(); const dissolvePlan = vi.fn();
|
||||
expect(makeController({ declineInvite }).declineInvite(user, 5)).toEqual({ success: true });
|
||||
expect(makeController({ ...planBase, cancelInvite }).cancelInvite(user, 2)).toEqual({ success: true });
|
||||
expect(makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('years', () => {
|
||||
it('400 when year missing on add', () => {
|
||||
return thrown(() => makeController({ ...planBase }).addYear(user, undefined)).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'Year required' } }));
|
||||
});
|
||||
|
||||
it('adds and deletes years', () => {
|
||||
const addYear = vi.fn().mockReturnValue([2026]); const deleteYear = vi.fn().mockReturnValue([]);
|
||||
expect(makeController({ ...planBase, addYear }).addYear(user, 2026, 'sock')).toEqual({ years: [2026] });
|
||||
expect(makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('entries', () => {
|
||||
it('400 when date missing on toggle', () => {
|
||||
return thrown(() => makeController({ ...planBase }).toggleEntry(user, {})).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'date required' } }));
|
||||
});
|
||||
|
||||
it('403 when toggling for a user not in the plan', () => {
|
||||
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
return thrown(() => makeController({ ...planBase, getPlanUsers }).toggleEntry(user, { date: '2026-07-01', target_user_id: 99 })).then((r) =>
|
||||
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
|
||||
});
|
||||
|
||||
it('toggles for the caller', () => {
|
||||
const toggleEntry = vi.fn().mockReturnValue({ action: 'added' });
|
||||
expect(makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' });
|
||||
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('GET wraps stats', () => {
|
||||
const getStats = vi.fn().mockReturnValue({ used: 5 });
|
||||
expect(makeController({ ...planBase, getStats }).stats(user, '2026')).toEqual({ stats: { used: 5 } });
|
||||
});
|
||||
|
||||
it('403 on updateStats for a user not in the plan', () => {
|
||||
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
return thrown(() => makeController({ ...planBase, getPlanUsers }).updateStats(user, '2026', { vacation_days: 30, target_user_id: 99 })).then((r) =>
|
||||
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('public holidays', () => {
|
||||
it('502 when the upstream country lookup fails', () => {
|
||||
const getCountries = vi.fn().mockResolvedValue({ error: 'upstream down' });
|
||||
return thrown(() => makeController({ getCountries }).holidayCountries()).then((r) =>
|
||||
expect(r).toEqual({ status: 502, body: { error: 'upstream down' } }));
|
||||
});
|
||||
|
||||
it('returns the country data on success', async () => {
|
||||
const getCountries = vi.fn().mockResolvedValue({ data: [{ code: 'DE' }] });
|
||||
expect(await makeController({ getCountries }).holidayCountries()).toEqual([{ code: 'DE' }]);
|
||||
});
|
||||
|
||||
it('502 when the holidays lookup fails', () => {
|
||||
const getHolidays = vi.fn().mockResolvedValue({ error: 'upstream down' });
|
||||
return thrown(() => makeController({ getHolidays }).holidays('2026', 'DE')).then((r) =>
|
||||
expect(r).toEqual({ status: 502, body: { error: 'upstream down' } }));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user