Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087)

* 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.

* Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.

* Restore the reset-password rate limit and fix copyTrip reservation links

Two correctness/security gaps the NestJS migration introduced:

- POST /api/auth/reset-password lost its per-IP rate limiter. Restore it
  (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter)
  so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019.
- copyTripById did not copy reservations.end_day_id (a day reference — now
  remapped through dayMap like day_id) or needs_review, so a duplicated trip
  lost multi-day transport end-day links and reset the review flag.

* Clean up dead code, dedupe helpers, fix the reset-password contract

- Remove server exports orphaned by the Express removal: the immich
  album-link helpers, seven route-only service exports, getFileByIdFull;
  de-export internal-only helpers (utcSuffix).
- De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts)
  and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost
  (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged.
- resetPasswordRequestSchema declared `password`, but the client sends and
  the service reads `new_password` — rename it so the contract matches and
  the client types resolve.
- Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of
  fetching ~4600 features from GitHub during the test (it hung the suite).

* Make the client typecheck runnable (vitest/vite ambient types)

The client had no `typecheck` script and tsc couldn't even start (the
baseUrl deprecation errored out, same as server/shared already silence).
Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck`
npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals
so tsc knows the test globals (describe/it/expect/vi). This turns ~3600
phantom "Cannot find name" errors into a real, measurable count (~590 actual
type errors remain, to be worked down). Type-only; no runtime change.

* Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.

* chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations

The migration that disables the legacy "memories" addon swallowed any
error in an empty catch, as did ~30 other catch blocks in the migration
runner (column adds, the journey rebuild, index probes). Replace each
silent catch with the existing console.warn('[migrations] ...') log so
failures are visible. Control flow is unchanged: every step stays
non-fatal, nothing new is thrown.

Add a static guardrail test that scans the migration source and fails
when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE /
DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and
when an empty/silent catch block is reintroduced. The existing
destructive statements are all legitimate table rebuilds or
bounded cleanups and are recorded in the allowlist with a reason.

* Re-check SSRF on every redirect hop when resolving short links

Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve.

* Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.

* fix(i18n): guard locale key parity and finish the OAuth consent page strings

Every non-en locale now exposes the exact same flat key set as en. Keys that
had drifted out of sync are backfilled with the English source value (tagged
en-fallback) so t() resolves a real string instead of relying on the silent
runtime fallback; no existing translation was touched and no key was removed.

Add a parity test that imports each aggregated locale bundle and asserts its
key set matches en, with a diagnostic listing of any missing/extra keys. This
complements the file-level check in shared/scripts by guarding the merged
export the app actually serves.

Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded
English chrome strings now go through oauth.authorize.* keys (English source
in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged.

* Add semantic theme color tokens to Tailwind

Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values.

* Surface silent store failures to the user and validate API responses in dev

Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.

* Migrate static theme inline styles to Tailwind utilities and extract page sub-components

Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.

* Remove the unrouted photos page and its dead photo components

PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays.

* Resolve the remaining client type errors and the trip.title navbar bug

Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change.

* Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities

Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead.

Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue).

* Add eslint flat-config for client and server and gate typecheck, lint and pages in CI

client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking.

* Decompose the remaining God Components into hooks, helpers and sub-components

FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted.

* Fix duplicate React keys in the file-assign place list

When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged.

* Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.

* Install all workspaces in the server CI job so SWC's native binary is present

The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'.

* Re-resolve dependencies with npm install in the server CI job for SWC

Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts.

* Install @swc/core's Linux binary explicitly in the server CI job

Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config.

* Use legacy-peer-deps when installing the SWC Linux binary in CI

The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary.

* Keep the lockfile when installing the SWC binary so other deps stay pinned

Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version.
This commit is contained in:
Maurice
2026-05-31 21:10:00 +02:00
committed by GitHub
parent 6d2dd37414
commit 20791a29a7
721 changed files with 44416 additions and 31919 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* GET /api/addons e2e — exercises the AddonsController through the real
* JwtAuthGuard against a temp SQLite db. getCollabFeatures + getPhotoProviderConfig
* are mocked; the addons/photo_providers/photo_provider_fields reads run against
* the temp db. Asserts the byte-identical body the legacy inline handler produced.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, type TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
const { getCollabFeatures, getPhotoProviderConfig } = vi.hoisted(() => ({
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
}));
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures }));
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
import { AddonsModule } from '../../src/nest/addons/addons.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run();
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run();
db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run();
db.prepare(`INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
VALUES ('immich','base_url','Base URL','text','https://...',NULL,1,0,'immich_url',NULL,1)`).run();
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie', async () => {
expect((await request(server).get('/api/addons')).status).toBe(401);
});
it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => {
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
addons: [
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
{
id: 'immich',
name: 'Immich',
type: 'photo_provider',
icon: 'Image',
enabled: true,
config: { url: 'https://immich.example' },
fields: [
{
key: 'base_url',
label: 'Base URL',
input_type: 'text',
placeholder: 'https://...',
hint: null,
required: true,
secret: false,
settings_key: 'immich_url',
payload_key: null,
sort_order: 1,
},
],
},
],
});
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Admin e2e — exercises the migrated /api/admin endpoints through the real
* JwtAuthGuard + AdminGuard against a temp SQLite db. The admin service +
* helpers are mocked; this focuses on auth (401), the admin gate (403 for a
* non-admin), create-201, validation 400 and the dev-only 404.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logInfo: vi.fn() }));
vi.mock('../../src/mcp', () => ({ invalidateMcpSessions: vi.fn() }));
vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({})), setAdminPreferences: vi.fn() }));
vi.mock('../../src/services/settingsService', () => ({ getAdminUserDefaults: vi.fn(() => ({})), setAdminUserDefaults: vi.fn() }));
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
const { adminSvc } = vi.hoisted(() => ({
adminSvc: { listUsers: vi.fn(), createUser: vi.fn(), updatePlacesPhotos: vi.fn() },
}));
vi.mock('../../src/services/adminService', () => adminSvc);
import { AdminModule } from '../../src/nest/admin/admin.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Admin e2e (real auth + admin guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AdminModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
seedUser(db as never, { id: 2, role: 'user', email: 'member@example.test' });
app = await build();
server = app.getHttpServer();
adminSvc.listUsers.mockReturnValue([{ id: 1 }]);
});
beforeEach(() => { delete process.env.NODE_ENV; });
afterAll(async () => {
await app.close();
});
it('401 without a session', async () => {
expect((await request(server).get('/api/admin/users')).status).toBe(401);
});
it('403 for a non-admin', async () => {
const res = await request(server).get('/api/admin/users').set('Cookie', sessionCookie(2));
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' });
});
it('200 list for an admin', async () => {
const res = await request(server).get('/api/admin/users').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ users: [{ id: 1 }] });
});
it('201 on user create', async () => {
adminSvc.createUser.mockReturnValue({ user: { id: 3 }, insertedId: 3, auditDetails: {} });
const res = await request(server).post('/api/admin/users').set('Cookie', sessionCookie(1)).send({ email: 'new@x.y' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ user: { id: 3 } });
});
it('400 on a non-boolean feature toggle', async () => {
const res = await request(server).put('/api/admin/places-photos').set('Cookie', sessionCookie(1)).send({ enabled: 'yes' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'enabled must be a boolean' });
});
it('404 on the dev-only test-notification outside development', async () => {
const res = await request(server).post('/api/admin/dev/test-notification').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(404);
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Airports module e2e — exercises the migrated /api/airports endpoints through
* the real JwtAuthGuard against a temp SQLite db (seeded via the shared harness).
* The airport service is mocked so the test doesn't depend on the bundled dataset.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mockSearch, mockFindByIata } = vi.hoisted(() => ({ mockSearch: vi.fn(), mockFindByIata: vi.fn() }));
vi.mock('../../src/services/airportService', async (importActual) => {
const actual = await importActual<typeof import('../../src/services/airportService')>();
return { ...actual, searchAirports: mockSearch, findByIata: mockFindByIata };
});
import { AirportsModule } from '../../src/nest/airports/airports.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const BER = {
iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin',
country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin',
};
describe('Airports e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AirportsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
mockSearch.mockReturnValue([BER]);
mockFindByIata.mockImplementation((code: string) => (code === 'BER' ? BER : null));
});
afterAll(async () => {
await app.close();
});
it('401 { error, code } without a session cookie', async () => {
const res = await request(server).get('/api/airports/search').query({ q: 'ber' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
});
it('200 with results for a query', async () => {
const res = await request(server).get('/api/airports/search').set('Cookie', sessionCookie(1)).query({ q: 'ber' });
expect(res.status).toBe(200);
expect(res.body).toEqual([BER]);
});
it('200 [] for a missing query without hitting the service', async () => {
mockSearch.mockClear();
const res = await request(server).get('/api/airports/search').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(mockSearch).not.toHaveBeenCalled();
});
it('200 for a known IATA code', async () => {
const res = await request(server).get('/api/airports/BER').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual(BER);
});
it('404 { error } for an unknown IATA code', async () => {
const res = await request(server).get('/api/airports/ZZZ').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Airport not found' });
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Assignments module e2e — exercises both migrated controllers through the real
* JwtAuthGuard against a temp SQLite db. assignmentService, journeyService,
* the permission check, canAccessTrip and the WebSocket broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn() }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { asg } = vi.hoisted(() => ({
asg: {
getAssignmentWithPlace: vi.fn(), listDayAssignments: vi.fn(), dayExists: vi.fn(), placeExists: vi.fn(),
createAssignment: vi.fn(), assignmentExistsInDay: vi.fn(), deleteAssignment: vi.fn(), reorderAssignments: vi.fn(),
getAssignmentForTrip: vi.fn(), moveAssignment: vi.fn(), getParticipants: vi.fn(), updateTime: vi.fn(), setParticipants: vi.fn(),
},
}));
vi.mock('../../src/services/assignmentService', () => asg);
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Assignments e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AssignmentsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
asg.listDayAssignments.mockReturnValue([{ id: 1 }]);
asg.createAssignment.mockReturnValue({ id: 9 });
asg.getParticipants.mockReturnValue([{ user_id: 2 }]);
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
asg.dayExists.mockReturnValue(true);
asg.placeExists.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie', async () => {
expect((await request(server).get('/api/trips/5/days/3/assignments')).status).toBe(401);
});
it('200 list day-assignments', async () => {
const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ assignments: [{ id: 1 }] });
});
it('201 create, 404 place', async () => {
const ok = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 2 });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ assignment: { id: 9 } });
asg.placeExists.mockReturnValue(false);
const miss = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 99 });
expect(miss.status).toBe(404);
expect(miss.body).toEqual({ error: 'Place not found' });
});
it('200 participants (access-only)', async () => {
const res = await request(server).get('/api/trips/5/assignments/9/participants').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ participants: [{ user_id: 2 }] });
});
it('400 set participants with non-array', async () => {
const res = await request(server).put('/api/trips/5/assignments/9/participants').set('Cookie', sessionCookie(1)).send({ user_ids: 'no' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'user_ids must be an array' });
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Atlas module e2e — exercises the migrated /api/addons/atlas endpoints through
* the real JwtAuthGuard against a temp SQLite db. atlasService is mocked; this
* focuses on auth, status codes (mark POSTs stay 200), the cache headers and the
* bespoke 400/404 bodies.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mocks } = vi.hoisted(() => ({
mocks: {
getStats: vi.fn(),
getCountryPlaces: vi.fn(),
markCountryVisited: vi.fn(),
unmarkCountryVisited: vi.fn(),
markRegionVisited: vi.fn(),
unmarkRegionVisited: vi.fn(),
getVisitedRegions: vi.fn(),
getRegionGeo: vi.fn(),
listBucketList: vi.fn(),
createBucketItem: vi.fn(),
updateBucketItem: vi.fn(),
deleteBucketItem: vi.fn(),
},
}));
vi.mock('../../src/services/atlasService', () => mocks);
import { AtlasModule } from '../../src/nest/atlas/atlas.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Atlas e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AtlasModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
mocks.getStats.mockResolvedValue({ countries: 3 });
mocks.markCountryVisited.mockReturnValue(undefined);
mocks.listBucketList.mockReturnValue([{ id: 1, name: 'Tokyo' }]);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/addons/atlas/stats');
expect(res.status).toBe(401);
});
it('200 stats for an authenticated user', async () => {
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ countries: 3 });
});
it('200 (not 201) on POST country mark, with upper-cased code', async () => {
const res = await request(server).post('/api/addons/atlas/country/de/mark').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(mocks.markCountryVisited).toHaveBeenCalledWith(1, 'DE');
});
it('400 on region mark without name/country_code', async () => {
const res = await request(server).post('/api/addons/atlas/region/by/mark').set('Cookie', sessionCookie(1)).send({ name: 'Bavaria' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'name and country_code are required' });
});
it('no-store cache header on /regions', async () => {
mocks.getVisitedRegions.mockResolvedValue({ regions: {} });
const res = await request(server).get('/api/addons/atlas/regions').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.headers['cache-control']).toBe('no-cache, no-store');
});
it('empty FeatureCollection (no cache header) when /regions/geo has no countries', async () => {
const res = await request(server).get('/api/addons/atlas/regions/geo').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ type: 'FeatureCollection', features: [] });
expect(res.headers['cache-control']).toBeUndefined();
});
it('201 on bucket-list create', async () => {
mocks.createBucketItem.mockReturnValue({ id: 2, name: 'Kyoto' });
const res = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(1)).send({ name: 'Kyoto' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ item: { id: 2, name: 'Kyoto' } });
});
it('404 on delete of a missing bucket item', async () => {
mocks.deleteBucketItem.mockReturnValue(false);
const res = await request(server).delete('/api/addons/atlas/bucket-list/9').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Item not found' });
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Auth e2e — exercises the migrated /api/auth endpoints through the real
* JwtAuthGuard/OptionalJwtGuard AND the real cookie service against a temp
* SQLite db. Only the authService (credential/MFA logic) + audit/notifications
* are mocked; this proves the httpOnly trek_session cookie is set on login and
* cleared on logout, that /me requires a session, and that /app-config is
* optional-auth.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://x', sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) }));
const { authSvc } = vi.hoisted(() => ({
authSvc: {
getAppConfig: vi.fn(), demoLogin: vi.fn(), validateInviteToken: vi.fn(), registerUser: vi.fn(), loginUser: vi.fn(),
requestPasswordReset: vi.fn(), resetPassword: vi.fn(), verifyMfaLogin: vi.fn(), getCurrentUser: vi.fn(),
changePassword: vi.fn(), deleteAccount: vi.fn(), updateMapsKey: vi.fn(), updateApiKeys: vi.fn(), updateSettings: vi.fn(),
getSettings: vi.fn(), saveAvatar: vi.fn(), deleteAvatar: vi.fn(), listUsers: vi.fn(), validateKeys: vi.fn(),
getAppSettings: vi.fn(), updateAppSettings: vi.fn(), getTravelStats: vi.fn(), setupMfa: vi.fn(), enableMfa: vi.fn(),
disableMfa: vi.fn(), listMcpTokens: vi.fn(), createMcpToken: vi.fn(), deleteMcpToken: vi.fn(), createWsToken: vi.fn(),
createResourceToken: vi.fn(),
},
}));
vi.mock('../../src/services/authService', () => authSvc);
import { AuthModule } from '../../src/nest/auth/auth.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [AuthModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1, email: 'u@example.test' });
app = await build();
server = app.getHttpServer();
authSvc.getAppConfig.mockReturnValue({ version: '3' });
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 } });
authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' });
});
beforeEach(() => vi.clearAllMocks());
afterAll(async () => {
await app.close();
});
it('GET /app-config is optional-auth (200 without a cookie)', async () => {
authSvc.getAppConfig.mockReturnValue({ version: '3' });
const res = await request(server).get('/api/auth/app-config');
expect(res.status).toBe(200);
expect(res.body).toEqual({ version: '3' });
});
it('GET /me requires a session (401 without a cookie)', async () => {
expect((await request(server).get('/api/auth/me')).status).toBe(401);
});
it('GET /me returns the user with a valid session', async () => {
authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' });
const res = await request(server).get('/api/auth/me').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ user: { id: 1, email: 'u@example.test' } });
});
it('POST /login sets the httpOnly trek_session cookie', async () => {
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 } });
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ token: 'jwt.token.value', user: { id: 1 } });
const setCookie = res.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
}, 10000);
it('POST /logout clears the session cookie', async () => {
const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
const setCookie = res.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((c) => c.startsWith('trek_session='))).toBe(true);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Backup e2e — exercises the migrated /api/backup endpoints through the real
* JwtAuthGuard + AdminGuard against a temp SQLite db. The backup service +
* audit log are mocked; this focuses on auth (401), the admin gate (403 for a
* non-admin), the rate-limit 429, filename guards and status codes.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
const { backupSvc } = vi.hoisted(() => ({
backupSvc: {
listBackups: vi.fn(), createBackup: vi.fn(), restoreFromZip: vi.fn(), getAutoSettings: vi.fn(),
updateAutoSettings: vi.fn(), deleteBackup: vi.fn(), isValidBackupFilename: vi.fn(), backupFilePath: vi.fn(),
backupFileExists: vi.fn(), checkRateLimit: vi.fn(), getUploadTmpDir: () => '/tmp', BACKUP_RATE_WINDOW: 3600000,
MAX_BACKUP_UPLOAD_SIZE: 1024,
},
}));
vi.mock('../../src/services/backupService', () => backupSvc);
import { BackupModule } from '../../src/nest/backup/backup.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Backup e2e (real auth + admin guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [BackupModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
seedUser(db as never, { id: 2, role: 'user', email: 'member@example.test' });
app = await build();
server = app.getHttpServer();
backupSvc.listBackups.mockReturnValue([{ filename: 'a.zip', size: 1 }]);
backupSvc.createBackup.mockResolvedValue({ filename: 'b.zip', size: 10 });
});
beforeEach(() => {
backupSvc.isValidBackupFilename.mockReturnValue(true);
backupSvc.backupFileExists.mockReturnValue(true);
backupSvc.checkRateLimit.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/backup/list')).status).toBe(401);
});
it('403 for a non-admin', async () => {
const res = await request(server).get('/api/backup/list').set('Cookie', sessionCookie(2));
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' });
});
it('200 list for an admin', async () => {
const res = await request(server).get('/api/backup/list').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ backups: [{ filename: 'a.zip', size: 1 }] });
});
it('429 when create is rate-limited', async () => {
backupSvc.checkRateLimit.mockReturnValue(false);
const res = await request(server).post('/api/backup/create').set('Cookie', sessionCookie(1));
expect(res.status).toBe(429);
expect(res.body).toEqual({ error: 'Too many backup requests. Please try again later.' });
});
it('400 on an invalid download filename', async () => {
backupSvc.isValidBackupFilename.mockReturnValue(false);
const res = await request(server).get('/api/backup/download/bad').set('Cookie', sessionCookie(1));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Invalid filename' });
});
it('404 deleting a missing backup', async () => {
backupSvc.backupFileExists.mockReturnValue(false);
const res = await request(server).delete('/api/backup/x.zip').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Backup not found' });
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints
* through the real JwtAuthGuard against a temp SQLite db. budgetService, the
* permission check and the WebSocket broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { svc } = vi.hoisted(() => ({
svc: {
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
},
}));
vi.mock('../../src/services/budgetService', () => svc);
import { BudgetModule } from '../../src/nest/budget/budget.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Budget e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
svc.listBudgetItems.mockReturnValue([{ id: 1, name: 'Hotel' }]);
svc.createBudgetItem.mockReturnValue({ id: 9, name: 'Hotel' });
});
beforeEach(() => {
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/trips/5/budget');
expect(res.status).toBe(401);
});
it('200 list for an accessible trip', async () => {
const res = await request(server).get('/api/trips/5/budget').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ items: [{ id: 1, name: 'Hotel' }] });
});
it('404 when the trip is not accessible', async () => {
svc.verifyTripAccess.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/budget').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 on create with permission', async () => {
const res = await request(server).post('/api/trips/5/budget').set('Cookie', sessionCookie(1)).send({ name: 'Hotel' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ item: { id: 9, name: 'Hotel' } });
});
it('403 on create without permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/budget').set('Cookie', sessionCookie(1)).send({ name: 'Hotel' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
it('400 on member update with a non-array user_ids', async () => {
const res = await request(server).put('/api/trips/5/budget/9/members').set('Cookie', sessionCookie(1)).send({ user_ids: 'no' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'user_ids must be an array' });
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Categories module e2e — exercises the migrated /api/categories endpoints
* through the real JwtAuthGuard + AdminGuard against a temp SQLite db seeded
* with an admin and a normal user. categoryService is mocked.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mocks } = vi.hoisted(() => ({
mocks: {
listCategories: vi.fn(),
createCategory: vi.fn(),
getCategoryById: vi.fn(),
updateCategory: vi.fn(),
deleteCategory: vi.fn(),
},
}));
vi.mock('../../src/services/categoryService', () => mocks);
import { CategoriesModule } from '../../src/nest/categories/categories.module';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const cat = { id: 1, name: 'Food', color: '#fff', icon: '🍔' };
describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CategoriesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' });
app = await build();
server = app.getHttpServer();
mocks.listCategories.mockReturnValue([cat]);
mocks.createCategory.mockReturnValue(cat);
mocks.getCategoryById.mockImplementation((id: string | number) => (String(id) === '1' ? cat : undefined));
mocks.updateCategory.mockReturnValue({ ...cat, name: 'Drinks' });
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/categories');
expect(res.status).toBe(401);
});
it('200 list for any authenticated user (non-admin allowed)', async () => {
const res = await request(server).get('/api/categories').set('Cookie', sessionCookie(2));
expect(res.status).toBe(200);
expect(res.body).toEqual({ categories: [cat] });
});
it('403 when a non-admin tries to create', async () => {
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(2)).send({ name: 'X' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin access required' });
expect(mocks.createCategory).not.toHaveBeenCalled();
});
it('201 when an admin creates a category', async () => {
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(1)).send({ name: 'Food', color: '#fff', icon: '🍔' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ category: cat });
expect(mocks.createCategory).toHaveBeenCalledWith(1, 'Food', '#fff', '🍔');
});
it('400 when an admin creates without a name', async () => {
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Category name is required' });
});
it('200 when an admin updates an existing category', async () => {
const res = await request(server).put('/api/categories/1').set('Cookie', sessionCookie(1)).send({ name: 'Drinks' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ category: { ...cat, name: 'Drinks' } });
});
it('404 when an admin updates a missing category', async () => {
const res = await request(server).put('/api/categories/9').set('Cookie', sessionCookie(1)).send({ name: 'X' });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Category not found' });
});
it('200 when an admin deletes an existing category', async () => {
const res = await request(server).delete('/api/categories/1').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(mocks.deleteCategory).toHaveBeenCalledWith('1');
});
});
+137
View File
@@ -0,0 +1,137 @@
/**
* Collab module e2e — exercises the migrated /api/trips/:tripId/collab endpoints
* through the real JwtAuthGuard against a temp SQLite db. The collab service,
* permission check, WebSocket broadcast and the chat/note notification are
* mocked; this focuses on auth, trip-access 404, permission 403, the create-201
* status codes and the vote/react 200 overrides.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
// The note/message notifications read the trip title fire-and-forget; the table
// must exist so that query doesn't throw after the test has torn down.
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
tmp.prepare("INSERT INTO trips (id, title) VALUES (5, 'Trip')").run();
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { svc } = vi.hoisted(() => ({
svc: {
verifyTripAccess: vi.fn(), listNotes: vi.fn(), createNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(),
addNoteFile: vi.fn(), getFormattedNoteById: vi.fn(), deleteNoteFile: vi.fn(),
listPolls: vi.fn(), createPoll: vi.fn(), votePoll: vi.fn(), closePoll: vi.fn(), deletePoll: vi.fn(),
listMessages: vi.fn(), createMessage: vi.fn(), deleteMessage: vi.fn(), addOrRemoveReaction: vi.fn(), fetchLinkPreview: vi.fn(),
},
}));
vi.mock('../../src/services/collabService', () => svc);
import { CollabModule } from '../../src/nest/collab/collab.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Collab e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
svc.listNotes.mockReturnValue([{ id: 1, title: 'N' }]);
svc.createNote.mockReturnValue({ id: 9, title: 'N' });
svc.createPoll.mockReturnValue({ id: 7 });
svc.votePoll.mockReturnValue({ poll: { id: 7 } });
svc.createMessage.mockReturnValue({ message: { id: 3, text: 'hi' } });
svc.addOrRemoveReaction.mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] });
svc.fetchLinkPreview.mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
});
beforeEach(() => {
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/trips/5/collab/notes')).status).toBe(401);
});
it('200 list notes for an accessible trip', async () => {
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ notes: [{ id: 1, title: 'N' }] });
});
it('404 when the trip is not accessible', async () => {
svc.verifyTripAccess.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 on note create with permission', async () => {
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ note: { id: 9, title: 'N' } });
});
it('403 on note create without permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
it('200 on poll vote (not 201)', async () => {
const res = await request(server).post('/api/trips/5/collab/polls/7/vote').set('Cookie', sessionCookie(1)).send({ option_index: 0 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ poll: { id: 7 } });
});
it('201 on message create', async () => {
const res = await request(server).post('/api/trips/5/collab/messages').set('Cookie', sessionCookie(1)).send({ text: 'hi' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ message: { id: 3, text: 'hi' } });
});
it('200 on react (not 201)', async () => {
const res = await request(server).post('/api/trips/5/collab/messages/3/react').set('Cookie', sessionCookie(1)).send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ reactions: [{ emoji: '👍', count: 1 }] });
});
it('400 on link-preview without a url', async () => {
const res = await request(server).get('/api/trips/5/collab/link-preview').set('Cookie', sessionCookie(1));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'URL is required' });
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Public config e2e — verifies /api/config is reachable WITHOUT authentication
* (it has no guard) and returns the server default language. No db needed.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { ConfigModule } from '../../src/nest/config/config.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { DEFAULT_LANGUAGE } from '../../src/config';
describe('Public config e2e (no auth guard)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [ConfigModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
});
it('200 with the default language and no cookie required', async () => {
const res = await request(server).get('/api/config');
expect(res.status).toBe(200);
expect(res.body).toEqual({ defaultLanguage: DEFAULT_LANGUAGE });
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Days + day-notes module e2e — exercises both migrated mounts through the real
* JwtAuthGuard against a temp SQLite db. The day/day-note services, the
* permission check, canAccessTrip and the WebSocket broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { day, note } = vi.hoisted(() => ({
day: { listDays: vi.fn(), createDay: vi.fn(), getDay: vi.fn(), updateDay: vi.fn(), deleteDay: vi.fn() },
note: {
verifyTripAccess: vi.fn(), listNotes: vi.fn(), dayExists: vi.fn(), createNote: vi.fn(),
getNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(),
},
}));
vi.mock('../../src/services/dayService', () => day);
vi.mock('../../src/services/dayNoteService', () => note);
import { DaysModule } from '../../src/nest/days/days.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Days + day-notes e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DaysModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
day.listDays.mockReturnValue({ days: [{ id: 1 }] });
day.createDay.mockReturnValue({ id: 9 });
note.listNotes.mockReturnValue([{ id: 1 }]);
note.dayExists.mockReturnValue(true);
note.createNote.mockReturnValue({ id: 7 });
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
note.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie', async () => {
expect((await request(server).get('/api/trips/5/days')).status).toBe(401);
});
it('200 list days (the { days } envelope)', async () => {
const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ days: [{ id: 1 }] });
});
it('201 create day, 404 trip when not accessible', async () => {
const ok = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ date: '2026-07-01' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ day: { id: 9 } });
canAccessTrip.mockReturnValue(undefined);
const miss = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
expect(miss.status).toBe(404);
expect(miss.body).toEqual({ error: 'Trip not found' });
});
it('201 create note, 400 on over-long text (before access)', async () => {
const ok = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: 'Lunch' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ note: { id: 7 } });
const long = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: 'x'.repeat(501) });
expect(long.status).toBe(400);
expect(long.body).toEqual({ error: 'text must be 500 characters or less' });
});
it('400 note without text', async () => {
const res = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: ' ' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Text required' });
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* Files + photos e2e — exercises the migrated /api/trips/:tripId/files and
* /api/photos endpoints through the real JwtAuthGuard against a temp SQLite db.
* The file/photo services, permission check and broadcast are mocked; this
* focuses on auth (incl. the unguarded download's own token auth), trip-access
* 404, permission 403, the photo id/access guards and status codes.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { fileSvc } = vi.hoisted(() => ({
fileSvc: {
MAX_FILE_SIZE: 50 * 1024 * 1024, BLOCKED_EXTENSIONS: ['.exe', '.svg'], filesDir: '/tmp/files', getAllowedExtensions: () => '*',
verifyTripAccess: vi.fn(), resolveFilePath: vi.fn(), authenticateDownload: vi.fn(),
listFiles: vi.fn(), getFileById: vi.fn(), getDeletedFile: vi.fn(), createFile: vi.fn(), updateFile: vi.fn(),
toggleStarred: vi.fn(), softDeleteFile: vi.fn(), restoreFile: vi.fn(), permanentDeleteFile: vi.fn(),
emptyTrash: vi.fn(), createFileLink: vi.fn(), deleteFileLink: vi.fn(), getFileLinks: vi.fn(), formatFile: vi.fn(),
},
}));
vi.mock('../../src/services/fileService', () => fileSvc);
const { photoSvc, helperSvc } = vi.hoisted(() => ({
photoSvc: { streamPhoto: vi.fn(), getPhotoInfo: vi.fn(), resolveTrekPhoto: vi.fn() },
helperSvc: { canAccessTrekPhoto: vi.fn() },
}));
vi.mock('../../src/services/memories/photoResolverService', () => photoSvc);
vi.mock('../../src/services/memories/helpersService', () => helperSvc);
import { FilesModule } from '../../src/nest/files/files.module';
import { PhotosModule } from '../../src/nest/photos/photos.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Files + photos e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [FilesModule, PhotosModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
fileSvc.listFiles.mockReturnValue([{ id: 1, original_name: 'a.pdf' }]);
fileSvc.getFileById.mockReturnValue({ id: 9, starred: 0 });
fileSvc.toggleStarred.mockReturnValue({ id: 9, starred: 1 });
});
beforeEach(() => {
fileSvc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
helperSvc.canAccessTrekPhoto.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 listing files without a session cookie', async () => {
expect((await request(server).get('/api/trips/5/files')).status).toBe(401);
});
it('200 list for an accessible trip', async () => {
const res = await request(server).get('/api/trips/5/files').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ files: [{ id: 1, original_name: 'a.pdf' }] });
});
it('404 when the trip is not accessible', async () => {
fileSvc.verifyTripAccess.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/files').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('200 toggling a star with permission', async () => {
const res = await request(server).patch('/api/trips/5/files/9/star').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ file: { id: 9, starred: 1 } });
});
it('403 deleting without file_delete permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).delete('/api/trips/5/files/9').set('Cookie', sessionCookie(1));
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission to delete files' });
});
it('download is unguarded but enforces its own token auth (401 without one)', async () => {
fileSvc.authenticateDownload.mockReturnValue({ error: 'Authentication required', status: 401 });
const res = await request(server).get('/api/trips/5/files/9/download');
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Authentication required' });
});
it('400 on a photo with a non-finite id', async () => {
const res = await request(server).get('/api/photos/abc/thumbnail').set('Cookie', sessionCookie(1));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Invalid photo ID' });
});
it('403 on a photo the user cannot access', async () => {
helperSvc.canAccessTrekPhoto.mockReturnValue(false);
const res = await request(server).get('/api/photos/5/original').set('Cookie', sessionCookie(1));
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Forbidden' });
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Journey e2e — exercises the migrated /api/journeys and /api/public/journey
* endpoints through the real JwtAuthGuard against a temp SQLite db. The journey
* services + addon gate are mocked; this focuses on the addon-gate-before-auth
* ordering (404 wins over 401), auth, the service-owned 403/404 mapping, status
* codes and the unguarded public route.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
vi.mock('../../src/services/fileService', () => ({ getAllowedExtensions: () => '*' }));
vi.mock('../../src/services/memories/immichService', () => ({ uploadToImmich: vi.fn(), streamImmichAsset: vi.fn() }));
vi.mock('../../src/services/memories/photoResolverService', () => ({ streamPhoto: vi.fn() }));
const { jsvc } = vi.hoisted(() => ({
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
}));
vi.mock('../../src/services/journeyService', () => jsvc);
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
vi.mock('../../src/services/journeyShareService', () => sharesvc);
import { JourneyModule } from '../../src/nest/journey/journey.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Journey e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
});
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('404 (addon gate wins over auth) when the Journey addon is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/journeys');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
});
it('401 with the addon enabled but no session cookie', async () => {
expect((await request(server).get('/api/journeys')).status).toBe(401);
});
it('200 list with a session', async () => {
const res = await request(server).get('/api/journeys').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ journeys: [{ id: 1, title: 'J' }] });
});
it('201 create, 400 without a title', async () => {
const ok = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({ title: 'J' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ id: 9, title: 'J' });
const bad = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({});
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Title is required' });
});
it('404 for an inaccessible journey', async () => {
jsvc.getJourneyFull.mockReturnValue(null);
const res = await request(server).get('/api/journeys/9').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('public journey read is unguarded (200 with a valid token, no cookie)', async () => {
const res = await request(server).get('/api/public/journey/tok');
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: 9 });
});
it('public journey 404 for an unknown token', async () => {
sharesvc.getPublicJourney.mockReturnValueOnce(null);
const res = await request(server).get('/api/public/journey/bad');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Not found' });
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Maps module e2e — exercises the migrated /api/maps endpoints through the real
* JwtAuthGuard against a temp SQLite db. mapsService is mocked (no outbound HTTP),
* and the temp db carries an empty app_settings table so the kill-switch reads
* resolve to "enabled".
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mocks } = vi.hoisted(() => ({
mocks: {
searchPlaces: vi.fn(),
autocompletePlaces: vi.fn(),
getPlaceDetails: vi.fn(),
getPlaceDetailsExpanded: vi.fn(),
getPlacePhoto: vi.fn(),
reverseGeocode: vi.fn(),
resolveGoogleMapsUrl: vi.fn(),
},
}));
vi.mock('../../src/services/mapsService', async (importActual) => {
const actual = await importActual<typeof import('../../src/services/mapsService')>();
return { ...actual, ...mocks };
});
import { MapsModule } from '../../src/nest/maps/maps.module';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Maps e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, MapsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
mocks.searchPlaces.mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' });
mocks.reverseGeocode.mockResolvedValue({ name: 'Spot', address: 'Street 1' });
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).post('/api/maps/search').send({ query: 'berlin' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
});
it('400 when authenticated but query is missing', async () => {
const res = await request(server).post('/api/maps/search').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Search query is required' });
});
it('200 with results for a search (POST stays 200, not 201)', async () => {
const res = await request(server).post('/api/maps/search').set('Cookie', sessionCookie(1)).send({ query: 'berlin' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ places: [{ name: 'Berlin' }], source: 'osm' });
});
it('200 on reverse geocode', async () => {
const res = await request(server).get('/api/maps/reverse').set('Cookie', sessionCookie(1)).query({ lat: '52.5', lng: '13.4' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ name: 'Spot', address: 'Street 1' });
});
it('400 on reverse geocode without coordinates', async () => {
const res = await request(server).get('/api/maps/reverse').set('Cookie', sessionCookie(1)).query({ lat: '52.5' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'lat and lng required' });
});
});
+411
View File
@@ -0,0 +1,411 @@
/**
* Memories (photo-providers) module e2e — exercises the migrated
* /api/integrations/memories endpoints (unified + immich + synologyphotos)
* through the real JwtAuthGuard against a temp SQLite db. The provider services
* and canAccessUserPhoto are mocked; fail/success stay real so the envelope
* shapes are produced by the actual helper code.
*
* Focus: auth (401), every route's happy path, the CRITICAL 200-on-failure
* behaviour of /test + /status, and at least one error envelope per provider
* router — all asserted byte-identical to the legacy Express routers.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
// Provider services — fully mocked. fail/success/canAccessUserPhoto from the
// helper module are kept real except canAccessUserPhoto which we override.
const { unified, immich, synology } = vi.hoisted(() => ({
unified: {
listTripPhotos: vi.fn(), addTripPhotos: vi.fn(), setTripPhotoSharing: vi.fn(),
removeTripPhoto: vi.fn(), listTripAlbumLinks: vi.fn(), createTripAlbumLink: vi.fn(), removeAlbumLink: vi.fn(),
},
immich: {
getConnectionSettings: vi.fn(), saveImmichSettings: vi.fn(), setImmichAutoUpload: vi.fn(),
testConnection: vi.fn(), getConnectionStatus: vi.fn(), browseTimeline: vi.fn(), searchPhotos: vi.fn(),
streamImmichAsset: vi.fn(), listAlbums: vi.fn(), getAlbumPhotos: vi.fn(), syncAlbumAssets: vi.fn(),
getAssetInfo: vi.fn(), isValidAssetId: vi.fn(),
},
synology: {
getSynologySettings: vi.fn(), updateSynologySettings: vi.fn(), getSynologyStatus: vi.fn(),
testSynologyConnection: vi.fn(), listSynologyAlbums: vi.fn(), getSynologyAlbumPhotos: vi.fn(),
syncSynologyAlbumLink: vi.fn(), searchSynologyPhotos: vi.fn(), getSynologyAssetInfo: vi.fn(),
streamSynologyAsset: vi.fn(),
},
}));
vi.mock('../../src/services/memories/unifiedService', () => unified);
vi.mock('../../src/services/memories/immichService', () => immich);
vi.mock('../../src/services/memories/synologyService', () => synology);
const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() }));
vi.mock('../../src/services/memories/helpersService', async () => {
const actual = await vi.importActual<typeof import('../../src/services/memories/helpersService')>(
'../../src/services/memories/helpersService',
);
return { ...actual, canAccessUserPhoto };
});
import { MemoriesModule } from '../../src/nest/memories/memories.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const BASE = '/api/integrations/memories';
const UNIFIED = `${BASE}/unified`;
const IMMICH = `${BASE}/immich`;
const SYNO = `${BASE}/synologyphotos`;
describe('Memories e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [MemoriesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
});
beforeEach(() => {
vi.clearAllMocks();
canAccessUserPhoto.mockReturnValue(true);
immich.isValidAssetId.mockReturnValue(true);
});
// ── Auth ───────────────────────────────────────────────────────────────────
describe('auth', () => {
it('401 without a cookie (unified photos)', async () => {
expect((await request(server).get(`${UNIFIED}/trips/5/photos`)).status).toBe(401);
});
it('401 without a cookie (immich status)', async () => {
expect((await request(server).get(`${IMMICH}/status`)).status).toBe(401);
});
it('401 without a cookie (synology albums)', async () => {
expect((await request(server).get(`${SYNO}/albums`)).status).toBe(401);
});
});
// ── Unified ──────────────────────────────────────────────────────────────────
describe('unified', () => {
it('200 list photos -> { photos }', async () => {
unified.listTripPhotos.mockReturnValue({ success: true, data: [{ photo_id: 1, asset_id: 'a' }] });
const res = await request(server).get(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ photos: [{ photo_id: 1, asset_id: 'a' }] });
});
it('200 add photos -> { success, added } (POST stays 200, not 201)', async () => {
unified.addTripPhotos.mockResolvedValue({ success: true, data: { added: 2, shared: true } });
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1))
.send({ shared: true, selections: [{ provider: 'immich', asset_ids: ['a', 'b'] }] });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, added: 2 });
// x-socket-id absent -> undefined, matching the legacy `req.headers['x-socket-id'] as string`.
expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 1, true, [{ provider: 'immich', asset_ids: ['a', 'b'] }], undefined);
});
it('400 add photos with empty selections -> error envelope', async () => {
unified.addTripPhotos.mockResolvedValue({ success: false, error: { message: 'No photos selected', status: 400 } });
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ selections: [] });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'No photos selected' });
});
it('200 PUT sharing -> { success: true }', async () => {
unified.setTripPhotoSharing.mockResolvedValue({ success: true, data: true });
const res = await request(server).put(`${UNIFIED}/trips/5/photos/sharing`).set('Cookie', sessionCookie(1)).send({ photo_id: 9, shared: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it('404 DELETE photo on inaccessible trip -> error envelope', async () => {
unified.removeTripPhoto.mockReturnValue({ success: false, error: { message: 'Trip not found or access denied', status: 404 } });
const res = await request(server).delete(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ photo_id: 9 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found or access denied' });
});
it('200 list album-links -> { links }', async () => {
unified.listTripAlbumLinks.mockReturnValue({ success: true, data: [{ id: 'l1' }] });
const res = await request(server).get(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ links: [{ id: 'l1' }] });
});
it('200 create album-link / 409 duplicate envelope', async () => {
unified.createTripAlbumLink.mockReturnValue({ success: true, data: true });
const ok = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ success: true });
unified.createTripAlbumLink.mockReturnValue({ success: false, error: { message: 'Album already linked', status: 409 } });
const dup = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
expect(dup.status).toBe(409);
expect(dup.body).toEqual({ error: 'Album already linked' });
});
it('200 DELETE album-link -> { success: true }', async () => {
unified.removeAlbumLink.mockReturnValue({ success: true, data: true });
const res = await request(server).delete(`${UNIFIED}/trips/5/album-links/7`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
});
// ── Immich ───────────────────────────────────────────────────────────────────
describe('immich', () => {
it('200 settings', async () => {
immich.getConnectionSettings.mockReturnValue({ immich_url: '', connected: false, auto_upload: false });
const res = await request(server).get(`${IMMICH}/settings`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ immich_url: '', connected: false, auto_upload: false });
});
it('200 PUT settings success / 400 invalid url', async () => {
immich.saveImmichSettings.mockResolvedValue({ success: true });
const ok = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', auto_upload: true });
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ success: true });
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(1, true);
immich.saveImmichSettings.mockResolvedValue({ success: false, error: 'Invalid Immich URL: bad' });
const bad = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'bad' });
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' });
});
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' });
const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'Not configured' });
});
it('CRITICAL: 200 /test missing fields -> { connected: false, error } without calling service', async () => {
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'URL and API key required' });
expect(immich.testConnection).not.toHaveBeenCalled();
});
it('200 /test with creds delegates to service', async () => {
immich.testConnection.mockResolvedValue({ connected: true, user: { name: 'T' } });
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: true, user: { name: 'T' } });
});
it('200 browse / 400 not configured', async () => {
immich.browseTimeline.mockResolvedValue({ buckets: [{ count: 3 }] });
const ok = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ buckets: [{ count: 3 }] });
immich.browseTimeline.mockResolvedValue({ error: 'Immich not configured', status: 400 });
const bad = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Immich not configured' });
});
it('200 search (POST stays 200) / 502 envelope', async () => {
immich.searchPhotos.mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true });
const ok = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({ page: 1, size: 50 });
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ assets: [{ id: 'a' }], hasMore: true });
expect(immich.searchPhotos).toHaveBeenCalledWith(1, undefined, undefined, 1, 50);
immich.searchPhotos.mockResolvedValue({ error: 'Could not reach Immich', status: 502 });
const bad = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({});
expect(bad.status).toBe(502);
expect(bad.body).toEqual({ error: 'Could not reach Immich' });
});
it('200 asset info / 400 invalid id / 403 no access', async () => {
immich.getAssetInfo.mockResolvedValue({ data: { id: 'asset-1', city: 'Paris' } });
const ok = await request(server).get(`${IMMICH}/assets/5/asset-1/1/info`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ id: 'asset-1', city: 'Paris' });
immich.isValidAssetId.mockReturnValue(false);
const invalid = await request(server).get(`${IMMICH}/assets/5/bad/1/info`).set('Cookie', sessionCookie(1));
expect(invalid.status).toBe(400);
expect(invalid.body).toEqual({ error: 'Invalid asset ID' });
immich.isValidAssetId.mockReturnValue(true);
canAccessUserPhoto.mockReturnValue(false);
const forbidden = await request(server).get(`${IMMICH}/assets/5/asset-1/2/info`).set('Cookie', sessionCookie(1));
expect(forbidden.status).toBe(403);
expect(forbidden.body).toEqual({ error: 'Forbidden' });
});
it('streams thumbnail bytes via the service helper', async () => {
immich.streamImmichAsset.mockImplementation(async (res: any) => {
res.status(200);
res.set('Content-Type', 'image/webp');
res.end(Buffer.from('thumb-bytes'));
});
const res = await request(server).get(`${IMMICH}/assets/5/asset-1/1/thumbnail`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/webp');
expect(immich.streamImmichAsset).toHaveBeenCalledWith(expect.anything(), 1, 'asset-1', 'thumbnail', 1);
});
it('200 albums / 200 album photos', async () => {
immich.listAlbums.mockResolvedValue({ albums: [{ id: 'al' }] });
const albums = await request(server).get(`${IMMICH}/albums`).set('Cookie', sessionCookie(1));
expect(albums.status).toBe(200);
expect(albums.body).toEqual({ albums: [{ id: 'al' }] });
immich.getAlbumPhotos.mockResolvedValue({ assets: [{ id: 'p1' }] });
const photos = await request(server).get(`${IMMICH}/albums/al/photos`).set('Cookie', sessionCookie(1));
expect(photos.status).toBe(200);
expect(photos.body).toEqual({ assets: [{ id: 'p1' }] });
});
it('200 album sync (POST stays 200) / 404 envelope', async () => {
immich.syncAlbumAssets.mockResolvedValue({ success: true, added: 3, total: 10 });
const ok = await request(server).post(`${IMMICH}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ success: true, added: 3, total: 10 });
immich.syncAlbumAssets.mockResolvedValue({ error: 'Album link not found', status: 404 });
const bad = await request(server).post(`${IMMICH}/trips/5/album-links/9/sync`).set('Cookie', sessionCookie(1));
expect(bad.status).toBe(404);
expect(bad.body).toEqual({ error: 'Album link not found' });
});
});
// ── Synology ───────────────────────────────────────────────────────────────
describe('synologyphotos', () => {
it('200 settings', async () => {
synology.getSynologySettings.mockResolvedValue({ success: true, data: { synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true } });
const res = await request(server).get(`${SYNO}/settings`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true });
});
it('400 PUT settings without url/username -> envelope', async () => {
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: '' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'URL and username are required' });
expect(synology.updateSynologySettings).not.toHaveBeenCalled();
});
it('200 PUT settings delegates when valid', async () => {
synology.updateSynologySettings.mockResolvedValue({ success: true, data: 'settings updated' });
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
expect(res.status).toBe(200);
expect(res.body).toEqual('settings updated');
});
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
synology.getSynologyStatus.mockResolvedValue({ success: true, data: { connected: false, error: 'Synology not configured' } });
const res = await request(server).get(`${SYNO}/status`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'Synology not configured' });
});
it('CRITICAL: 200 /test missing fields -> 200 { connected: false, error } without calling service', async () => {
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: false, error: 'Username, Password are required' });
expect(synology.testSynologyConnection).not.toHaveBeenCalled();
});
it('200 /test delegates when all fields present', async () => {
synology.testSynologyConnection.mockResolvedValue({ success: true, data: { connected: true, user: { name: 'admin' } } });
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ connected: true, user: { name: 'admin' } });
});
it('200 albums / 200 album photos with passphrase', async () => {
synology.listSynologyAlbums.mockResolvedValue({ success: true, data: { albums: [{ id: '1', albumName: 'A', assetCount: 3 }] } });
const albums = await request(server).get(`${SYNO}/albums`).set('Cookie', sessionCookie(1));
expect(albums.status).toBe(200);
expect(albums.body).toEqual({ albums: [{ id: '1', albumName: 'A', assetCount: 3 }] });
synology.getSynologyAlbumPhotos.mockResolvedValue({ success: true, data: { assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false } });
const photos = await request(server).get(`${SYNO}/albums/1/photos?passphrase=secret`).set('Cookie', sessionCookie(1));
expect(photos.status).toBe(200);
expect(photos.body).toEqual({ assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false });
expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(1, '1', 'secret');
});
it('200 search (POST stays 200) with offset/limit coercion', async () => {
synology.searchSynologyPhotos.mockResolvedValue({ success: true, data: { assets: [], total: 0, hasMore: false } });
const res = await request(server).post(`${SYNO}/search`).set('Cookie', sessionCookie(1)).send({ page: 3, size: 20 });
expect(res.status).toBe(200);
expect(res.body).toEqual({ assets: [], total: 0, hasMore: false });
// page=3 -> (3-1)=2; size=20 -> limit=20; offset = 2 * 20 = 40
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(1, undefined, undefined, 40, 20);
});
it('200 album sync (POST stays 200)', async () => {
synology.syncSynologyAlbumLink.mockResolvedValue({ success: true, data: { added: 2, total: 5 } });
const res = await request(server).post(`${SYNO}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ added: 2, total: 5 });
});
it('200 asset info / 403 distinct synology string on no access', async () => {
synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: '40808_1', takenAt: null } });
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/info`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ id: '40808_1', takenAt: null });
canAccessUserPhoto.mockReturnValue(false);
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/info`).set('Cookie', sessionCookie(1));
expect(forbidden.status).toBe(403);
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
});
it('400 invalid asset kind / 403 no access / stream on valid kind', async () => {
const invalid = await request(server).get(`${SYNO}/assets/5/40808_1/1/bogus`).set('Cookie', sessionCookie(1));
expect(invalid.status).toBe(400);
expect(invalid.body).toEqual({ error: 'Invalid asset kind' });
canAccessUserPhoto.mockReturnValue(false);
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/thumbnail`).set('Cookie', sessionCookie(1));
expect(forbidden.status).toBe(403);
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
canAccessUserPhoto.mockReturnValue(true);
synology.streamSynologyAsset.mockImplementation(async (res: any) => {
res.status(200);
res.set('Content-Type', 'image/jpeg');
res.end(Buffer.from('syno-bytes'));
});
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/thumbnail?size=xl`).set('Cookie', sessionCookie(1));
expect(ok.status).toBe(200);
expect(ok.headers['content-type']).toContain('image/jpeg');
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(expect.anything(), 1, 1, '40808_1', 'thumbnail', 'xl', undefined);
});
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Notifications module e2e — exercises the migrated /api/notifications endpoints
* through the real JwtAuthGuard against a temp SQLite db. The notification
* services are mocked; this focuses on auth, the inline admin gate on
* /test-smtp, routing (the /in-app/all ordering trap) and status/body shapes.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { prefs, inapp, channels } = vi.hoisted(() => ({
prefs: { getPreferencesMatrix: vi.fn(), setPreferences: vi.fn() },
inapp: {
getNotifications: vi.fn(), getUnreadCount: vi.fn(), markRead: vi.fn(), markUnread: vi.fn(),
markAllRead: vi.fn(), deleteNotification: vi.fn(), deleteAll: vi.fn(), respondToBoolean: vi.fn(),
},
channels: {
testSmtp: vi.fn(), testWebhook: vi.fn(), testNtfy: vi.fn(),
getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn(),
getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn(),
},
}));
vi.mock('../../src/services/notificationPreferencesService', () => prefs);
vi.mock('../../src/services/inAppNotifications', () => inapp);
vi.mock('../../src/services/notifications', () => channels);
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Notifications e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [NotificationsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' });
app = await build();
server = app.getHttpServer();
prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} });
inapp.getUnreadCount.mockReturnValue(2);
inapp.deleteAll.mockReturnValue(4);
channels.testSmtp.mockResolvedValue({ success: true });
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/notifications/preferences');
expect(res.status).toBe(401);
});
it('200 preferences for an authenticated user', async () => {
const res = await request(server).get('/api/notifications/preferences').set('Cookie', sessionCookie(2));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ preferences: {} });
});
it('403 { error: Admin only } when a non-admin hits test-smtp', async () => {
const res = await request(server).post('/api/notifications/test-smtp').set('Cookie', sessionCookie(2)).send({});
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Admin only' });
expect(channels.testSmtp).not.toHaveBeenCalled();
});
it('200 test-smtp for an admin (stays 200, not 201)', async () => {
const res = await request(server).post('/api/notifications/test-smtp').set('Cookie', sessionCookie(1)).send({ email: 'x@y.z' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it('200 unread-count', async () => {
const res = await request(server).get('/api/notifications/in-app/unread-count').set('Cookie', sessionCookie(2));
expect(res.status).toBe(200);
expect(res.body).toEqual({ count: 2 });
});
it('DELETE /in-app/all hits deleteAll, not deleteNotification', async () => {
const res = await request(server).delete('/api/notifications/in-app/all').set('Cookie', sessionCookie(2));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, count: 4 });
expect(inapp.deleteAll).toHaveBeenCalledWith(2);
expect(inapp.deleteNotification).not.toHaveBeenCalled();
});
it('400 on a non-numeric in-app id', async () => {
const res = await request(server).put('/api/notifications/in-app/abc/read').set('Cookie', sessionCookie(2));
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Invalid id' });
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* OAuth e2e — exercises the migrated /oauth/* and /api/oauth/* endpoints through
* the real JwtAuthGuard / CookieAuthGuard / OptionalJwtGuard against a temp
* SQLite db. The OAuth service + addon gate are mocked; this focuses on the
* public token/userinfo guards, the MCP 404/403 gates, and the cookie-only auth
* on the management endpoints (a Bearer must NOT satisfy them).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie, signSession } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logWarn: vi.fn() }));
vi.mock('../../src/services/notifications', () => ({ getMcpSafeUrl: () => 'https://app' }));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
const { oauthSvc } = vi.hoisted(() => ({
oauthSvc: {
validateAuthorizeRequest: vi.fn(), createAuthCode: vi.fn(), consumeAuthCode: vi.fn(), saveConsent: vi.fn(),
issueTokens: vi.fn(), issueClientCredentialsToken: vi.fn(), refreshTokens: vi.fn(), revokeToken: vi.fn(),
verifyPKCE: vi.fn(), authenticateClient: vi.fn(), listOAuthClients: vi.fn(), createOAuthClient: vi.fn(),
deleteOAuthClient: vi.fn(), rotateOAuthClientSecret: vi.fn(), listOAuthSessions: vi.fn(), revokeSession: vi.fn(),
getUserByAccessToken: vi.fn(),
},
}));
vi.mock('../../src/services/oauthService', () => oauthSvc);
import { OauthModule } from '../../src/nest/oauth/oauth.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('OAuth e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [OauthModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
oauthSvc.listOAuthClients.mockReturnValue([{ id: 'c1' }]);
});
beforeEach(() => { isAddonEnabled.mockReturnValue(true); });
afterAll(async () => {
await app.close();
});
it('POST /oauth/token is public — 401 invalid_client without client_id', async () => {
const res = await request(server).post('/oauth/token').send({});
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
expect(res.headers['cache-control']).toBe('no-store');
});
it('POST /oauth/token 404 (empty) when MCP is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).post('/oauth/token').send({ client_id: 'c' });
expect(res.status).toBe(404);
expect(res.text).toBe('');
});
it('GET /oauth/userinfo 401 with a WWW-Authenticate challenge', async () => {
const res = await request(server).get('/oauth/userinfo');
expect(res.status).toBe(401);
expect(res.headers['www-authenticate']).toContain('Bearer');
});
it('GET /api/oauth/clients 401 without a session', async () => {
expect((await request(server).get('/api/oauth/clients')).status).toBe(401);
});
it('GET /api/oauth/clients works with a Bearer (authenticate) session', async () => {
const res = await request(server).get('/api/oauth/clients').set('Authorization', `Bearer ${signSession(1)}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({ clients: [{ id: 'c1' }] });
});
it('POST /api/oauth/clients requires a COOKIE session (a Bearer is rejected)', async () => {
const bearer = await request(server).post('/api/oauth/clients').set('Authorization', `Bearer ${signSession(1)}`).send({ name: 'CLI', allowed_scopes: ['a'] });
expect(bearer.status).toBe(401);
expect(bearer.body).toEqual({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
oauthSvc.createOAuthClient.mockReturnValue({ client_id: 'c1', client_secret: 's' });
const cookie = await request(server).post('/api/oauth/clients').set('Cookie', sessionCookie(1)).send({ name: 'CLI', allowed_scopes: ['a'] });
expect(cookie.status).toBe(201);
expect(cookie.body).toEqual({ client_id: 'c1', client_secret: 's' });
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* OIDC e2e — exercises the migrated /api/auth/oidc flow with the real cookie
* service. The OIDC service + auth toggles are mocked; this proves the flow is
* unauthenticated, the sso-disabled 403, the login redirect, and that /exchange
* sets the httpOnly trek_session cookie from a valid auth code.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://app' }));
const { toggles } = vi.hoisted(() => ({ toggles: { oidc_login: true } }));
vi.mock('../../src/services/authService', () => ({ resolveAuthToggles: () => toggles }));
const { oidcSvc } = vi.hoisted(() => ({
oidcSvc: {
getOidcConfig: vi.fn(), discover: vi.fn(), createState: vi.fn(), consumeState: vi.fn(), createAuthCode: vi.fn(),
consumeAuthCode: vi.fn(), exchangeCodeForToken: vi.fn(), getUserInfo: vi.fn(), verifyIdToken: vi.fn(),
findOrCreateUser: vi.fn(), touchLastLogin: vi.fn(), generateToken: vi.fn(), frontendUrl: (p: string) => 'https://app' + p,
},
}));
vi.mock('../../src/services/oidcService', () => oidcSvc);
import { OidcModule } from '../../src/nest/oidc/oidc.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('OIDC e2e (real cookie service)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [OidcModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
app = await build();
server = app.getHttpServer();
oidcSvc.getOidcConfig.mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null });
oidcSvc.discover.mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' });
oidcSvc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' });
oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
});
beforeEach(() => { toggles.oidc_login = true; });
afterAll(async () => {
await app.close();
});
it('GET /login is unauthenticated and redirects (302) to the provider', async () => {
const res = await request(server).get('/api/auth/oidc/login').redirects(0);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('https://idp/auth?');
});
it('GET /login returns 403 when SSO is disabled', async () => {
toggles.oidc_login = false;
const res = await request(server).get('/api/auth/oidc/login');
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'SSO login is disabled.' });
});
it('GET /exchange 400 without a code', async () => {
const res = await request(server).get('/api/auth/oidc/exchange');
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Code required' });
});
it('GET /exchange sets the httpOnly trek_session cookie + returns the token', async () => {
oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
const res = await request(server).get('/api/auth/oidc/exchange').query({ code: 'good' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ token: 'jwt.value' });
const setCookie = res.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Packing module e2e — exercises the migrated /api/trips/:tripId/packing
* endpoints through the real JwtAuthGuard against a temp SQLite db. The packing
* service, permission check and WebSocket broadcast are mocked; this focuses on
* auth, trip-access 404, permission 403, status codes and bodies.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { svc } = vi.hoisted(() => ({
svc: {
verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(),
deleteItem: vi.fn(), bulkImport: vi.fn(), reorderItems: 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(),
},
}));
vi.mock('../../src/services/packingService', () => svc);
import { PackingModule } from '../../src/nest/packing/packing.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Packing e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
svc.listItems.mockReturnValue([{ id: 1, name: 'Socks' }]);
svc.createItem.mockReturnValue({ id: 9, name: 'Socks' });
});
beforeEach(() => {
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/trips/5/packing');
expect(res.status).toBe(401);
});
it('200 list for an accessible trip', async () => {
const res = await request(server).get('/api/trips/5/packing').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ items: [{ id: 1, name: 'Socks' }] });
});
it('404 when the trip is not accessible', async () => {
svc.verifyTripAccess.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/packing').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 on create with permission', async () => {
const res = await request(server).post('/api/trips/5/packing').set('Cookie', sessionCookie(1)).send({ name: 'Socks' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ item: { id: 9, name: 'Socks' } });
});
it('403 on create without permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/packing').set('Cookie', sessionCookie(1)).send({ name: 'Socks' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Places module e2e — exercises the migrated /api/trips/:tripId/places endpoints
* through the real JwtAuthGuard against a temp SQLite db. placeService,
* journeyService, the permission check, canAccessTrip and the WebSocket
* broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { pl } = vi.hoisted(() => ({
pl: {
listPlaces: vi.fn(), createPlace: vi.fn(), getPlace: vi.fn(), updatePlace: vi.fn(), deletePlace: vi.fn(),
deletePlacesMany: vi.fn(), importGpx: vi.fn(), importMapFile: vi.fn(), importGoogleList: vi.fn(),
importNaverList: vi.fn(), searchPlaceImage: vi.fn(),
},
}));
vi.mock('../../src/services/placeService', () => pl);
import { PlacesModule } from '../../src/nest/places/places.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Places e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [PlacesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
pl.listPlaces.mockReturnValue([{ id: 1, name: 'Spot' }]);
pl.createPlace.mockReturnValue({ id: 9, name: 'Spot' });
pl.deletePlacesMany.mockReturnValue([1, 2]);
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie', async () => {
expect((await request(server).get('/api/trips/5/places')).status).toBe(401);
});
it('200 list', async () => {
const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ places: [{ id: 1, name: 'Spot' }] });
});
it('201 create, 403 without permission, 400 over-long name', async () => {
const ok = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'Spot' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ place: { id: 9, name: 'Spot' } });
const long = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'x'.repeat(201) });
expect(long.status).toBe(400);
expect(long.body).toEqual({ error: 'name must be 200 characters or less' });
checkPermission.mockReturnValue(false);
const forbidden = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'Spot' });
expect(forbidden.status).toBe(403);
});
it('200 (not 201) bulk-delete, 400 on bad ids', async () => {
const ok = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: [1, 2] });
expect(ok.status).toBe(200);
expect(ok.body).toEqual({ deleted: [1, 2], count: 2 });
const bad = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: ['a'] });
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'ids must be an array of numbers' });
});
it('404 trip when not accessible', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* Reservations + accommodations module e2e — exercises both migrated mounts
* through the real JwtAuthGuard against a temp SQLite db. The reservation/day/
* budget services, the permission check, canAccessTrip and the WebSocket
* broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
return { db: tmp };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { resv, budget, day } = vi.hoisted(() => ({
resv: {
verifyTripAccess: vi.fn(), listReservations: vi.fn(), createReservation: vi.fn(), updatePositions: vi.fn(),
getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(), getUpcomingReservations: vi.fn(),
},
budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
day: {
listAccommodations: vi.fn(), validateAccommodationRefs: vi.fn(), createAccommodation: vi.fn(),
getAccommodation: vi.fn(), updateAccommodation: vi.fn(), deleteAccommodation: vi.fn(),
},
}));
vi.mock('../../src/services/reservationService', () => resv);
vi.mock('../../src/services/budgetService', () => budget);
vi.mock('../../src/services/dayService', () => day);
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [ReservationsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
resv.listReservations.mockReturnValue([{ id: 1, title: 'Hotel' }]);
resv.createReservation.mockReturnValue({ reservation: { id: 9, title: 'Hotel' }, accommodationCreated: false });
day.listAccommodations.mockReturnValue([{ id: 1 }]);
day.validateAccommodationRefs.mockReturnValue([]);
day.createAccommodation.mockReturnValue({ id: 9 });
resv.getUpcomingReservations.mockReturnValue([{ id: 1, trip_id: 5, title: 'Flight' }]);
});
beforeEach(() => {
resv.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie (reservations)', async () => {
expect((await request(server).get('/api/trips/5/reservations')).status).toBe(401);
});
it('200 list reservations', async () => {
const res = await request(server).get('/api/trips/5/reservations').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ reservations: [{ id: 1, title: 'Hotel' }] });
});
it('401 without a cookie (upcoming feed)', async () => {
expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401);
});
it('200 cross-trip upcoming reservations feed', async () => {
const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ reservations: [{ id: 1, trip_id: 5, title: 'Flight' }] });
});
it('404 when trip not accessible (reservations)', async () => {
resv.verifyTripAccess.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/reservations').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 create reservation, 400 without title', async () => {
const ok = await request(server).post('/api/trips/5/reservations').set('Cookie', sessionCookie(1)).send({ title: 'Hotel' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ reservation: { id: 9, title: 'Hotel' } });
const bad = await request(server).post('/api/trips/5/reservations').set('Cookie', sessionCookie(1)).send({});
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Title is required' });
});
it('200 list accommodations + 201 create', async () => {
const list = await request(server).get('/api/trips/5/accommodations').set('Cookie', sessionCookie(1));
expect(list.status).toBe(200);
expect(list.body).toEqual({ accommodations: [{ id: 1 }] });
const create = await request(server).post('/api/trips/5/accommodations').set('Cookie', sessionCookie(1)).send({ place_id: 2, start_day_id: 10, end_day_id: 11 });
expect(create.status).toBe(201);
expect(create.body).toEqual({ accommodation: { id: 9 } });
});
it('404 when trip not accessible (accommodations)', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/accommodations').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('400 accommodation create without refs', async () => {
const res = await request(server).post('/api/trips/5/accommodations').set('Cookie', sessionCookie(1)).send({ place_id: 2 });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'place_id, start_day_id, and end_day_id are required' });
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* Settings e2e — exercises the migrated /api/settings endpoints through the real
* JwtAuthGuard against a temp SQLite db. The settings service is mocked; this
* focuses on auth, the 400 guards, the masked-sentinel no-op and status codes.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { settingsSvc } = vi.hoisted(() => ({
settingsSvc: { getUserSettings: vi.fn(), upsertSetting: vi.fn(), bulkUpsertSettings: vi.fn() },
}));
vi.mock('../../src/services/settingsService', () => settingsSvc);
import { SettingsModule } from '../../src/nest/settings/settings.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Settings e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [SettingsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
settingsSvc.getUserSettings.mockReturnValue({ theme: 'dark' });
settingsSvc.bulkUpsertSettings.mockReturnValue(2);
});
beforeEach(() => vi.clearAllMocks());
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/settings')).status).toBe(401);
});
it('200 list with a session', async () => {
settingsSvc.getUserSettings.mockReturnValue({ theme: 'dark' });
const res = await request(server).get('/api/settings').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ settings: { theme: 'dark' } });
});
it('PUT 400 without a key', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ value: 'x' });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Key is required' });
});
it('PUT no-ops on the masked sentinel', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'secret', value: '••••••••' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, key: 'secret', unchanged: true });
expect(settingsSvc.upsertSetting).not.toHaveBeenCalled();
});
it('POST /bulk 200', async () => {
settingsSvc.bulkUpsertSettings.mockReturnValue(2);
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)).send({ settings: { a: 1, b: 2 } });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, updated: 2 });
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Share-link e2e — exercises the migrated /api/trips/:tripId/share-link and the
* public /api/shared/:token endpoints through the real JwtAuthGuard against a
* temp SQLite db. The share service + permission check are mocked; this focuses
* on auth, trip-access 404, permission 403, the create-201-vs-update-200 split
* and the unguarded public read.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db, canAccessTrip } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp, canAccessTrip: vi.fn() };
});
vi.mock('../../src/db/database', () => ({ db, canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { shareSvc } = vi.hoisted(() => ({
shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn() },
}));
vi.mock('../../src/services/shareService', () => shareSvc);
import { ShareModule } from '../../src/nest/share/share.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Share-link e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [ShareModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
shareSvc.getSharedTripData.mockReturnValue({ trip: { id: 9 } });
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/trips/5/share-link')).status).toBe(401);
});
it('201 on first create, 200 on a subsequent update', async () => {
shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: true });
const created = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({ share_map: true });
expect(created.status).toBe(201);
expect(created.body).toEqual({ token: 't' });
shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: false });
const updated = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({});
expect(updated.status).toBe(200);
expect(updated.body).toEqual({ token: 't' });
});
it('403 without share_manage', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
it('404 when the trip is not accessible', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/share-link').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('public shared read is unguarded (200, no cookie)', async () => {
const res = await request(server).get('/api/shared/tok');
expect(res.status).toBe(200);
expect(res.body).toEqual({ trip: { id: 9 } });
});
it('public shared read 404 for an invalid token', async () => {
shareSvc.getSharedTripData.mockReturnValueOnce(null);
const res = await request(server).get('/api/shared/bad');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Invalid or expired link' });
});
});
@@ -0,0 +1,92 @@
/**
* System-notices module e2e — exercises the migrated /api/system-notices
* endpoints through the real JwtAuthGuard against a temp SQLite db. The notices
* service is mocked so the test doesn't depend on the static registry or the
* dismissal tables; it focuses on routing, auth, status codes and bodies.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mockGetActive, mockDismiss } = vi.hoisted(() => ({ mockGetActive: vi.fn(), mockDismiss: vi.fn() }));
vi.mock('../../src/systemNotices/service', () => ({
getActiveNoticesFor: mockGetActive,
dismissNotice: mockDismiss,
}));
import { SystemNoticesModule } from '../../src/nest/system-notices/system-notices.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const notice = {
id: 'welcome', display: 'modal', severity: 'info',
titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true,
};
describe('System-notices e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [SystemNoticesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/system-notices/active');
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
});
it('200 with the active notices for the user', async () => {
mockGetActive.mockReturnValue([notice]);
const res = await request(server).get('/api/system-notices/active').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual([notice]);
expect(mockGetActive).toHaveBeenCalledWith(1);
});
it('204 with no body on a successful dismiss', async () => {
mockDismiss.mockReturnValue(true);
const res = await request(server).post('/api/system-notices/welcome/dismiss').set('Cookie', sessionCookie(1));
expect(res.status).toBe(204);
expect(res.body).toEqual({});
expect(res.text).toBe('');
expect(mockDismiss).toHaveBeenCalledWith(1, 'welcome');
});
it('404 { error: NOTICE_NOT_FOUND } when the id is unknown', async () => {
mockDismiss.mockReturnValue(false);
const res = await request(server).post('/api/system-notices/nope/dismiss').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'NOTICE_NOT_FOUND' });
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Tags module e2e — exercises the migrated /api/tags endpoints through the real
* JwtAuthGuard against a temp SQLite db. tagService is mocked; tags are
* user-scoped (no admin gate), so a normal authenticated user can do everything.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { mocks } = vi.hoisted(() => ({
mocks: {
listTags: vi.fn(),
createTag: vi.fn(),
getTagByIdAndUser: vi.fn(),
updateTag: vi.fn(),
deleteTag: vi.fn(),
},
}));
vi.mock('../../src/services/tagService', () => mocks);
import { TagsModule } from '../../src/nest/tags/tags.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const tag = { id: 1, user_id: 1, name: 'Beach', color: '#10b981' };
describe('Tags e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [TagsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
mocks.listTags.mockReturnValue([tag]);
mocks.createTag.mockReturnValue(tag);
mocks.getTagByIdAndUser.mockImplementation((id: string | number) => (String(id) === '1' ? tag : undefined));
mocks.updateTag.mockReturnValue({ ...tag, name: 'Hike' });
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/tags');
expect(res.status).toBe(401);
});
it('200 list scoped to the user', async () => {
const res = await request(server).get('/api/tags').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ tags: [tag] });
expect(mocks.listTags).toHaveBeenCalledWith(1);
});
it('201 on create', async () => {
const res = await request(server).post('/api/tags').set('Cookie', sessionCookie(1)).send({ name: 'Beach', color: '#10b981' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ tag });
expect(mocks.createTag).toHaveBeenCalledWith(1, 'Beach', '#10b981');
});
it('400 on create without a name', async () => {
const res = await request(server).post('/api/tags').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'Tag name is required' });
});
it('200 on update of an owned tag', async () => {
const res = await request(server).put('/api/tags/1').set('Cookie', sessionCookie(1)).send({ name: 'Hike' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ tag: { ...tag, name: 'Hike' } });
});
it('404 on update of a tag the user does not own', async () => {
const res = await request(server).put('/api/tags/9').set('Cookie', sessionCookie(1)).send({ name: 'X' });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Tag not found' });
});
it('200 on delete of an owned tag', async () => {
const res = await request(server).delete('/api/tags/1').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(mocks.deleteTag).toHaveBeenCalledWith('1');
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* To-do module e2e — exercises the migrated /api/trips/:tripId/todo endpoints
* through the real JwtAuthGuard against a temp SQLite db. todoService, the
* permission check and the WebSocket broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { svc } = vi.hoisted(() => ({
svc: {
verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(),
deleteItem: vi.fn(), reorderItems: vi.fn(), getCategoryAssignees: vi.fn(), updateCategoryAssignees: vi.fn(),
},
}));
vi.mock('../../src/services/todoService', () => svc);
import { TodoModule } from '../../src/nest/todo/todo.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('To-do e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [TodoModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
svc.listItems.mockReturnValue([{ id: 1, name: 'Book hotel' }]);
svc.createItem.mockReturnValue({ id: 9, name: 'Book hotel' });
});
beforeEach(() => {
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/trips/5/todo');
expect(res.status).toBe(401);
});
it('200 list for an accessible trip', async () => {
const res = await request(server).get('/api/trips/5/todo').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ items: [{ id: 1, name: 'Book hotel' }] });
});
it('404 when the trip is not accessible', async () => {
svc.verifyTripAccess.mockReturnValue(undefined);
const res = await request(server).get('/api/trips/5/todo').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 on create with permission', async () => {
const res = await request(server).post('/api/trips/5/todo').set('Cookie', sessionCookie(1)).send({ name: 'Book hotel' });
expect(res.status).toBe(201);
expect(res.body).toEqual({ item: { id: 9, name: 'Book hotel' } });
});
it('403 on create without permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server).post('/api/trips/5/todo').set('Cookie', sessionCookie(1)).send({ name: 'Book hotel' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Trips module e2e — exercises the migrated /api/trips aggregate-root endpoints
* through the real JwtAuthGuard against a temp SQLite db. tripService, the bundle
* list-services, auditLog, demo, the permission check, canAccessTrip and the
* WebSocket broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
return { db: tmp };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
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) }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
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(), addMember: vi.fn(), removeMember: vi.fn(), exportICS: vi.fn(), copyTripById: vi.fn(),
verifyTripAccess: vi.fn(), NotFoundError: class NotFoundError extends Error {}, ValidationError: class ValidationError extends Error {}, TRIP_SELECT: 'SELECT',
},
}));
vi.mock('../../src/services/tripService', () => tripSvc);
vi.mock('../../src/services/dayService', () => ({ listDays: () => ({ days: [] }), 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 { TripsModule } from '../../src/nest/trips/trips.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Trips e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
tripSvc.listTrips.mockReturnValue([{ id: 1, title: 'T' }]);
tripSvc.createTrip.mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
tripSvc.getTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, user_id: 1 } : undefined));
tripSvc.listMembers.mockReturnValue({ owner: { id: 1 }, members: [] });
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie', async () => {
expect((await request(server).get('/api/trips')).status).toBe(401);
});
it('200 list', async () => {
const res = await request(server).get('/api/trips').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ trips: [{ id: 1, title: 'T' }] });
});
it('201 create, 403 without permission', async () => {
const ok = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ trip: { id: 9 } });
checkPermission.mockReturnValue(false);
const forbidden = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' });
expect(forbidden.status).toBe(403);
});
it('404 on a missing trip', async () => {
const res = await request(server).get('/api/trips/77').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('200 bundle for an accessible trip', async () => {
const res = await request(server).get('/api/trips/9/bundle').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ trip: { id: 9 }, days: [], members: [{ id: 1 }] });
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Vacay module e2e — exercises the migrated /api/addons/vacay endpoints through
* the real JwtAuthGuard against a temp SQLite db. vacayService is mocked; this
* focuses on auth, status codes (POSTs stay 200) and a couple of validation/403
* bodies.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { svc } = vi.hoisted(() => ({
svc: {
getPlanData: vi.fn(), getActivePlanId: vi.fn(), getActivePlan: vi.fn(), updatePlan: vi.fn(),
addHolidayCalendar: vi.fn(), updateHolidayCalendar: vi.fn(), deleteHolidayCalendar: vi.fn(),
getPlanUsers: vi.fn(), setUserColor: vi.fn(), sendInvite: vi.fn(), acceptInvite: vi.fn(),
declineInvite: vi.fn(), cancelInvite: vi.fn(), dissolvePlan: vi.fn(), getAvailableUsers: vi.fn(),
listYears: vi.fn(), addYear: vi.fn(), deleteYear: vi.fn(), getEntries: vi.fn(),
toggleEntry: vi.fn(), toggleCompanyHoliday: vi.fn(), getStats: vi.fn(), updateStats: vi.fn(),
getCountries: vi.fn(), getHolidays: vi.fn(),
},
}));
vi.mock('../../src/services/vacayService', () => svc);
import { VacayModule } from '../../src/nest/vacay/vacay.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Vacay e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [VacayModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
svc.getActivePlanId.mockReturnValue(10);
svc.getActivePlan.mockReturnValue({ id: 10 });
svc.getPlanUsers.mockReturnValue([{ id: 1 }]);
svc.getPlanData.mockReturnValue({ plan: { id: 10 } });
svc.toggleEntry.mockReturnValue({ action: 'added' });
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get('/api/addons/vacay/plan');
expect(res.status).toBe(401);
});
it('200 plan for an authenticated user', async () => {
const res = await request(server).get('/api/addons/vacay/plan').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ plan: { id: 10 } });
});
it('200 (not 201) on POST entries/toggle, forwarding the socket id', async () => {
const res = await request(server).post('/api/addons/vacay/entries/toggle')
.set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ action: 'added' });
expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 'sock-7');
});
it('400 on entries/toggle without a date', async () => {
const res = await request(server).post('/api/addons/vacay/entries/toggle').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'date required' });
});
it('403 on color for a user not in the plan', async () => {
const res = await request(server).put('/api/addons/vacay/color').set('Cookie', sessionCookie(1)).send({ color: '#fff', target_user_id: 99 });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'User not in plan' });
});
});
+19
View File
@@ -17,8 +17,11 @@
*/
import Database from 'better-sqlite3';
import type { INestApplication } from '@nestjs/common';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { AuthPublicController } from '../../src/nest/auth/auth-public.controller';
import type { RateLimitService } from '../../src/nest/auth/rate-limit.service';
// Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset).
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
@@ -238,6 +241,22 @@ export function buildDbMock(testDb: Database.Database) {
};
}
/**
* Resets the Nest per-IP rate-limit buckets between tests — the buildApp() drop-in
* for the legacy `loginAttempts.clear(); mfaAttempts.clear()`.
*
* The Nest auth path keeps its rate-limit state in a RateLimitService instance that
* lives inside the AuthModule injector (shared by AuthPublicController/AuthController
* for the login/mfa/forgot buckets). The same class is ALSO provided separately in
* OauthModule (its own instance, distinct oauth_* buckets), so a plain
* app.get(RateLimitService) is ambiguous and may hand back the wrong instance — we
* resolve the auth controller and clear the limiter it actually uses.
*/
export function resetRateLimits(app: INestApplication): void {
const ctrl = app.get(AuthPublicController, { strict: false }) as unknown as { rl: RateLimitService };
ctrl.rl.reset();
}
/** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */
export const TEST_CONFIG = {
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+35 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,56 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
// Stub the admin-1 GeoJSON download so /regions/geo is deterministic and never
// hits the real network (the un-stubbed fetch of a ~4600-feature file from
// raw.githubusercontent.com is what made ATLAS-013 hang/time out under load).
// Any other outbound fetch (e.g. background reverse-geocoding) returns empty so
// no test depends on live network.
vi.stubGlobal('fetch', async (url: unknown) => {
if (String(url).includes('natural-earth-vector')) {
return new Response(
JSON.stringify({
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { iso_a2: 'DE' }, geometry: { type: 'Point', coordinates: [10, 51] } },
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: { type: 'Point', coordinates: [2, 47] } },
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
vi.unstubAllGlobals();
await nestApp.close();
testDb.close();
});
+50 -8
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import { authenticator } from 'otplib';
// ─────────────────────────────────────────────────────────────────────────────
@@ -46,31 +47,35 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
import { authCookie, authHeader } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
// Reset rate limiter state between tests so they don't interfere
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
@@ -467,6 +472,31 @@ describe('Forced MFA policy', () => {
const res = await request(app).get('/api/trips').set(authHeader(user.id));
expect(res.status).toBe(200);
});
it('AUTH-020 — require_mfa guards nested Nest addon controllers, not just top-level routes', async () => {
// The global MFA middleware runs ahead of the Express→Nest dispatch, so it
// must block the deeper trip-scoped controllers (budget/packing/todo) too —
// not only /api/trips. A regression that only guarded top-level paths would
// leave every addon endpoint reachable without MFA.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
for (const path of [`/api/trips/${trip.id}/budget`, `/api/trips/${trip.id}/packing`, `/api/trips/${trip.id}/todo`]) {
const res = await request(app).get(path).set(authHeader(user.id));
expect(res.status, `${path} must be MFA-gated`).toBe(403);
expect(res.body.code).toBe('MFA_REQUIRED');
}
});
it('AUTH-020 — MFA-enabled user reaches nested Nest addon controllers under require_mfa', async () => {
const { user } = createUserWithMfa(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
const res = await request(app).get(`/api/trips/${trip.id}/budget`).set(authHeader(user.id));
expect(res.status).toBe(200);
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -805,6 +835,18 @@ describe('Rate limiting', () => {
}
expect(lastStatus).toBe(429);
});
it('AUTH-019 — reset-password endpoint rate-limits after 5 attempts (parity with the legacy resetLimiter)', async () => {
let lastStatus = 0;
for (let i = 0; i <= 5; i++) {
const res = await request(app)
.post('/api/auth/reset-password')
.send({ token: 'badtoken', new_password: 'NewPassw0rd!' });
lastStatus = res.status;
if (lastStatus === 429) break;
}
expect(lastStatus).toBe(429);
});
});
// ─────────────────────────────────────────────────────────────────────────────
+13 -8
View File
@@ -9,6 +9,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -39,7 +40,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
vi.mock('../../src/services/backupService', async () => {
@@ -69,32 +72,34 @@ vi.mock('../../src/services/backupService', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createAdmin, createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as backupService from '../../src/services/backupService';
import fs from 'fs';
import path from 'path';
import os from 'os';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+122
View File
@@ -0,0 +1,122 @@
/**
* BOOTSTRAP / F6 — boots the unified production bootstrap (buildApp) and asserts
* the whole shell is intact on the single NestJS instance now that Express is gone:
* the global security pipeline (helmet/CSP), the /uploads platform routes, the
* migrated /api domains (with the JWT guard), the /api/health + /api/addons
* platform/inline endpoints, and (in production) HSTS. This is the test that proves
* server/src/bootstrap.ts + index.ts serve everything correctly without the legacy app.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db
.prepare(
`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`,
)
.get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) => !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { buildApp } from '../../src/bootstrap';
describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () => {
let app: INestApplication;
let instance: import('express').Application;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
resetTestDb(testDb);
app = await buildApp();
instance = app.getHttpAdapter().getInstance();
});
afterAll(async () => {
await app.close();
testDb.close();
});
it('BOOT-001 — GET /api/health returns 200 { status: ok } (platform transport on Nest)', async () => {
const res = await request(instance).get('/api/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.headers['cache-control']).toContain('no-store');
});
it('BOOT-002 — the global security pipeline (helmet) is applied', async () => {
const res = await request(instance).get('/api/health');
// helmet defaults — proof applyGlobalMiddleware ran on the Nest instance.
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['content-security-policy']).toBeDefined();
});
it('BOOT-003 — public /api/config is reachable without auth (migrated Nest domain)', async () => {
const res = await request(instance).get('/api/config');
expect(res.status).toBe(200);
});
it('BOOT-004 — a protected /api domain rejects an anonymous request (JWT guard wired)', async () => {
const res = await request(instance).get('/api/trips');
expect(res.status).toBe(401);
});
it('BOOT-005 — /uploads/files is blocked without auth (platform uploads on Nest)', async () => {
const res = await request(instance).get('/uploads/files/anything.bin');
expect(res.status).toBe(401);
});
it('BOOT-006 — GET /api/addons works end-to-end (guard → Nest AddonsController)', async () => {
const anon = await request(instance).get('/api/addons');
expect(anon.status).toBe(401);
const { user } = createUser(testDb);
const res = await request(instance).get('/api/addons').set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.addons)).toBe(true);
});
it('BOOT-007 — HSTS is advertised when NODE_ENV=production', async () => {
const prev = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
let prodApp: INestApplication | undefined;
try {
prodApp = await buildApp();
const res = await request(prodApp.getHttpAdapter().getInstance()).get('/api/health');
expect(res.headers['strict-transport-security']).toContain('max-age=');
} finally {
if (prodApp) await prodApp.close();
if (prev === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = prev;
}
});
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -30,30 +31,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
import fs from 'fs';
@@ -40,7 +41,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// Partially mock collabService to make fetchLinkPreview controllable
vi.mock('../../src/services/collabService', async (importOriginal) => {
@@ -51,34 +54,36 @@ vi.mock('../../src/services/collabService', async (importOriginal) => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as collabService from '../../src/services/collabService';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
// Ensure uploads/files dir exists for collab file uploads
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+18 -7
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ─────────────────────────────────────────────────────────────────────────────
// In-memory DB — schema applied in beforeAll after mocks register
@@ -38,20 +39,30 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); });
afterAll(() => { testDb.close(); });
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => { resetTestDb(testDb); resetRateLimits(nestApp); });
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// List days (DAY-001, DAY-002)
+13 -8
View File
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
import fs from 'fs';
@@ -42,26 +43,30 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
// Ensure uploads/files dir exists
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
// Seed allowed_file_types to include common types (wildcard)
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
@@ -69,13 +74,13 @@ beforeAll(() => {
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
// Re-seed allowed_file_types after reset
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
fs.rmSync(uploadsDir, { recursive: true, force: true });
});
+13 -8
View File
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -38,7 +39,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS.
vi.mock('../../src/utils/ssrfGuard', async () => {
@@ -64,28 +67,30 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+17 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
@@ -43,6 +44,7 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({
broadcast: vi.fn(),
@@ -55,10 +57,10 @@ vi.mock('../../src/services/memories/immichService', () => ({
getImmichCredentials: vi.fn(() => null),
}));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import {
createUser,
createAdmin,
@@ -68,23 +70,30 @@ import {
addJourneyContributor,
} from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
invalidatePermissionsCache();
// Enable the journey addon
testDb.prepare(
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)"
).run();
});
afterAll(() => { testDb.close(); });
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// List journeys (JOURNEY-INT-001, 002)
+13 -8
View File
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -38,7 +39,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for
// URLs that look internal); individual tests override with mockResolvedValueOnce.
@@ -53,29 +56,31 @@ vi.mock('../../src/services/mapsService', () => ({
),
}));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as mapsService from '../../src/services/mapsService';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -38,33 +39,37 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { createMcpToken } from '../helpers/factories';
import { closeMcpSessions } from '../../src/mcp/index';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
closeMcpSessions();
await nestApp.close();
testDb.close();
});
@@ -9,6 +9,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
@@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// ── SSRF guard mock — routes all Immich API calls to fake responses ───────────
vi.mock('../../src/utils/ssrfGuard', async () => {
@@ -164,31 +166,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { safeFetch } from '../../src/utils/ssrfGuard';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const IMMICH = '/api/integrations/memories/immich';
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => testDb.close());
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Connection status ─────────────────────────────────────────────────────────
@@ -11,6 +11,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
@@ -38,6 +39,7 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
@@ -190,31 +192,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { safeFetch } from '../../src/utils/ssrfGuard';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const SYNO = '/api/integrations/memories/synologyphotos';
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => testDb.close());
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Settings ──────────────────────────────────────────────────────────────────
@@ -9,6 +9,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
@@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/utils/ssrfGuard', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
return {
@@ -47,30 +49,34 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const BASE = '/api/integrations/memories/unified';
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => testDb.close());
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Helpers ──────────────────────────────────────────────────────────────────
+30 -22
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
@@ -95,33 +100,36 @@ describe('Photo endpoint auth', () => {
describe('Force HTTPS redirect', () => {
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => {
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
// applyGlobalMiddleware reads FORCE_HTTPS when buildApp() composes the app, so
// we need a fresh Nest instance built with the flag set.
process.env.FORCE_HTTPS = 'true';
let httpsApp: Express;
let httpsApp: INestApplication | undefined;
try {
httpsApp = createApp();
httpsApp = await buildApp();
const res = await request(httpsApp.getHttpAdapter().getInstance())
.get('/api/addons')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(301);
} finally {
if (httpsApp) await httpsApp.close();
delete process.env.FORCE_HTTPS;
}
const res = await request(httpsApp)
.get('/api/addons')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(301);
});
it('MISC-008 — FORCE_HTTPS does not redirect /api/health (probes must reach it over HTTP)', async () => {
process.env.FORCE_HTTPS = 'true';
let httpsApp: Express;
let httpsApp: INestApplication | undefined;
try {
httpsApp = createApp();
httpsApp = await buildApp();
const res = await request(httpsApp.getHttpAdapter().getInstance())
.get('/api/health')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
} finally {
if (httpsApp) await httpsApp.close();
delete process.env.FORCE_HTTPS;
}
const res = await request(httpsApp)
.get('/api/health')
.set('X-Forwarded-Proto', 'http');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
+13 -9
View File
@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -38,8 +39,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/services/notifications', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
return {
@@ -49,28 +51,30 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+34 -18
View File
@@ -6,6 +6,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import crypto from 'crypto';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -37,6 +38,7 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
const { isAddonEnabledMock } = vi.hoisted(() => {
@@ -56,16 +58,16 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
// PKCE helpers
function makePkce() {
@@ -74,19 +76,33 @@ function makePkce() {
return { verifier, challenge };
}
beforeAll(() => {
// A7: under the unified Nest app the adminService mock only reaches the directly
// imported isAddonEnabled (OauthService.mcpEnabled); oauthService.ts reads the
// addon state through its own import that the Nest module graph loads unmocked,
// so it falls back to the real DB row. Drive BOTH so the MCP-enabled state is
// consistent across mcpEnabled() AND validateAuthorizeRequest()/token/revoke.
function setMcpEnabled(enabled: boolean) {
isAddonEnabledMock.mockReturnValue(enabled);
testDb.prepare(
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)"
).run(enabled ? 1 : 0);
}
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
isAddonEnabledMock.mockReturnValue(true);
resetRateLimits(nestApp);
setMcpEnabled(true);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
@@ -156,7 +172,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
});
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
@@ -511,7 +527,7 @@ describe('POST /oauth/revoke', () => {
describe('GET /api/oauth/authorize/validate', () => {
it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const res = await request(app)
.get('/api/oauth/authorize/validate')
.query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' });
@@ -697,7 +713,7 @@ describe('POST /api/oauth/authorize', () => {
});
it('OAUTH-029 — 403 when MCP disabled', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
@@ -772,7 +788,7 @@ describe('POST /api/oauth/authorize', () => {
describe('Client CRUD — /api/oauth/clients', () => {
it('OAUTH-033 — GET returns 403 when addon disabled', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
@@ -809,7 +825,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
});
it('OAUTH-036 — POST returns 403 when addon disabled', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
@@ -859,7 +875,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
describe('Sessions — /api/oauth/sessions', () => {
it('OAUTH-040 — GET returns 403 when addon disabled', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
@@ -927,7 +943,7 @@ describe('Sessions — /api/oauth/sessions', () => {
});
it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const { user } = createUser(testDb);
const res = await request(app)
@@ -952,13 +968,13 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => {
it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const res = await request(app).get('/.well-known/oauth-authorization-server');
expect(res.status).toBe(404);
});
it('OAUTH-SEC-003 — /oauth/revoke returns 404 when disabled', async () => {
isAddonEnabledMock.mockReturnValue(false);
setMcpEnabled(false);
const res = await request(app)
.post('/oauth/revoke')
.send({ token: 'x', client_id: 'y', client_secret: 'z' });
+13 -8
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
@@ -34,7 +35,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// ── Mock only the HTTP-calling functions from oidcService ────────────────────
vi.mock('../../src/services/oidcService', async (importOriginal) => {
@@ -52,12 +55,11 @@ vi.mock('../../src/services/oidcService', async (importOriginal) => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as oidcService from '../../src/services/oidcService';
const mockDiscover = vi.mocked(oidcService.discover);
@@ -71,17 +73,19 @@ const MOCK_DISCOVERY_DOC = {
userinfo_endpoint: 'https://oidc.example.com/userinfo',
};
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
vi.clearAllMocks();
// Set OIDC environment variables for each test
@@ -98,7 +102,8 @@ afterEach(() => {
delete process.env.APP_URL;
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -41,7 +42,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/services/placeService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/placeService')>();
return {
@@ -51,36 +54,38 @@ vi.mock('../../src/services/placeService', async (importOriginal) => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as placeService from '../../src/services/placeService';
import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml');
const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml');
const KML_MALFORMED_FIXTURE = path.join(__dirname, '../fixtures/test-malformed.kml');
const KMZ_FIXTURE = path.join(__dirname, '../fixtures/test.kmz');
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
invalidatePermissionsCache();
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -36,32 +37,36 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, createTrip } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
import path from 'path';
import fs from 'fs';
@@ -42,35 +43,39 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip } from '../helpers/factories';
import { authCookie, authHeader, generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
const uploadsDir = path.join(__dirname, '../../uploads/files');
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
fs.rmSync(uploadsDir, { recursive: true, force: true });
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -30,30 +31,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+11 -4
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ─────────────────────────────────────────────────────────────────────────────
// Bare in-memory DB — schema applied in beforeAll after mocks register
@@ -33,9 +34,11 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
@@ -44,7 +47,8 @@ import { authCookie } from '../helpers/auth';
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
import type { SystemNotice } from '../../src/systemNotices/types';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
// Test notice injected into the registry for notice-specific tests
const TEST_NOTICE: SystemNotice = {
@@ -59,16 +63,19 @@ const TEST_NOTICE: SystemNotice = {
priority: 0,
};
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -30,30 +31,34 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -30,32 +31,36 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
invalidatePermissionsCache();
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
});
+18 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
@@ -43,27 +44,36 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { invalidatePermissionsCache } from '../../src/services/permissions';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
invalidatePermissionsCache();
});
afterAll(() => { testDb.close(); });
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ─────────────────────────────────────────────────────────────────────────────
// Create trip (TRIP-001, TRIP-002, TRIP-003)
+13 -8
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -35,7 +36,9 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// Prevent real HTTP calls (holiday API etc.)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@@ -56,28 +59,30 @@ vi.mock('../../src/services/vacayService', async () => {
};
});
import { createApp } from '../../src/app';
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
const app: Application = createApp();
let nestApp: INestApplication;
let app: Application;
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
afterAll(() => {
afterAll(async () => {
await nestApp.close();
testDb.close();
vi.unstubAllGlobals();
});
-39
View File
@@ -1,39 +0,0 @@
import request from 'supertest';
import { expect } from 'vitest';
import type { Server } from 'http';
export interface ParityRequest {
method?: 'get' | 'post' | 'put' | 'patch' | 'delete';
path: string;
query?: Record<string, string>;
body?: unknown;
}
/**
* Reusable Nest-vs-Express parity harness.
*
* Fires the same HTTP request at the legacy Express app and the migrated Nest app
* and asserts the response is client-identical same status code and same JSON
* body. With the underlying service mocked identically for both, any difference is
* purely framework-layer (routing, validation, error envelope), which is exactly
* what a migration must not change. Use one assertion per migrated route/case.
*/
export async function expectParity(
expressServer: Server | Express.Application,
nestServer: Server,
req: ParityRequest,
): Promise<void> {
const fire = (target: Server | Express.Application) => {
const method = req.method ?? 'get';
let r = request(target as never)[method](req.path);
if (req.query) r = r.query(req.query);
if (req.body !== undefined) r = r.send(req.body as object);
return r;
};
const [ex, ne] = await Promise.all([fire(expressServer), fire(nestServer)]);
const label = `${(req.method ?? 'GET').toUpperCase()} ${req.path}`;
expect(ne.status, `${label}: status mismatch`).toBe(ex.status);
expect(ne.body, `${label}: body mismatch`).toEqual(ex.body);
}
@@ -0,0 +1,194 @@
/**
* Migration hygiene guardrails.
*
* These tests scan the migration source statically and fail when a NEW
* destructive operation (DROP TABLE / DROP COLUMN / TRUNCATE / DELETE FROM /
* ALTER ... DROP) is introduced, or when an empty/silent `catch` block creeps
* back into the migration runner.
*
* Migrations 1..N are append-only and immutable once shipped (the live schema
* has already applied them; rewriting an applied migration is a breaking
* change). The destructive statements that already exist were each reviewed
* and are legitimate almost all are the standard SQLite "table rebuild"
* pattern (create *_new, copy rows, DROP old, RENAME), plus a handful of
* deliberate, data-preserving cleanups. They are recorded in
* ALLOWED_DESTRUCTIVE below with the reason. Anything not on that list is
* treated as a regression.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { createTestDb } from '../../helpers/test-db';
const here = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_PATH = resolve(here, '../../../src/db/migrations.ts');
const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8');
/**
* Strip line and block comments so commented-out SQL (or prose mentioning
* "DROP TABLE") is never flagged. String/template contents are preserved
* that is exactly where the real SQL lives.
*/
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
const scannableSource = stripComments(migrationsSource);
interface DestructiveHit {
/** Normalised signature used as the allowlist key, e.g. "DROP TABLE budget_items". */
signature: string;
/** The raw matched fragment, kept for diagnostics. */
fragment: string;
}
/**
* Detects destructive DDL/DML. For each match we build a normalised signature
* of "<OPERATION> <TARGET>" so cosmetic whitespace/quoting changes don't churn
* the allowlist, while a genuinely new target (or operation) shows up as a new
* signature.
*/
function findDestructiveStatements(src: string): DestructiveHit[] {
const hits: DestructiveHit[] = [];
const norm = (s: string) => s.replace(/[`"'\[\]]/g, '').replace(/\s+/g, ' ').trim();
// DROP TABLE [IF EXISTS] <name>
for (const m of src.matchAll(/DROP\s+TABLE\s+(IF\s+EXISTS\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
hits.push({ signature: `DROP TABLE ${m[2]}`, fragment: norm(m[0]) });
}
// ALTER TABLE <t> DROP COLUMN <c> (and bare ALTER ... DROP <c>)
for (const m of src.matchAll(/ALTER\s+TABLE\s+[`"'\[]?([A-Za-z_][\w]*)[`"'\]]?\s+DROP\s+(COLUMN\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
hits.push({ signature: `ALTER TABLE ${m[1]} DROP COLUMN ${m[3]}`, fragment: norm(m[0]) });
}
// TRUNCATE <t> (not valid SQLite, but guard anyway)
for (const m of src.matchAll(/TRUNCATE\s+(TABLE\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
hits.push({ signature: `TRUNCATE ${m[2]}`, fragment: norm(m[0]) });
}
// DELETE FROM <t>
for (const m of src.matchAll(/DELETE\s+FROM\s+[`"'\[]?([A-Za-z_][\w]*)/gi)) {
hits.push({ signature: `DELETE FROM ${m[1]}`, fragment: norm(m[0]) });
}
return hits;
}
/**
* Allowlist of destructive statements already present and reviewed as
* legitimate. Keyed by normalised signature. NEVER add to this without a
* code-review-level justification that is the whole point of the guard.
*
* Rebuild = standard SQLite 12-step ALTER emulation: CREATE <t>_new,
* INSERT ... SELECT to copy rows, DROP old <t>, ALTER ... RENAME <t>_new TO <t>.
* Rows are preserved across the rebuild.
*/
const ALLOWED_DESTRUCTIVE: Record<string, string> = {
// ── table rebuilds (data preserved) ──────────────────────────────────────
'DROP TABLE budget_items':
'Migration 12: rebuild to drop a stale NOT NULL DEFAULT on persons/days. Rows copied first.',
'DROP TABLE oauth_clients':
'Make oauth_clients.user_id nullable for anonymous DCR clients. Rebuild, rows copied.',
'DROP TABLE idempotency_keys':
'Widen PK to (key,user_id,method,path). Rebuild, rows copied (old PK is a subset).',
'DROP TABLE day_accommodations':
'Make place_id nullable + ON DELETE SET NULL. Rebuild, rows copied.',
'DROP TABLE schema_version':
'Add surrogate id PK to schema_version. Rebuild, version row copied.',
// ── photo/journey table rebuilds (data preserved) ────────────────────────
'DROP TABLE trip_photos':
'trip_photos normalisation + later photo_id FK refactor. Rebuilds, rows copied.',
'DROP TABLE trip_album_links':
'Normalise trip_album_links to provider+album_id schema. Rebuild, rows copied.',
'DROP TABLE journey_photos':
'Journey photo provider support + photo_id FK refactor. Rebuilds, rows copied.',
'DROP TABLE journey_photos_old':
'Migration 121 gallery refactor: drops the temporary *_old backup after backfill.',
'DROP TABLE journey_location_trail':
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
'DROP TABLE journey_entries':
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
'DROP TABLE journey_checkins':
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
'DROP TABLE journey_members':
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
'DROP TABLE journey_trips':
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
'DROP TABLE journeys':
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
// ── template/cache scaffolding drops (no user content lost) ──────────────
'DROP TABLE packing_template_items':
'IF EXISTS drop to recreate the template-items table with a category_id FK. Template scaffolding.',
'DROP TABLE notification_preferences':
'IF EXISTS drop AFTER migration 71 copied the data into notification_channel_preferences.',
// ── guarded column drop ──────────────────────────────────────────────────
'ALTER TABLE photo_providers DROP COLUMN config':
'Drop generated-only config column; guarded by a PRAGMA table_info check that it exists.',
// ── targeted, bounded DELETEs ────────────────────────────────────────────
'DELETE FROM oauth_tokens':
'SEC-H6: DELETE ... WHERE audience IS NULL — purge pre-audience-binding tokens that the MCP server now rejects.',
'DELETE FROM journey_entries':
"Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.",
'DELETE FROM place_regions':
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.',
};
describe('migration hygiene — destructive operation guard', () => {
it('introduces no destructive migration statement outside the reviewed allowlist', () => {
const hits = findDestructiveStatements(scannableSource);
const offenders = hits.filter((h) => !(h.signature in ALLOWED_DESTRUCTIVE));
if (offenders.length > 0) {
const detail = offenders
.map((o) => `${o.signature} (matched: "${o.fragment}")`)
.join('\n');
throw new Error(
`Found ${offenders.length} destructive migration statement(s) that are not on the ` +
`reviewed allowlist in tests/unit/db/migration-hygiene.test.ts.\n` +
`Migrations are append-only and destructive DDL/DML risks data loss on upgrade.\n` +
`If the statement is genuinely safe (e.g. a SQLite table rebuild that copies rows ` +
`first, or a tightly-bounded cache/cleanup DELETE), add its signature to ` +
`ALLOWED_DESTRUCTIVE with a justification.\n\nOffending statement(s):\n${detail}`,
);
}
expect(offenders).toEqual([]);
});
it('every allowlist entry still corresponds to a real statement (no dead allowlist rows)', () => {
const present = new Set(findDestructiveStatements(scannableSource).map((h) => h.signature));
const dead = Object.keys(ALLOWED_DESTRUCTIVE).filter((sig) => !present.has(sig));
expect(dead, `Allowlist entries no longer found in migrations.ts: ${dead.join(', ')}`).toEqual([]);
});
});
describe('migration hygiene — no silently swallowed errors', () => {
it('contains no empty catch block (catch must at least log)', () => {
// Matches `catch {}` and `catch (e) {}` where the body is only whitespace.
const emptyCatch = scannableSource.match(/catch\s*(\([^)]*\))?\s*\{\s*\}/g) ?? [];
expect(
emptyCatch,
`migrations.ts must not swallow errors silently. Give each catch a log line ` +
`(e.g. console.warn('[migrations] ...', err)). Found: ${emptyCatch.length}`,
).toEqual([]);
});
});
describe('migration hygiene — full chain smoke', () => {
it('migrates a fresh in-memory database from zero to the latest version', () => {
// createTestDb() runs createTables() + the entire runMigrations() chain.
// This proves the logging edits in the previously-empty catch blocks do
// not change control flow / break the migration runner.
const db = createTestDb();
try {
const row = db.prepare('SELECT version FROM schema_version').get() as { version: number };
expect(row.version).toBeGreaterThan(0);
} finally {
db.close();
}
});
});
+11 -4
View File
@@ -71,8 +71,10 @@ beforeEach(() => {
isAddonEnabledMock.mockReturnValue(true);
// Default mock: returns a trip-summary-shaped value from the real in-memory DB
// so that the trip title / existence match what tests insert, but budget/packing
// are arrays (as prompts.ts expects), not the object shape getTripSummary now returns.
// so the trip title / existence match what tests insert. `budget` mirrors the
// real getTripSummary object shape ({ items, total, ... }) that prompts.ts reads
// via budget.items/budget.total; packing stays an array (the packing prompt
// tolerates it).
mockGetTripSummary.mockImplementation((tripId: any) => {
const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
if (!trip) return null;
@@ -87,8 +89,13 @@ beforeEach(() => {
trip,
days: [],
members,
budget: budgetRows, // array shape expected by prompts.ts
packing: packingRows, // array shape expected by prompts.ts
budget: {
items: budgetRows,
item_count: budgetRows.length,
total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0),
currency: trip.currency,
},
packing: packingRows, // array shape; packing prompt tolerates it
reservations: [],
collabNotes: [],
};
@@ -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 } });
});
});
-33
View File
@@ -1,33 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler';
describe('strangler toggle', () => {
const original = process.env.NEST_PREFIXES;
afterEach(() => {
if (original === undefined) delete process.env.NEST_PREFIXES;
else process.env.NEST_PREFIXES = original;
});
it('defaults to the migrated prefixes (/api/_nest + /api/weather) when NEST_PREFIXES is unset', () => {
delete process.env.NEST_PREFIXES;
expect(getNestPrefixes()).toEqual(['/api/_nest', '/api/weather']);
});
it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
process.env.NEST_PREFIXES = '/api/weather, /api/airports';
expect(getNestPrefixes()).toEqual(['/api/weather', '/api/airports']);
});
it('treats an empty NEST_PREFIXES as "all routes on legacy"', () => {
process.env.NEST_PREFIXES = '';
expect(getNestPrefixes()).toEqual([]);
});
it('matches exact prefixes and subpaths but not lookalikes', () => {
const match = makeNestPathMatcher(['/api/_nest']);
expect(match('/api/_nest')).toBe(true);
expect(match('/api/_nest/health')).toBe(true);
expect(match('/api/_nestxyz')).toBe(false);
expect(match('/api/health')).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();
});
});

Some files were not shown because too many files have changed in this diff Show More