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.
This commit is contained in:
Maurice
2026-05-31 13:29:22 +02:00
parent fc7d8b5d12
commit bfe52579df
138 changed files with 2289 additions and 12666 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,
},
],
},
],
});
});
});
+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);
});
});
});
+12 -1
View File
@@ -35,7 +35,7 @@ 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(),
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: {
@@ -72,6 +72,7 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', ()
day.listAccommodations.mockReturnValue([{ id: 1 }]);
day.validateAccommodationRefs.mockReturnValue([]);
day.createAccommodation.mockReturnValue({ id: 9 });
resv.getUpcomingReservations.mockReturnValue([{ id: 1, trip_id: 5, title: 'Flight' }]);
});
beforeEach(() => {
@@ -94,6 +95,16 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', ()
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));
+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();
});
+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 } 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
@@ -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();
});
+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();
});
-117
View File
@@ -1,117 +0,0 @@
/**
* S3 parity to-dos (trip-scoped).
*
* Same request at the legacy Express /api/trips/:tripId/todo route (mergeParams)
* and the migrated Nest controller, with todoService, the permission check, the
* WebSocket broadcast and auth all mocked identically. Asserts client-identical
* status + body, including trip 404, permission 403, and the create 201.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser, trip } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
trip: { id: 5, user_id: 1 },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 todoRoutes from '../../src/routes/todo';
import { TodoModule } from '../../src/nest/todo/todo.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S3 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/todo', todoRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [TodoModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
svc.listItems.mockReturnValue([{ id: 1, name: 'Book hotel' }]);
svc.createItem.mockReturnValue({ id: 9, name: 'Book hotel' });
svc.updateItem.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null));
svc.getCategoryAssignees.mockReturnValue([]);
});
beforeEach(() => {
svc.verifyTripAccess.mockReturnValue(trip);
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/todo' }));
it('GET / 404 when trip not accessible', () => {
svc.verifyTripAccess.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/todo' });
});
it('POST / create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: { name: 'Book hotel' } }));
it('POST / 403 without permission', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: { name: 'Book hotel' } });
});
it('POST / 400 missing name', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: {} }));
it('PUT /reorder', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/todo/reorder', body: { orderedIds: [1, 2] } }));
it('PUT /:id 404 when item missing', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/todo/77', body: { name: 'X' } }));
it('GET /category-assignees', () =>
expectParity(expressServer, nestServer, { path: '/api/trips/5/todo/category-assignees' }));
});
@@ -1,124 +0,0 @@
/**
* S4 parity budget (trip-scoped).
*
* Same request at the legacy Express /api/trips/:tripId/budget route (mergeParams)
* and the migrated Nest controller, with budgetService, the permission check, the
* WebSocket broadcast, the DB and auth all mocked identically. Asserts
* client-identical status + body across the trip 404, permission 403, validation
* 400 and the create 201.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser, trip } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
trip: { id: 5, user_id: 1 },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 budgetRoutes from '../../src/routes/budget';
import { BudgetModule } from '../../src/nest/budget/budget.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S4 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/budget', budgetRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
svc.listBudgetItems.mockReturnValue([{ id: 1, name: 'Hotel' }]);
svc.createBudgetItem.mockReturnValue({ id: 9, name: 'Hotel' });
svc.updateBudgetItem.mockImplementation((id: string) => (id === '9' ? { id: 9, reservation_id: null, total_price: 100 } : null));
svc.updateMembers.mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } });
svc.toggleMemberPaid.mockReturnValue({ user_id: 2, paid: 1 });
svc.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
svc.calculateSettlement.mockReturnValue({ transfers: [] });
});
beforeEach(() => {
svc.verifyTripAccess.mockReturnValue(trip);
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget' }));
it('GET / 404 trip', () => {
svc.verifyTripAccess.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/budget' });
});
it('GET /summary/per-person', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget/summary/per-person' }));
it('GET /settlement', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget/settlement' }));
it('POST / create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: { name: 'Hotel' } }));
it('POST / 403 no permission', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: { name: 'Hotel' } });
});
it('POST / 400 missing name', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: {} }));
it('PUT /reorder/items', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/reorder/items', body: { orderedIds: [1, 2] } }));
it('PUT /reorder/categories', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/reorder/categories', body: { orderedCategories: ['a'] } }));
it('PUT /:id 404 when item missing', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/77', body: { name: 'X' } }));
it('PUT /:id/members 400 not array', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/9/members', body: { user_ids: 'no' } }));
it('DELETE /:id 404 when missing', () => {
svc.deleteBudgetItem.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/budget/77' });
});
});
@@ -1,154 +0,0 @@
/**
* S5 parity reservations + accommodations (trip-scoped).
*
* Fires the same request at the legacy Express routes (reservations route +
* accommodations sub-router from routes/days.ts, both mounted with mergeParams)
* and the migrated Nest controllers, with the reservation/day/budget services,
* the permission check, canAccessTrip, the WebSocket broadcast and auth all
* mocked identically. Asserts client-identical status + body across the trip
* 404, permission 403, validation 400/404 and the create 201.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser, trip } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
trip: { id: 5, user_id: 1 },
}));
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
canAccessTrip,
isOwner: vi.fn(() => true),
getPlaceWithTags: vi.fn(),
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
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(),
},
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 reservationsRoutes from '../../src/routes/reservations';
import { accommodationsRouter } from '../../src/routes/days';
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S5 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/accommodations', accommodationsRouter);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [ReservationsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
resv.listReservations.mockReturnValue([{ id: 1, title: 'Hotel' }]);
resv.createReservation.mockReturnValue({ reservation: { id: 9, title: 'Hotel' }, accommodationCreated: false });
resv.getReservation.mockImplementation((id: string) => (id === '9' ? { title: 'Hotel', type: 'lodging' } : undefined));
resv.updateReservation.mockReturnValue({ reservation: { id: 9 }, accommodationChanged: false });
resv.deleteReservation.mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null });
day.listAccommodations.mockReturnValue([{ id: 1 }]);
day.validateAccommodationRefs.mockReturnValue([]);
day.createAccommodation.mockReturnValue({ id: 9 });
day.getAccommodation.mockImplementation((id: string) => (id === '9' ? { id: 9 } : undefined));
day.updateAccommodation.mockReturnValue({ id: 9 });
day.deleteAccommodation.mockReturnValue({ linkedReservationId: null, deletedBudgetItemId: null });
});
beforeEach(() => {
resv.verifyTripAccess.mockReturnValue(trip);
canAccessTrip.mockReturnValue(trip);
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
// Reservations
it('GET /reservations', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/reservations' }));
it('GET /reservations 404 trip', () => {
resv.verifyTripAccess.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/reservations' });
});
it('POST /reservations create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: { title: 'Hotel' } }));
it('POST /reservations 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: { title: 'Hotel' } });
});
it('POST /reservations 400 missing title', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: {} }));
it('PUT /reservations/positions 400 not array', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/reservations/positions', body: { positions: 'no' } }));
it('PUT /reservations/:id 404 missing', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/reservations/77', body: { title: 'X' } }));
it('DELETE /reservations/:id success', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/reservations/9' }));
// Accommodations
it('GET /accommodations', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/accommodations' }));
it('GET /accommodations 404 trip', () => {
canAccessTrip.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/accommodations' });
});
it('POST /accommodations create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2, start_day_id: 10, end_day_id: 11 } }));
it('POST /accommodations 400 missing refs', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2 } }));
it('POST /accommodations 404 bad ref', () => {
day.validateAccommodationRefs.mockReturnValue([{ field: 'place_id', message: 'Place not found' }]);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2, start_day_id: 10, end_day_id: 11 } });
});
it('PUT /accommodations/:id 404 missing', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/accommodations/77', body: {} }));
it('DELETE /accommodations/:id success', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/accommodations/9' }));
});
-135
View File
@@ -1,135 +0,0 @@
/**
* S6 parity days + day notes (trip-scoped).
*
* Same request at the legacy Express days route + the day-notes route (both
* mergeParams) and the migrated Nest controllers, with dayService /
* dayNoteService, the permission check, canAccessTrip, the WebSocket broadcast
* and auth all mocked identically. Covers trip 404, permission 403, the bespoke
* 404s, the create 201, and the string-length-before-access ordering.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser, trip } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
trip: { id: 5, user_id: 1 },
}));
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 daysRoutes from '../../src/routes/days';
import dayNotesRoutes from '../../src/routes/dayNotes';
import { DaysModule } from '../../src/nest/days/days.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S6 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.use('/api/trips/:tripId/days', daysRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [DaysModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
day.listDays.mockReturnValue({ days: [{ id: 1 }] });
day.createDay.mockReturnValue({ id: 9 });
day.getDay.mockImplementation((id: string) => (id === '9' ? { id: 9 } : undefined));
day.updateDay.mockReturnValue({ id: 9, title: 'T' });
note.listNotes.mockReturnValue([{ id: 1 }]);
note.dayExists.mockReturnValue(true);
note.createNote.mockReturnValue({ id: 7 });
note.getNote.mockImplementation((id: string) => (id === '7' ? { id: 7 } : undefined));
note.updateNote.mockReturnValue({ id: 7 });
});
beforeEach(() => {
canAccessTrip.mockReturnValue(trip);
note.verifyTripAccess.mockReturnValue(trip);
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
// Days
it('GET /days', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days' }));
it('GET /days 404 trip', () => {
canAccessTrip.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/days' });
});
it('POST /days create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days', body: { date: '2026-07-01' } }));
it('POST /days 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days', body: {} });
});
it('PUT /days/:id 404 missing', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/77', body: { title: 'X' } }));
it('DELETE /days/:id 404 missing', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/77' }));
// Day notes
it('GET notes', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/notes' }));
it('POST notes create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'Lunch', time: '12:00' } }));
it('POST notes 400 empty text', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: ' ' } }));
it('POST notes 400 over-long text (before access)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'x'.repeat(501) } }));
it('POST notes 404 day not found', () => {
note.dayExists.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'ok' } });
});
it('PUT notes/:id 404 missing', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/3/notes/99', body: { text: 'x' } }));
it('DELETE notes/:id 404 missing', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/3/notes/99' }));
});
@@ -1,131 +0,0 @@
/**
* S7 parity assignments (placeday itinerary).
*
* Same request at the legacy Express assignments route (mounted on /api, with
* full /trips/... paths) and the migrated Nest controllers, with
* assignmentService, journeyService.onPlaceCreated, the permission check,
* canAccessTrip, the WebSocket broadcast and auth all mocked identically. Covers
* trip 404, permission 403, the bespoke 404s, the create 201 and validation 400.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser, trip } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
trip: { id: 5, user_id: 1 },
}));
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 assignmentsRoutes from '../../src/routes/assignments';
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S7 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api', assignmentsRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [AssignmentsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
asg.listDayAssignments.mockReturnValue([{ id: 1 }]);
asg.createAssignment.mockReturnValue({ id: 9 });
asg.assignmentExistsInDay.mockReturnValue(true);
asg.getAssignmentForTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, day_id: 3 } : undefined));
asg.moveAssignment.mockReturnValue({ assignment: { id: 9 } });
asg.getParticipants.mockReturnValue([{ user_id: 2 }]);
asg.updateTime.mockReturnValue({ id: 9 });
asg.setParticipants.mockReturnValue([{ user_id: 2 }]);
});
beforeEach(() => {
canAccessTrip.mockReturnValue(trip);
checkPermission.mockReturnValue(true);
asg.dayExists.mockReturnValue(true);
asg.placeExists.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('GET day-assignments', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/assignments' }));
it('GET day-assignments 404 day', () => {
asg.dayExists.mockReturnValue(false);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/assignments' });
});
it('POST create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 2 } }));
it('POST 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 2 } });
});
it('POST 404 place', () => {
asg.placeExists.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 99 } });
});
it('PUT reorder', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/3/assignments/reorder', body: { orderedIds: [1, 2] } }));
it('DELETE /:id 404 not in day', () => {
asg.assignmentExistsInDay.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/3/assignments/77' });
});
it('PUT move 404 assignment', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/77/move', body: { new_day_id: 4 } }));
it('PUT move success', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/move', body: { new_day_id: 4, order_index: 0 } }));
it('GET participants', () =>
expectParity(expressServer, nestServer, { path: '/api/trips/5/assignments/9/participants' }));
it('PUT time success', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/time', body: { place_time: '10:00' } }));
it('PUT participants 400 not array', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/participants', body: { user_ids: 'no' } }));
});
@@ -1,138 +0,0 @@
/**
* S8 parity places (trip-scoped).
*
* Same request at the legacy Express /api/trips/:tripId/places route (mergeParams)
* and the migrated Nest controller, with placeService, journeyService, the
* permission check, canAccessTrip, the WebSocket broadcast and auth mocked
* identically. Covers the JSON endpoints (the multer file imports are covered by
* the controller unit test): trip 404, length 400, permission 403, name 400,
* list-import error mapping, bulk-delete validation, and the create 201.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser, trip } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
trip: { id: 5, user_id: 1 },
}));
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 placesRoutes from '../../src/routes/places';
import { PlacesModule } from '../../src/nest/places/places.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S8 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/places', placesRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [PlacesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
pl.listPlaces.mockReturnValue([{ id: 1, name: 'Spot' }]);
pl.createPlace.mockReturnValue({ id: 9, name: 'Spot' });
pl.getPlace.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : undefined));
pl.updatePlace.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null));
pl.deletePlace.mockImplementation((_t: string, id: string) => id === '9');
pl.deletePlacesMany.mockReturnValue([1, 2]);
pl.importGoogleList.mockResolvedValue({ places: [{ id: 1 }], listName: 'L', skipped: 0 });
pl.importNaverList.mockResolvedValue({ error: 'List is empty', status: 400 });
pl.searchPlaceImage.mockResolvedValue({ photos: [{ url: 'x' }] });
});
beforeEach(() => {
canAccessTrip.mockReturnValue(trip);
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/places', query: { search: 'sp' } }));
it('GET / 404 trip', () => {
canAccessTrip.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/places' });
});
it('POST / create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'Spot' } }));
it('POST / 400 over-long name', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'x'.repeat(201) } }));
it('POST / 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'Spot' } });
});
it('POST / 400 missing name', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: {} }));
it('POST /import/google-list success (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/google-list', body: { url: 'http://x' } }));
it('POST /import/google-list 400 missing url', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/google-list', body: {} }));
it('POST /import/naver-list service error', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/naver-list', body: { url: 'http://x' } }));
it('POST /bulk-delete 400 not numbers', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: ['a'] } }));
it('POST /bulk-delete empty', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: [] } }));
it('POST /bulk-delete success', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: [1, 2] } }));
it('GET /:id 404', () =>
expectParity(expressServer, nestServer, { path: '/api/trips/5/places/77' }));
it('GET /:id found', () =>
expectParity(expressServer, nestServer, { path: '/api/trips/5/places/9' }));
it('GET /:id/image', () =>
expectParity(expressServer, nestServer, { path: '/api/trips/5/places/9/image' }));
it('PUT /:id 404', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/places/77', body: { name: 'X' } }));
it('DELETE /:id success', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/places/9' }));
});
@@ -1,139 +0,0 @@
/**
* C1 parity trips aggregate root.
*
* Same request at the legacy Express /api/trips route and the migrated Nest
* controller, with tripService, the bundle list-services, auditLog, demo,
* the permission check, the WebSocket broadcast and auth mocked identically.
* Covers the own-routes (list/create/get/update/delete/members/copy/bundle);
* the exact-prefix routing (not capturing collab/files) is unit-tested in the
* strangler spec.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => ({ id: 42 }), all: () => [], run: () => undefined }) },
canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
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 * FROM trips t',
},
}));
vi.mock('../../src/services/tripService', () => tripSvc);
// Bundle list-services — return empty collections.
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 tripsRoutes from '../../src/routes/trips';
import { TripsModule } from '../../src/nest/trips/trips.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('C1 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips', tripsRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.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.updateTrip.mockReturnValue({ updatedTrip: { id: 9 }, changes: {}, newTitle: 'T', newReminder: 0, oldReminder: 0 });
tripSvc.getTripOwner.mockReturnValue({ user_id: 1 });
tripSvc.deleteTrip.mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: false });
tripSvc.listMembers.mockReturnValue({ owner: { id: 1 }, members: [] });
tripSvc.copyTripById.mockReturnValue(42);
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/trips' }));
it('POST / create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T' } }));
it('POST / 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T' } });
});
it('POST / 400 missing title', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: {} }));
it('POST / 400 end before start', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' } }));
it('GET /:id found', () => expectParity(expressServer, nestServer, { path: '/api/trips/9' }));
it('GET /:id 404', () => {
tripSvc.getTrip.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/77' });
});
it('PUT /:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/9', body: { title: 'b' } }));
it('PUT /:id 404 no access', () => {
canAccessTrip.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/9', body: { title: 'b' } });
});
it('POST /:id/copy (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/9/copy', body: { title: 'Copy' } }));
it('DELETE /:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/9' }));
it('GET /:id/members', () => expectParity(expressServer, nestServer, { path: '/api/trips/9/members' }));
it('POST /:id/members (201)', () => {
tripSvc.addMember.mockReturnValue({ member: { id: 2, email: 'b@x.y' }, targetUserId: 2, tripTitle: 'T' });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/9/members', body: { identifier: 'b@x.y' } });
});
it('GET /:id/bundle', () => expectParity(expressServer, nestServer, { path: '/api/trips/9/bundle' }));
});
@@ -1,166 +0,0 @@
/**
* C2 parity collab (shared notes, polls, chat + reactions, link previews).
*
* Same request at the legacy Express /api/trips/:tripId/collab route and the
* migrated Nest controller, with collabService, permissions, the WebSocket
* broadcast, the notification fire-and-forget, the db and auth mocked
* identically. File uploads are exercised by the e2e/unit specs (multer differs
* per framework); this pins routing, status codes, the error envelopes and the
* poll/message error-string mapping.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => ({ title: 'T' }), all: () => [], run: () => undefined }) },
canAccessTrip: vi.fn(() => ({ user_id: 1 })), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 { collabSvc } = vi.hoisted(() => ({
collabSvc: {
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', () => collabSvc);
import collabRoutes from '../../src/routes/collab';
import { CollabModule } from '../../src/nest/collab/collab.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('C2 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/collab', collabRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
collabSvc.listNotes.mockReturnValue([{ id: 1, title: 'N' }]);
collabSvc.createNote.mockReturnValue({ id: 9, title: 'N' });
collabSvc.updateNote.mockReturnValue({ id: 9, title: 'N2' });
collabSvc.deleteNote.mockReturnValue(true);
collabSvc.listPolls.mockReturnValue([{ id: 1 }]);
collabSvc.createPoll.mockReturnValue({ id: 7 });
collabSvc.votePoll.mockReturnValue({ poll: { id: 7 } });
collabSvc.closePoll.mockReturnValue({ id: 7, closed: 1 });
collabSvc.deletePoll.mockReturnValue(true);
collabSvc.listMessages.mockReturnValue([{ id: 1, text: 'hi' }]);
collabSvc.createMessage.mockReturnValue({ message: { id: 3, text: 'hi' } });
collabSvc.deleteMessage.mockReturnValue({ username: 'u' });
collabSvc.addOrRemoveReaction.mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] });
collabSvc.fetchLinkPreview.mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
});
beforeEach(() => {
collabSvc.verifyTripAccess.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
// Notes
it('GET /notes', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/notes' }));
it('GET /notes 404 no access', () => {
collabSvc.verifyTripAccess.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/notes' });
});
it('POST /notes (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: { title: 'N' } }));
it('POST /notes 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: { title: 'N' } });
});
it('POST /notes 400 missing title', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: {} }));
it('PUT /notes/:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/notes/9', body: { title: 'N2' } }));
it('PUT /notes/:id 404', () => {
collabSvc.updateNote.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/notes/9', body: { title: 'x' } });
});
it('DELETE /notes/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/notes/9' }));
it('DELETE /notes/:id 404', () => {
collabSvc.deleteNote.mockReturnValueOnce(false).mockReturnValueOnce(false);
return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/notes/9' });
});
// Polls
it('GET /polls', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/polls' }));
it('POST /polls (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { question: 'q', options: ['a', 'b'] } }));
it('POST /polls 400 missing question', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { options: ['a', 'b'] } }));
it('POST /polls 400 too few options', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { question: 'q', options: ['a'] } }));
it('POST /polls/:id/vote (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } }));
it('POST /polls/:id/vote 404', () => {
collabSvc.votePoll.mockReturnValueOnce({ error: 'not_found' }).mockReturnValueOnce({ error: 'not_found' });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } });
});
it('POST /polls/:id/vote 400 closed', () => {
collabSvc.votePoll.mockReturnValueOnce({ error: 'closed' }).mockReturnValueOnce({ error: 'closed' });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } });
});
it('PUT /polls/:id/close', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/polls/7/close' }));
it('DELETE /polls/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/polls/7' }));
// Messages
it('GET /messages', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/messages' }));
it('POST /messages (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'hi' } }));
it('POST /messages 400 too long', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'x'.repeat(5001) } }));
it('POST /messages 400 empty', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: ' ' } }));
it('POST /messages 400 reply_not_found', () => {
collabSvc.createMessage.mockReturnValueOnce({ error: 'reply_not_found' }).mockReturnValueOnce({ error: 'reply_not_found' });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'hi', reply_to: 99 } });
});
it('POST /messages/:id/react (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages/3/react', body: { emoji: '👍' } }));
it('POST /messages/:id/react 404', () => {
collabSvc.addOrRemoveReaction.mockReturnValueOnce({ found: false, reactions: [] }).mockReturnValueOnce({ found: false, reactions: [] });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages/3/react', body: { emoji: '👍' } });
});
it('DELETE /messages/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/messages/3' }));
it('DELETE /messages/:id 403 not owner', () => {
collabSvc.deleteMessage.mockReturnValueOnce({ error: 'not_owner' }).mockReturnValueOnce({ error: 'not_owner' });
return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/messages/3' });
});
// Link preview
it('GET /link-preview', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/link-preview', query: { url: 'http://x' } }));
it('GET /link-preview 400 missing url', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/link-preview' }));
});
@@ -1,172 +0,0 @@
/**
* C3 parity files (trip file manager) + photos (global photo access).
*
* Same request at the legacy Express routes and the migrated Nest controllers,
* with the file/photo services, permissions, the WebSocket broadcast, demo and
* auth mocked identically. Multipart upload + the sendFile/stream success bodies
* differ per framework (multer vs FileInterceptor, res.sendFile), so this pins
* routing, status codes and the JSON error envelopes including the unguarded
* download's token-auth errors and the photo id/access guards.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => ({ id: 42 }), all: () => [], run: () => undefined }) },
canAccessTrip: vi.fn(() => ({ user_id: 1 })), closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
vi.mock('../../src/middleware/tripAccess', () => ({
requireTripAccess: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { trip: unknown }).trip = { user_id: 1 };
next();
},
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { fileSvc } = vi.hoisted(() => ({
fileSvc: {
// Constants the route + controller read at import time.
MAX_FILE_SIZE: 50 * 1024 * 1024,
BLOCKED_EXTENSIONS: ['.exe', '.svg'],
filesDir: '/tmp/files',
getAllowedExtensions: () => '*',
verifyTripAccess: vi.fn(), formatFile: vi.fn(), resolveFilePath: vi.fn(), authenticateDownload: vi.fn(),
listFiles: vi.fn(), getFileById: vi.fn(), getFileByIdFull: 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(),
},
}));
vi.mock('../../src/services/fileService', () => fileSvc);
vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
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 filesRoutes from '../../src/routes/files';
import photosRoutes from '../../src/routes/photos';
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('C3 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/photos', photosRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [FilesModule, PhotosModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
fileSvc.listFiles.mockReturnValue([{ id: 1, original_name: 'a.pdf' }]);
fileSvc.getFileById.mockReturnValue({ id: 9, starred: 0, description: 'x' });
fileSvc.getDeletedFile.mockReturnValue({ id: 9 });
fileSvc.updateFile.mockReturnValue({ id: 9, description: 'new' });
fileSvc.toggleStarred.mockReturnValue({ id: 9, starred: 1 });
fileSvc.restoreFile.mockReturnValue({ id: 9 });
fileSvc.permanentDeleteFile.mockResolvedValue(undefined);
fileSvc.emptyTrash.mockResolvedValue(2);
fileSvc.createFileLink.mockReturnValue([{ id: 1 }]);
fileSvc.getFileLinks.mockReturnValue([{ id: 1 }]);
fileSvc.authenticateDownload.mockReturnValue({ error: 'Authentication required', status: 401 });
});
beforeEach(() => {
fileSvc.verifyTripAccess.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
helperSvc.canAccessTrekPhoto.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
// Files — JSON endpoints
it('GET /files', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files' }));
it('GET /files?trash=true', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files', query: { trash: 'true' } }));
it('GET /files 404 no access', () => {
fileSvc.verifyTripAccess.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/files' });
});
it('PUT /files/:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: { description: 'new' } }));
it('PUT /files/:id 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: { description: 'x' } });
});
it('PUT /files/:id 404', () => {
fileSvc.getFileById.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: {} });
});
it('PATCH /files/:id/star', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/trips/5/files/9/star' }));
it('DELETE /files/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9' }));
it('POST /files/:id/restore (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/restore' }));
it('POST /files/:id/restore 404 not in trash', () => {
fileSvc.getDeletedFile.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/restore' });
});
it('DELETE /files/:id/permanent', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9/permanent' }));
it('DELETE /files/trash/empty', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/trash/empty' }));
it('POST /files/:id/link (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/link', body: { reservation_id: 2 } }));
it('DELETE /files/:id/link/:linkId', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9/link/3' }));
it('GET /files/:id/links', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/links' }));
// Files — download (unguarded), error paths only (sendFile body differs)
it('GET /files/:id/download 401 (token)', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/download' }));
it('GET /files/:id/download 404 no access', () => {
fileSvc.authenticateDownload.mockReturnValue({ userId: 1 });
fileSvc.verifyTripAccess.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/download' });
});
// Photos — guard paths only (stream/info success writes binary/json via res)
it('GET /photos/:id/thumbnail 400 invalid id', () => expectParity(expressServer, nestServer, { path: '/api/photos/abc/thumbnail' }));
it('GET /photos/:id/original 403 no access', () => {
helperSvc.canAccessTrekPhoto.mockReturnValue(false);
return expectParity(expressServer, nestServer, { path: '/api/photos/5/original' });
});
it('GET /photos/:id/info 403 no access', () => {
helperSvc.canAccessTrekPhoto.mockReturnValue(false);
return expectParity(expressServer, nestServer, { path: '/api/photos/5/info' });
});
});
@@ -1,197 +0,0 @@
/**
* C4 parity journey (authenticated) + public journey share.
*
* Same request at the legacy Express routes and the migrated Nest controllers,
* with journeyService, journeyShareService, the addon gate, db and auth mocked
* identically. Multipart photo uploads + the stream/sendFile success bodies
* differ per framework, so this pins routing, the addon-gate 404, status codes
* (create 201 vs cover/trips/share 200 vs unlink 204) and the JSON envelopes.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => ({ immich_auto_upload: 0 }), all: () => [], run: () => undefined }) },
closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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: {
canAccessJourney: vi.fn(), isOwner: vi.fn(), canEdit: vi.fn(),
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(), updateJourney: vi.fn(),
updateJourneyPreferences: vi.fn(), deleteJourney: vi.fn(), addTripToJourney: vi.fn(), removeTripFromJourney: vi.fn(),
listEntries: vi.fn(), createEntry: vi.fn(), updateEntry: vi.fn(), reorderEntries: vi.fn(), deleteEntry: vi.fn(),
addPhoto: vi.fn(), addProviderPhoto: vi.fn(), linkPhotoToEntry: vi.fn(), uploadGalleryPhotos: vi.fn(),
addProviderPhotoToGallery: vi.fn(), unlinkPhotoFromEntry: vi.fn(), deleteGalleryPhoto: vi.fn(), setPhotoProvider: vi.fn(),
updatePhoto: vi.fn(), deletePhoto: vi.fn(), addContributor: vi.fn(), updateContributorRole: vi.fn(), removeContributor: vi.fn(),
getSuggestions: vi.fn(), listUserTrips: vi.fn(),
},
}));
vi.mock('../../src/services/journeyService', () => jsvc);
const { sharesvc } = vi.hoisted(() => ({
sharesvc: {
createOrUpdateJourneyShareLink: vi.fn(), getJourneyShareLink: vi.fn(), deleteJourneyShareLink: vi.fn(),
getPublicJourney: vi.fn(), validateShareTokenForPhoto: vi.fn(), validateShareTokenForAsset: vi.fn(),
},
}));
vi.mock('../../src/services/journeyShareService', () => sharesvc);
import journeyRoutes from '../../src/routes/journey';
import journeyPublicRoutes from '../../src/routes/journeyPublic';
import { JourneyModule } from '../../src/nest/journey/journey.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ADDON_IDS } from '../../src/addons';
describe('C4 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
// Mirror the app.ts mount gate so both stacks 404 when the addon is off.
app.use('/api/journeys', (_req, res, next) => {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
next();
}, journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
jsvc.getSuggestions.mockReturnValue([{ id: 1 }]);
jsvc.listUserTrips.mockReturnValue([{ id: 2 }]);
jsvc.getJourneyFull.mockReturnValue({ id: 9, title: 'J' });
jsvc.updateJourney.mockReturnValue({ id: 9, title: 'J2' });
jsvc.deleteJourney.mockReturnValue(true);
jsvc.addTripToJourney.mockReturnValue(true);
jsvc.removeTripFromJourney.mockReturnValue(true);
jsvc.listEntries.mockReturnValue([{ id: 1 }]);
jsvc.createEntry.mockReturnValue({ id: 3 });
jsvc.updateEntry.mockReturnValue({ id: 3 });
jsvc.deleteEntry.mockReturnValue(true);
jsvc.reorderEntries.mockReturnValue(true);
jsvc.addProviderPhoto.mockReturnValue({ id: 5 });
jsvc.linkPhotoToEntry.mockReturnValue({ id: 5 });
jsvc.addProviderPhotoToGallery.mockReturnValue({ id: 5 });
jsvc.unlinkPhotoFromEntry.mockReturnValue(true);
jsvc.deleteGalleryPhoto.mockReturnValue({ id: 5, file_path: null });
jsvc.updatePhoto.mockReturnValue({ id: 5 });
jsvc.deletePhoto.mockReturnValue({ id: 5, file_path: null });
jsvc.addContributor.mockReturnValue(true);
jsvc.updateContributorRole.mockReturnValue(true);
jsvc.removeContributor.mockReturnValue(true);
jsvc.updateJourneyPreferences.mockReturnValue({ ok: true });
sharesvc.getJourneyShareLink.mockReturnValue({ token: 'abc' });
sharesvc.createOrUpdateJourneyShareLink.mockReturnValue({ token: 'abc' });
sharesvc.deleteJourneyShareLink.mockReturnValue(true);
sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
});
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('404 when the Journey addon is disabled', () => {
isAddonEnabled.mockReturnValue(false);
return expectParity(expressServer, nestServer, { path: '/api/journeys' });
});
it('GET /journeys', () => expectParity(expressServer, nestServer, { path: '/api/journeys' }));
it('POST /journeys (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys', body: { title: 'J' } }));
it('POST /journeys 400 no title', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys', body: {} }));
it('GET /journeys/suggestions', () => expectParity(expressServer, nestServer, { path: '/api/journeys/suggestions' }));
it('GET /journeys/available-trips', () => expectParity(expressServer, nestServer, { path: '/api/journeys/available-trips' }));
it('PATCH /journeys/entries/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/entries/3', body: { title: 'x' } }));
it('PATCH /journeys/entries/:id 404', () => {
jsvc.updateEntry.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/entries/3', body: {} });
});
it('DELETE /journeys/entries/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/entries/3' }));
it('POST /journeys/entries/:id/provider-photos batch', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/provider-photos', body: { provider: 'immich', asset_ids: ['a', 'b'] } }));
it('POST /journeys/entries/:id/provider-photos 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/provider-photos', body: { provider: 'immich' } }));
it('POST /journeys/entries/:id/link-photo (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/link-photo', body: { journey_photo_id: 5 } }));
it('POST /journeys/entries/:id/link-photo 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/link-photo', body: {} }));
it('DELETE /journeys/entries/:id/photos/:pid (204)', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/entries/3/photos/7' }));
it('PATCH /journeys/photos/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/photos/5', body: { caption: 'c' } }));
it('DELETE /journeys/photos/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/photos/5' }));
it('POST /journeys/:id/gallery/provider-photos batch', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/gallery/provider-photos', body: { provider: 'immich', asset_ids: ['a'] } }));
it('DELETE /journeys/:id/gallery/:pid (204)', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/gallery/7' }));
it('GET /journeys/:id', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9' }));
it('GET /journeys/:id 404', () => {
jsvc.getJourneyFull.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(expressServer, nestServer, { path: '/api/journeys/9' });
});
it('PATCH /journeys/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9', body: { title: 'J2' } }));
it('DELETE /journeys/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9' }));
it('POST /journeys/:id/trips (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/trips', body: { trip_id: 2 } }));
it('POST /journeys/:id/trips 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/trips', body: {} }));
it('DELETE /journeys/:id/trips/:tripId', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/trips/2' }));
it('GET /journeys/:id/entries', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9/entries' }));
it('POST /journeys/:id/entries (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/entries', body: { entry_date: '2026-01-01' } }));
it('POST /journeys/:id/entries 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/entries', body: {} }));
it('PUT /journeys/:id/entries/reorder', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/journeys/9/entries/reorder', body: { orderedIds: [1, 2] } }));
it('PUT /journeys/:id/entries/reorder 400', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/journeys/9/entries/reorder', body: { orderedIds: 'x' } }));
it('POST /journeys/:id/contributors (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/contributors', body: { user_id: 2 } }));
it('POST /journeys/:id/contributors 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/contributors', body: {} }));
it('PATCH /journeys/:id/contributors/:uid', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9/contributors/2', body: { role: 'editor' } }));
it('DELETE /journeys/:id/contributors/:uid', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/contributors/2' }));
it('PATCH /journeys/:id/preferences', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9/preferences', body: { theme: 'dark' } }));
it('GET /journeys/:id/share-link', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9/share-link' }));
it('POST /journeys/:id/share-link (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/share-link', body: { share_timeline: true } }));
it('DELETE /journeys/:id/share-link', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/share-link' }));
// Public
it('GET /public/journey/:token', () => expectParity(expressServer, nestServer, { path: '/api/public/journey/tok' }));
it('GET /public/journey/:token 404', () => {
sharesvc.getPublicJourney.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(expressServer, nestServer, { path: '/api/public/journey/tok' });
});
});
-130
View File
@@ -1,130 +0,0 @@
/**
* L2 parity airports + public config + system notices.
*
* Fires the same request at the legacy Express routes and the migrated Nest
* controllers with the shared services mocked identically for both, then asserts
* the responses are client-identical (status + body). This is the gate before
* the prefixes are flipped to Nest: any difference here is a framework-layer
* regression (routing, error envelope, status), which a migration must not cause.
*
* Auth is neutralised the same way for both apps `verifyJwtAndLoadUser` /
* `extractToken` are stubbed so the real Nest guard and the Express middleware
* both authenticate the same fixed user. Auth behaviour itself is covered by the
* per-module e2e tests.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'parity', email: 'parity@example.test', role: 'user' },
}));
// The services under test are mocked below, so no real DB is needed. Stubbing
// the connection keeps the legacy database.ts init (and its lazy backfill
// require) out of the parity run, which otherwise clashes with the mocked
// airportService module.
vi.mock('../../src/db/database', () => ({ db: {}, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'parity-token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 };
});
const { mockGetActive, mockDismiss } = vi.hoisted(() => ({ mockGetActive: vi.fn(), mockDismiss: vi.fn() }));
vi.mock('../../src/systemNotices/service', () => ({
getActiveNoticesFor: mockGetActive,
dismissNotice: mockDismiss,
}));
import airportsRoutes from '../../src/routes/airports';
import publicConfigRoutes from '../../src/routes/publicConfig';
import systemNoticesRoutes from '../../src/routes/systemNotices';
import { AirportsModule } from '../../src/nest/airports/airports.module';
import { ConfigModule } from '../../src/nest/config/config.module';
import { SystemNoticesModule } from '../../src/nest/system-notices/system-notices.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',
};
const notice = {
id: 'welcome', display: 'modal', severity: 'info',
titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true,
};
describe('L2 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/airports', airportsRoutes);
app.use('/api/config', publicConfigRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({
imports: [AirportsModule, ConfigModule, SystemNoticesModule],
}).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
mockSearch.mockReturnValue([BER]);
mockFindByIata.mockImplementation((code: string) => (code === 'BER' ? BER : null));
mockGetActive.mockReturnValue([notice]);
mockDismiss.mockImplementation((_userId: number, id: string) => id === 'welcome');
});
afterAll(async () => {
await nestApp.close();
});
it('GET /api/airports/search with a query', () =>
expectParity(expressServer, nestServer, { path: '/api/airports/search', query: { q: 'ber' } }));
it('GET /api/airports/search without a query', () =>
expectParity(expressServer, nestServer, { path: '/api/airports/search' }));
it('GET /api/airports/:iata found', () =>
expectParity(expressServer, nestServer, { path: '/api/airports/BER' }));
it('GET /api/airports/:iata not found (404)', () =>
expectParity(expressServer, nestServer, { path: '/api/airports/ZZZ' }));
it('GET /api/config (public)', () =>
expectParity(expressServer, nestServer, { path: '/api/config' }));
it('GET /api/system-notices/active', () =>
expectParity(expressServer, nestServer, { path: '/api/system-notices/active' }));
it('POST /api/system-notices/:id/dismiss success (204)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/system-notices/welcome/dismiss' }));
it('POST /api/system-notices/:id/dismiss not found (404)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/system-notices/nope/dismiss' }));
});
@@ -1,118 +0,0 @@
/**
* C5 parity trip share links + the public shared-trip read.
*
* Same request at the legacy Express /api route and the migrated Nest
* controllers, with shareService, the permission check, the trip-access lookup
* and auth mocked identically. Pins routing, trip-access 404, permission 403,
* the create-201-vs-update-200 split and the unguarded public 404/JSON.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
canAccessTrip, closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 shareRoutes from '../../src/routes/share';
import { ShareModule } from '../../src/nest/share/share.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('C5 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api', shareRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [ShareModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
shareSvc.getShareLink.mockReturnValue({ token: 't', share_map: 1 });
shareSvc.getSharedTripData.mockReturnValue({ trip: { id: 9 } });
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('POST /trips/:id/share-link (201 created)', () => {
shareSvc.createOrUpdateShareLink.mockReturnValue({ token: 't', created: true });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: { share_map: true } });
});
it('POST /trips/:id/share-link (200 update)', () => {
shareSvc.createOrUpdateShareLink.mockReturnValue({ token: 't', created: false });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} });
});
it('POST /trips/:id/share-link 404 no access', () => {
canAccessTrip.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} });
});
it('POST /trips/:id/share-link 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} });
});
it('GET /trips/:id/share-link', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' }));
it('GET /trips/:id/share-link null token', () => {
shareSvc.getShareLink.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' });
});
it('GET /trips/:id/share-link 404 no access', () => {
canAccessTrip.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' });
});
it('DELETE /trips/:id/share-link', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/share-link' }));
it('DELETE /trips/:id/share-link 403', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/share-link' });
});
it('GET /shared/:token', () => expectParity(expressServer, nestServer, { path: '/api/shared/tok' }));
it('GET /shared/:token 404', () => {
shareSvc.getSharedTripData.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(expressServer, nestServer, { path: '/api/shared/bad' });
});
});
@@ -1,77 +0,0 @@
/**
* C6 parity user settings.
*
* Same request at the legacy Express /api/settings route and the migrated Nest
* controller, with settingsService and auth mocked identically. Pins routing,
* the 400 guards, the masked-sentinel no-op and the bulk 200.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
const { settingsSvc } = vi.hoisted(() => ({
settingsSvc: { getUserSettings: vi.fn(), upsertSetting: vi.fn(), bulkUpsertSettings: vi.fn() },
}));
vi.mock('../../src/services/settingsService', () => settingsSvc);
import settingsRoutes from '../../src/routes/settings';
import { SettingsModule } from '../../src/nest/settings/settings.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('C6 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/settings', settingsRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [SettingsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
settingsSvc.getUserSettings.mockReturnValue({ theme: 'dark' });
settingsSvc.bulkUpsertSettings.mockReturnValue(2);
});
afterAll(async () => {
await nestApp.close();
});
it('GET /settings', () => expectParity(expressServer, nestServer, { path: '/api/settings' }));
it('PUT /settings', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { key: 'theme', value: 'dark' } }));
it('PUT /settings 400 no key', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { value: 'x' } }));
it('PUT /settings masked sentinel no-op', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { key: 'k', value: '••••••••' } }));
it('POST /settings/bulk (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/settings/bulk', body: { settings: { a: 1, b: 2 } } }));
it('POST /settings/bulk 400 no object', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/settings/bulk', body: {} }));
});
@@ -1,121 +0,0 @@
/**
* C7 parity backup (admin-only).
*
* Same request at the legacy Express /api/backup route and the migrated Nest
* controller, with backupService, auditLog and auth mocked identically (the
* fixed user is an admin so both the legacy adminOnly and the Nest AdminGuard
* pass). Multipart upload + res.download success differ per framework, so this
* pins routing, the rate-limit 429, filename 400/404, restore status mapping
* and the auto-settings/list/delete JSON.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedAdmin } = vi.hoisted(() => ({ fixedAdmin: { id: 1, username: 'a', email: 'a@example.test', role: 'admin' } }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedAdmin;
next();
},
adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedAdmin,
}));
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 backupRoutes from '../../src/routes/backup';
import { BackupModule } from '../../src/nest/backup/backup.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('C7 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/backup', backupRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [BackupModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
backupSvc.listBackups.mockReturnValue([{ filename: 'a.zip', size: 1 }]);
backupSvc.createBackup.mockResolvedValue({ filename: 'b.zip', size: 10 });
backupSvc.getAutoSettings.mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' });
backupSvc.updateAutoSettings.mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 });
backupSvc.restoreFromZip.mockResolvedValue({ success: true });
});
beforeEach(() => {
backupSvc.isValidBackupFilename.mockReturnValue(true);
backupSvc.backupFileExists.mockReturnValue(true);
backupSvc.checkRateLimit.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('GET /backup/list', () => expectParity(expressServer, nestServer, { path: '/api/backup/list' }));
it('POST /backup/create', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/create' }));
it('POST /backup/create 429 rate-limited', () => {
backupSvc.checkRateLimit.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/create' });
});
it('GET /backup/download/:f 400 invalid', () => {
backupSvc.isValidBackupFilename.mockReturnValue(false);
return expectParity(expressServer, nestServer, { path: '/api/backup/download/bad' });
});
it('GET /backup/download/:f 404 missing', () => {
backupSvc.backupFileExists.mockReturnValue(false);
return expectParity(expressServer, nestServer, { path: '/api/backup/download/x.zip' });
});
it('POST /backup/restore/:f', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/x.zip' }));
it('POST /backup/restore/:f 400 invalid', () => {
backupSvc.isValidBackupFilename.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/bad' });
});
it('POST /backup/restore/:f maps the service status', () => {
backupSvc.restoreFromZip.mockResolvedValueOnce({ success: false, status: 422, error: 'bad zip' }).mockResolvedValueOnce({ success: false, status: 422, error: 'bad zip' });
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/x.zip' });
});
it('GET /backup/auto-settings', () => expectParity(expressServer, nestServer, { path: '/api/backup/auto-settings' }));
it('PUT /backup/auto-settings', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/backup/auto-settings', body: { enabled: true } }));
it('DELETE /backup/:f', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/backup/x.zip' }));
it('DELETE /backup/:f 404', () => {
backupSvc.backupFileExists.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/backup/x.zip' });
});
});
-158
View File
@@ -1,158 +0,0 @@
/**
* A1 parity auth (public flows + authenticated account/MFA/token endpoints).
*
* Same request at the legacy Express /api/auth route and the migrated Nest
* controllers, with authService, the cookie service, notifications, auditLog and
* auth middleware mocked identically. Cookies are a header side-effect (not
* compared) and the rate-limit 429 + multipart avatar are covered in the unit
* tests; this pins routing, status codes (register/mcp-token 201 vs the rest
* 200), the login/reset MFA branches and the {error,status} envelopes.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
optionalAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
vi.mock('../../src/services/cookie', () => ({ setAuthCookie: vi.fn(), clearAuthCookie: vi.fn() }));
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(), requestPasswordReset_unused: vi.fn(),
},
}));
vi.mock('../../src/services/authService', () => authSvc);
import authRoutes from '../../src/routes/auth';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('A1 parity (Express vs Nest)', () => {
let ex: express.Express;
let ne: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/auth', authRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [AuthModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
ex = buildExpress();
nestApp = await buildNest();
ne = nestApp.getHttpServer();
authSvc.getAppConfig.mockReturnValue({ version: '3', features: {} });
authSvc.demoLogin.mockReturnValue({ token: 'tk', user: fixedUser });
authSvc.validateInviteToken.mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null });
authSvc.registerUser.mockReturnValue({ token: 'tk', user: fixedUser, auditUserId: 1, auditDetails: {} });
authSvc.loginUser.mockReturnValue({ token: 'tk', user: fixedUser });
authSvc.requestPasswordReset.mockReturnValue({ reason: 'no_user', userId: null });
authSvc.resetPassword.mockReturnValue({ userId: 1 });
authSvc.verifyMfaLogin.mockReturnValue({ token: 'tk', user: fixedUser, auditUserId: 1 });
authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' });
authSvc.changePassword.mockReturnValue({});
authSvc.deleteAccount.mockReturnValue({});
authSvc.updateMapsKey.mockReturnValue({ success: true });
authSvc.updateApiKeys.mockReturnValue({ success: true });
authSvc.updateSettings.mockReturnValue({ success: true, user: fixedUser });
authSvc.getSettings.mockReturnValue({ settings: { theme: 'dark' } });
authSvc.deleteAvatar.mockResolvedValue({ success: true });
authSvc.listUsers.mockReturnValue([{ id: 1 }]);
authSvc.validateKeys.mockResolvedValue({ maps: true, weather: true, maps_details: {} });
authSvc.getAppSettings.mockReturnValue({ data: { foo: 'bar' } });
authSvc.updateAppSettings.mockReturnValue({ auditSummary: {}, auditDebugDetails: {} });
authSvc.getTravelStats.mockReturnValue({ trips: 5 });
authSvc.enableMfa.mockReturnValue({ mfa_enabled: true, backup_codes: ['a'] });
authSvc.disableMfa.mockReturnValue({ mfa_enabled: false });
authSvc.listMcpTokens.mockReturnValue([{ id: 't1' }]);
authSvc.createMcpToken.mockReturnValue({ token: 'mcp_x' });
authSvc.deleteMcpToken.mockReturnValue({});
authSvc.createWsToken.mockReturnValue({ token: 'ws_x' });
authSvc.createResourceToken.mockReturnValue({ token: 'rt_x' });
});
afterAll(async () => { await nestApp.close(); });
it('GET /app-config', () => expectParity(ex, ne, { path: '/api/auth/app-config' }));
it('POST /demo-login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/demo-login' }));
it('GET /invite/:token', () => expectParity(ex, ne, { path: '/api/auth/invite/tok' }));
it('POST /register (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/register', body: { email: 'a@b.c', password: 'p' } }));
it('POST /login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: { email: 'a@b.c', password: 'p' } }));
it('POST /login mfa branch', () => {
authSvc.loginUser.mockReturnValueOnce({ mfa_required: true, mfa_token: 'mt' }).mockReturnValueOnce({ mfa_required: true, mfa_token: 'mt' });
return expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: {} });
});
it('POST /login 401', () => {
authSvc.loginUser.mockReturnValueOnce({ error: 'Bad creds', status: 401 }).mockReturnValueOnce({ error: 'Bad creds', status: 401 });
return expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: {} });
});
it('POST /forgot-password', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/forgot-password', body: { email: 'a@b.c' } }));
it('POST /reset-password', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/reset-password', body: { token: 't', password: 'p' } }));
it('POST /reset-password mfa branch', () => {
authSvc.resetPassword.mockReturnValueOnce({ mfa_required: true }).mockReturnValueOnce({ mfa_required: true });
return expectParity(ex, ne, { method: 'post', path: '/api/auth/reset-password', body: {} });
});
it('POST /logout', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/logout' }));
it('POST /mfa/verify-login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/verify-login', body: { mfa_token: 't', code: '1' } }));
it('GET /me', () => expectParity(ex, ne, { path: '/api/auth/me' }));
it('GET /me 404', () => {
authSvc.getCurrentUser.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined);
return expectParity(ex, ne, { path: '/api/auth/me' });
});
it('PUT /me/password', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/password', body: { current_password: 'a', new_password: 'b' } }));
it('DELETE /me', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/me' }));
it('PUT /me/maps-key', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/maps-key', body: { maps_api_key: 'k' } }));
it('PUT /me/api-keys', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/api-keys', body: {} }));
it('PUT /me/settings', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/settings', body: {} }));
it('GET /me/settings', () => expectParity(ex, ne, { path: '/api/auth/me/settings' }));
it('DELETE /avatar', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/avatar' }));
it('GET /users', () => expectParity(ex, ne, { path: '/api/auth/users' }));
it('GET /validate-keys', () => expectParity(ex, ne, { path: '/api/auth/validate-keys' }));
it('GET /app-settings', () => expectParity(ex, ne, { path: '/api/auth/app-settings' }));
it('PUT /app-settings', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/app-settings', body: {} }));
it('GET /travel-stats', () => expectParity(ex, ne, { path: '/api/auth/travel-stats' }));
it('POST /mfa/enable', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/enable', body: { code: '1' } }));
it('POST /mfa/disable', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/disable', body: {} }));
it('GET /mcp-tokens', () => expectParity(ex, ne, { path: '/api/auth/mcp-tokens' }));
it('POST /mcp-tokens (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mcp-tokens', body: { name: 'CLI' } }));
it('DELETE /mcp-tokens/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/mcp-tokens/t1' }));
it('POST /ws-token', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/ws-token' }));
it('POST /resource-token', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/resource-token', body: { purpose: 'download' } }));
it('POST /resource-token 503', () => {
authSvc.createResourceToken.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(ex, ne, { method: 'post', path: '/api/auth/resource-token', body: {} });
});
});
-105
View File
@@ -1,105 +0,0 @@
/**
* A2 parity OIDC SSO.
*
* Same request at the legacy Express /api/auth/oidc route and the migrated Nest
* controller, with oidcService, authService.resolveAuthToggles, the cookie
* service and getAppUrl mocked identically. Redirects compare by status (302,
* same Location by construction); the disabled/not-configured/exchange branches
* compare the JSON bodies. supertest does not follow redirects, so 302 bodies
* stay empty on both sides.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
vi.mock('../../src/services/cookie', () => ({ setAuthCookie: vi.fn() }));
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 oidcRoutes from '../../src/routes/oidc';
import { OidcModule } from '../../src/nest/oidc/oidc.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('A2 parity (Express vs Nest)', () => {
let ex: express.Express;
let ne: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/auth/oidc', oidcRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [OidcModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
ex = buildExpress();
nestApp = await buildNest();
ne = nestApp.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.consumeState.mockReturnValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv' });
oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt' });
});
beforeEach(() => { toggles.oidc_login = true; });
afterAll(async () => { await nestApp.close(); });
it('GET /login redirects (302)', () => expectParity(ex, ne, { path: '/api/auth/oidc/login' }));
it('GET /login 403 when SSO disabled', () => {
toggles.oidc_login = false;
return expectParity(ex, ne, { path: '/api/auth/oidc/login' });
});
it('GET /login 400 not configured', () => {
oidcSvc.getOidcConfig.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(ex, ne, { path: '/api/auth/oidc/login' });
});
it('GET /callback redirects on missing params', () => expectParity(ex, ne, { path: '/api/auth/oidc/callback' }));
it('GET /callback redirects with provider error', () => expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { error: 'access_denied' } }));
it('GET /callback redirects on invalid state', () => {
oidcSvc.consumeState.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { code: 'c', state: 's' } });
});
it('GET /callback completes the full flow with an auth-code redirect', () => {
// Drive the whole success chain so the service wrappers (exchange/verify/
// userinfo/provision/token/auth-code) run on both stacks.
oidcSvc.consumeState.mockReturnValueOnce({ redirectUri: 'https://app/cb', codeVerifier: 'cv' }).mockReturnValueOnce({ redirectUri: 'https://app/cb', codeVerifier: 'cv' });
oidcSvc.exchangeCodeForToken.mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' });
oidcSvc.verifyIdToken.mockResolvedValue({ ok: true, claims: { sub: 'u1' } });
oidcSvc.getUserInfo.mockResolvedValue({ email: 'a@b.c', sub: 'u1' });
oidcSvc.findOrCreateUser.mockReturnValue({ user: { id: 1 } });
oidcSvc.generateToken.mockReturnValue('jwt');
oidcSvc.createAuthCode.mockReturnValue('ac');
return expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { code: 'c', state: 's' } });
});
it('GET /exchange 400 without a code', () => expectParity(ex, ne, { path: '/api/auth/oidc/exchange' }));
it('GET /exchange 400 on an invalid code', () => {
oidcSvc.consumeAuthCode.mockReturnValueOnce({ error: 'invalid_code' }).mockReturnValueOnce({ error: 'invalid_code' });
return expectParity(ex, ne, { path: '/api/auth/oidc/exchange', query: { code: 'bad' } });
});
it('GET /exchange sets cookie + returns token', () => expectParity(ex, ne, { path: '/api/auth/oidc/exchange', query: { code: 'good' } }));
});
@@ -1,138 +0,0 @@
/**
* A3 parity OAuth 2.1 server (public token/userinfo/revoke + the SPA's
* /api/oauth management endpoints).
*
* Same request at the legacy Express routers and the migrated Nest controllers,
* with oauthService, the MCP addon gate, getMcpSafeUrl, auditLog and auth
* mocked identically. The Nest app gets cookie-parser and the cookie-auth
* routes are sent a trek_session cookie (the legacy mocks ignore it). Pins the
* grant branches, RFC error bodies, the empty-404 gate and the consent redirect.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } }));
const COOKIE = { Cookie: 'trek_session=x' };
vi.mock('../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
optionalAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
requireCookieAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); },
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
vi.mock('../../src/services/notifications', () => ({ getMcpSafeUrl: () => 'https://app' }));
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logWarn: vi.fn() }));
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 { oauthPublicRouter, oauthApiRouter } from '../../src/routes/oauth';
import { OauthModule } from '../../src/nest/oauth/oauth.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('A3 parity (Express vs Nest)', () => {
let ex: express.Express;
let ne: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/oauth', oauthApiRouter);
app.use('/', oauthPublicRouter);
return app;
}
async function buildNest() {
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(() => {});
ex = buildExpress();
nestApp = await buildNest();
ne = nestApp.getHttpServer();
oauthSvc.getUserByAccessToken.mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } });
oauthSvc.authenticateClient.mockReturnValue({ id: 'c', is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' });
oauthSvc.listOAuthClients.mockReturnValue([{ id: 'c1' }]);
oauthSvc.listOAuthSessions.mockReturnValue([{ id: 1 }]);
oauthSvc.createOAuthClient.mockReturnValue({ client_id: 'c1', client_secret: 's' });
oauthSvc.deleteOAuthClient.mockReturnValue({});
oauthSvc.revokeSession.mockReturnValue({});
oauthSvc.validateAuthorizeRequest.mockReturnValue({ valid: true, scopes: ['s'], resource: null, client_name: 'CLI', allowed_scopes: ['s'] });
oauthSvc.createAuthCode.mockReturnValue('the_code');
});
beforeEach(() => { isAddonEnabled.mockReturnValue(true); });
afterAll(async () => { await nestApp.close(); });
// Public — token
it('POST /oauth/token 401 without client_id', () => expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: {} }));
it('POST /oauth/token unsupported grant', () => expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { client_id: 'c', grant_type: 'password' } }));
it('POST /oauth/token authorization_code invalid_grant', () => {
oauthSvc.consumeAuthCode.mockReturnValueOnce(null).mockReturnValueOnce(null);
return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' } });
});
it('POST /oauth/token authorization_code success', () => {
oauthSvc.consumeAuthCode.mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null });
oauthSvc.verifyPKCE.mockReturnValue(true);
oauthSvc.issueTokens.mockReturnValue({ access_token: 'at', token_type: 'Bearer', expires_in: 3600 });
return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' } });
});
it('POST /oauth/token client_credentials success', () => {
oauthSvc.issueClientCredentialsToken.mockReturnValue({ access_token: 'cc_at', token_type: 'Bearer' });
return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'client_credentials', client_id: 'c', client_secret: 's' } });
});
it('POST /oauth/token 404 when MCP disabled', () => {
isAddonEnabled.mockReturnValue(false);
return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { client_id: 'c' } });
});
// Public — userinfo + revoke
it('GET /oauth/userinfo 401 without Bearer', () => expectParity(ex, ne, { path: '/oauth/userinfo' }));
it('GET /oauth/userinfo with Bearer', () => expectParity(ex, ne, { path: '/oauth/userinfo', headers: { Authorization: 'Bearer tok' } }));
it('POST /oauth/revoke 400 without token', () => expectParity(ex, ne, { method: 'post', path: '/oauth/revoke', body: { client_id: 'c' } }));
it('POST /oauth/revoke 200', () => expectParity(ex, ne, { method: 'post', path: '/oauth/revoke', body: { token: 't', client_id: 'c' } }));
// API — validate / authorize / clients / sessions
it('GET /api/oauth/authorize/validate', () => expectParity(ex, ne, { path: '/api/oauth/authorize/validate', query: { response_type: 'code', client_id: 'c', redirect_uri: 'u', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256' }, headers: COOKIE }));
it('GET /api/oauth/authorize/validate 404 MCP off', () => {
isAddonEnabled.mockReturnValue(false);
return expectParity(ex, ne, { path: '/api/oauth/authorize/validate', headers: COOKIE });
});
it('POST /api/oauth/authorize denied redirect', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/authorize', headers: COOKIE, body: { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false } }));
it('POST /api/oauth/authorize approved redirect', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/authorize', headers: COOKIE, body: { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true } }));
it('GET /api/oauth/clients', () => expectParity(ex, ne, { path: '/api/oauth/clients', headers: COOKIE }));
it('POST /api/oauth/clients (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/clients', headers: COOKIE, body: { name: 'CLI', allowed_scopes: ['a'] } }));
it('DELETE /api/oauth/clients/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/oauth/clients/c1', headers: COOKIE }));
it('GET /api/oauth/sessions', () => expectParity(ex, ne, { path: '/api/oauth/sessions', headers: COOKIE }));
it('DELETE /api/oauth/sessions/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/oauth/sessions/1', headers: COOKIE }));
it('GET /api/oauth/clients 403 MCP off', () => {
isAddonEnabled.mockReturnValue(false);
return expectParity(ex, ne, { path: '/api/oauth/clients', headers: COOKIE });
});
});
@@ -1,182 +0,0 @@
/**
* A4 parity admin control surface.
*
* Same request at the legacy Express /api/admin route and the migrated Nest
* controller, with adminService, the settings/MCP/notification-pref helpers,
* auditLog and auth mocked identically (the fixed user is an admin so both the
* legacy adminOnly and the Nest AdminGuard pass). Pins routing, the create-201
* vs 200 split, the {error,status} envelopes and the validation 400s across a
* representative slice of each sub-domain.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedAdmin } = vi.hoisted(() => ({ fixedAdmin: { id: 1, username: 'a', email: 'a@example.test', role: 'admin' } }));
vi.mock('../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedAdmin; next(); },
adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedAdmin,
}));
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(() => ({ matrix: {} })), setAdminPreferences: vi.fn() }));
vi.mock('../../src/services/settingsService', () => ({ getAdminUserDefaults: vi.fn(() => ({ theme: 'dark' })), setAdminUserDefaults: vi.fn() }));
const { adminSvc } = vi.hoisted(() => ({
adminSvc: {
listUsers: vi.fn(), createUser: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), getStats: vi.fn(),
getPermissions: vi.fn(), savePermissions: vi.fn(), getAuditLog: vi.fn(), getOidcSettings: vi.fn(), updateOidcSettings: vi.fn(),
saveDemoBaseline: vi.fn(), getGithubReleases: vi.fn(), checkVersion: vi.fn(), listInvites: vi.fn(), createInvite: vi.fn(),
deleteInvite: vi.fn(), getBagTracking: vi.fn(), updateBagTracking: vi.fn(), getPlacesPhotos: vi.fn(), updatePlacesPhotos: vi.fn(),
getPlacesAutocomplete: vi.fn(), updatePlacesAutocomplete: vi.fn(), getPlacesDetails: vi.fn(), updatePlacesDetails: vi.fn(),
getCollabFeatures: vi.fn(), updateCollabFeatures: vi.fn(), listPackingTemplates: vi.fn(), getPackingTemplate: vi.fn(),
createPackingTemplate: vi.fn(), updatePackingTemplate: vi.fn(), deletePackingTemplate: vi.fn(), createTemplateCategory: vi.fn(),
updateTemplateCategory: vi.fn(), deleteTemplateCategory: vi.fn(), createTemplateItem: vi.fn(), updateTemplateItem: vi.fn(),
deleteTemplateItem: vi.fn(), listAddons: vi.fn(), updateAddon: vi.fn(), listMcpTokens: vi.fn(), deleteMcpToken: vi.fn(),
listOAuthSessions: vi.fn(), revokeOAuthSession: vi.fn(), rotateJwtSecret: vi.fn(),
},
}));
vi.mock('../../src/services/adminService', () => adminSvc);
import adminRoutes from '../../src/routes/admin';
import { AdminModule } from '../../src/nest/admin/admin.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('A4 parity (Express vs Nest)', () => {
let ex: express.Express;
let ne: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/admin', adminRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [AdminModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
ex = buildExpress();
nestApp = await buildNest();
ne = nestApp.getHttpServer();
adminSvc.listUsers.mockReturnValue([{ id: 1 }]);
adminSvc.createUser.mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} });
adminSvc.updateUser.mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] });
adminSvc.deleteUser.mockReturnValue({ email: 'a@b.c' });
adminSvc.getStats.mockReturnValue({ users: 3 });
adminSvc.getPermissions.mockReturnValue({ permissions: {} });
adminSvc.savePermissions.mockReturnValue({ permissions: { x: 1 }, skipped: [] });
adminSvc.getAuditLog.mockReturnValue({ entries: [] });
adminSvc.getOidcSettings.mockReturnValue({ issuer: '' });
adminSvc.updateOidcSettings.mockReturnValue({});
adminSvc.getGithubReleases.mockResolvedValue({ releases: [] });
adminSvc.checkVersion.mockResolvedValue({ current: '3', latest: '3' });
adminSvc.listInvites.mockReturnValue([]);
adminSvc.createInvite.mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 });
adminSvc.deleteInvite.mockReturnValue({});
adminSvc.getBagTracking.mockReturnValue({ enabled: false });
adminSvc.updateBagTracking.mockReturnValue({ enabled: true });
adminSvc.updatePlacesPhotos.mockReturnValue({ enabled: true });
adminSvc.getPlacesPhotos.mockReturnValue({ enabled: false });
adminSvc.getPlacesAutocomplete.mockReturnValue({ enabled: false });
adminSvc.updatePlacesAutocomplete.mockReturnValue({ enabled: true });
adminSvc.getPlacesDetails.mockReturnValue({ enabled: false });
adminSvc.updatePlacesDetails.mockReturnValue({ enabled: true });
adminSvc.updatePackingTemplate.mockReturnValue({ id: 3, name: 'B2' });
adminSvc.createTemplateCategory.mockReturnValue({ id: 4 });
adminSvc.updateTemplateCategory.mockReturnValue({ id: 4 });
adminSvc.deleteTemplateCategory.mockReturnValue({});
adminSvc.updateTemplateItem.mockReturnValue({ id: 7 });
adminSvc.deleteTemplateItem.mockReturnValue({});
adminSvc.getCollabFeatures.mockReturnValue({ chat: true });
adminSvc.updateCollabFeatures.mockReturnValue({ chat: false });
adminSvc.listPackingTemplates.mockReturnValue([]);
adminSvc.getPackingTemplate.mockReturnValue({ id: 3 });
adminSvc.createPackingTemplate.mockReturnValue({ id: 3, name: 'Beach' });
adminSvc.deletePackingTemplate.mockReturnValue({ name: 'Beach' });
adminSvc.createTemplateItem.mockReturnValue({ id: 7 });
adminSvc.listAddons.mockReturnValue([{ id: 'mcp' }]);
adminSvc.updateAddon.mockReturnValue({ addon: { id: 'mcp', enabled: true }, auditDetails: {} });
adminSvc.listMcpTokens.mockReturnValue([]);
adminSvc.deleteMcpToken.mockReturnValue({});
adminSvc.listOAuthSessions.mockReturnValue([]);
adminSvc.revokeOAuthSession.mockReturnValue({});
adminSvc.rotateJwtSecret.mockReturnValue({});
});
beforeEach(() => { delete process.env.NODE_ENV; });
afterAll(async () => { await nestApp.close(); });
it('GET /users', () => expectParity(ex, ne, { path: '/api/admin/users' }));
it('POST /users (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/users', body: { email: 'a@b.c' } }));
it('POST /users error', () => {
adminSvc.createUser.mockReturnValueOnce({ error: 'taken', status: 409 }).mockReturnValueOnce({ error: 'taken', status: 409 });
return expectParity(ex, ne, { method: 'post', path: '/api/admin/users', body: {} });
});
it('PUT /users/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/users/2', body: { role: 'admin' } }));
it('DELETE /users/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/users/2' }));
it('GET /stats', () => expectParity(ex, ne, { path: '/api/admin/stats' }));
it('GET /permissions', () => expectParity(ex, ne, { path: '/api/admin/permissions' }));
it('PUT /permissions 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/permissions', body: {} }));
it('PUT /permissions', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/permissions', body: { permissions: { x: 1 } } }));
it('GET /audit-log', () => expectParity(ex, ne, { path: '/api/admin/audit-log', query: { limit: '10' } }));
it('GET /oidc', () => expectParity(ex, ne, { path: '/api/admin/oidc' }));
it('PUT /oidc', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/oidc', body: { issuer: 'https://idp' } }));
it('POST /save-demo-baseline error', () => {
adminSvc.saveDemoBaseline.mockReturnValueOnce({ error: 'not demo', status: 400 }).mockReturnValueOnce({ error: 'not demo', status: 400 });
return expectParity(ex, ne, { method: 'post', path: '/api/admin/save-demo-baseline' });
});
it('GET /github-releases', () => expectParity(ex, ne, { path: '/api/admin/github-releases' }));
it('GET /version-check', () => expectParity(ex, ne, { path: '/api/admin/version-check' }));
it('GET /notification-preferences', () => expectParity(ex, ne, { path: '/api/admin/notification-preferences' }));
it('GET /invites', () => expectParity(ex, ne, { path: '/api/admin/invites' }));
it('POST /invites (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/invites', body: { max_uses: 1 } }));
it('DELETE /invites/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/invites/5' }));
it('PUT /bag-tracking', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/bag-tracking', body: { enabled: true } }));
it('PUT /places-photos 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-photos', body: { enabled: 'yes' } }));
it('PUT /places-photos', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-photos', body: { enabled: true } }));
it('PUT /collab-features', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/collab-features', body: { chat: false } }));
it('GET /places-photos', () => expectParity(ex, ne, { path: '/api/admin/places-photos' }));
it('GET /places-autocomplete', () => expectParity(ex, ne, { path: '/api/admin/places-autocomplete' }));
it('PUT /places-autocomplete', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-autocomplete', body: { enabled: true } }));
it('GET /places-details', () => expectParity(ex, ne, { path: '/api/admin/places-details' }));
it('PUT /places-details', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-details', body: { enabled: true } }));
it('PUT /packing-templates/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3', body: { name: 'B2' } }));
it('POST /packing-templates/:id/categories (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates/3/categories', body: { name: 'Cat' } }));
it('PUT /packing-templates/:t/categories/:c', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3/categories/4', body: { name: 'C2' } }));
it('DELETE /packing-templates/:t/categories/:c', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3/categories/4' }));
it('PUT /packing-templates/:t/items/:i', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3/items/7', body: { name: 'I2' } }));
it('DELETE /packing-templates/:t/items/:i', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3/items/7' }));
it('DELETE /mcp-tokens/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/mcp-tokens/t1' }));
it('PUT /notification-preferences', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/notification-preferences', body: {} }));
it('GET /packing-templates', () => expectParity(ex, ne, { path: '/api/admin/packing-templates' }));
it('GET /packing-templates/:id', () => expectParity(ex, ne, { path: '/api/admin/packing-templates/3' }));
it('POST /packing-templates (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates', body: { name: 'Beach' } }));
it('DELETE /packing-templates/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3' }));
it('POST /packing-templates/:t/categories/:c/items (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates/3/categories/4/items', body: { name: 'Towel' } }));
it('GET /addons', () => expectParity(ex, ne, { path: '/api/admin/addons' }));
it('PUT /addons/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/addons/mcp', body: { enabled: true } }));
it('GET /mcp-tokens', () => expectParity(ex, ne, { path: '/api/admin/mcp-tokens' }));
it('GET /oauth-sessions', () => expectParity(ex, ne, { path: '/api/admin/oauth-sessions' }));
it('DELETE /oauth-sessions/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/oauth-sessions/3' }));
it('POST /rotate-jwt-secret', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/rotate-jwt-secret' }));
it('GET /default-user-settings', () => expectParity(ex, ne, { path: '/api/admin/default-user-settings' }));
it('PUT /default-user-settings 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/default-user-settings', body: [] }));
it('PUT /default-user-settings', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/default-user-settings', body: { theme: 'dark' } }));
});
-145
View File
@@ -1,145 +0,0 @@
/**
* L3 parity maps / geo.
*
* Fires the same request at the legacy Express /api/maps route and the migrated
* Nest controller with mapsService mocked identically for both, asserting
* client-identical status + body. Covers the JSON endpoints; the file-serving
* /place-photo/:placeId/bytes route is covered by the controller unit test.
*
* The per-endpoint kill-switches read app_settings; the stubbed DB returns no
* rows, so every switch reads as "enabled" the disabled short-circuits are
* covered by the unit + e2e tests. Auth is neutralised identically for both apps.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'parity', email: 'parity@example.test', role: 'user' },
}));
// Stub DB: every app_settings lookup misses -> kill-switches read as enabled.
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'parity-token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 mapsRoutes from '../../src/routes/maps';
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('L3 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/maps', mapsRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, MapsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
mocks.searchPlaces.mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' });
mocks.autocompletePlaces.mockResolvedValue({ suggestions: [{ placeId: 'p', mainText: 'Berlin', secondaryText: 'DE' }], source: 'osm' });
mocks.getPlaceDetails.mockResolvedValue({ place: { id: 'p1', name: 'Spot' } });
mocks.getPlaceDetailsExpanded.mockResolvedValue({ place: { id: 'p1', name: 'Spot', expanded: true } });
mocks.getPlacePhoto.mockResolvedValue({ photoUrl: 'http://x/y.jpg', attribution: 'CC' });
mocks.reverseGeocode.mockResolvedValue({ name: 'Spot', address: 'Street 1' });
mocks.resolveGoogleMapsUrl.mockResolvedValue({ lat: 52.5, lng: 13.4, name: null, address: null });
});
afterAll(async () => {
await nestApp.close();
});
it('POST /search success', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: { query: 'berlin' } }));
it('POST /search missing query (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: {} }));
it('POST /search service error', async () => {
mocks.searchPlaces.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 }));
mocks.searchPlaces.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 }));
await expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: { query: 'x' } });
});
it('POST /autocomplete success', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: { input: 'ber' } }));
it('POST /autocomplete missing input (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: {} }));
it('POST /autocomplete too long (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: { input: 'x'.repeat(201) } }));
it('POST /autocomplete invalid locationBias (400)', () =>
expectParity(expressServer, nestServer, {
method: 'post', path: '/api/maps/autocomplete',
body: { input: 'ber', locationBias: { low: { lat: 1, lng: 'no' }, high: { lat: 2, lng: 3 } } },
}));
it('GET /details/:placeId', () =>
expectParity(expressServer, nestServer, { path: '/api/maps/details/p1' }));
it('GET /details/:placeId?expand=full', () =>
expectParity(expressServer, nestServer, { path: '/api/maps/details/p1', query: { expand: 'full' } }));
it('GET /place-photo/:placeId', () =>
expectParity(expressServer, nestServer, { path: '/api/maps/place-photo/p1', query: { lat: '1', lng: '2' } }));
it('GET /reverse success', () =>
expectParity(expressServer, nestServer, { path: '/api/maps/reverse', query: { lat: '52.5', lng: '13.4' } }));
it('GET /reverse missing lat/lng (400)', () =>
expectParity(expressServer, nestServer, { path: '/api/maps/reverse', query: { lat: '52.5' } }));
it('POST /resolve-url success', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/resolve-url', body: { url: 'https://maps.app.goo.gl/x' } }));
it('POST /resolve-url missing url (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/resolve-url', body: {} }));
});
-107
View File
@@ -1,107 +0,0 @@
/**
* L4 parity categories CRUD.
*
* Fires the same request at the legacy Express /api/categories route and the
* migrated Nest controller with categoryService mocked identically for both,
* asserting client-identical status + body. Auth + admin are neutralised the
* same way for both apps (a fixed admin user); the 401/403 paths are covered by
* the e2e test against the real guards.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { adminUser } = vi.hoisted(() => ({
adminUser: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin' },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = adminUser;
next();
},
adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
extractToken: () => 'token',
verifyJwtAndLoadUser: () => adminUser,
}));
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 categoriesRoutes from '../../src/routes/categories';
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('L4 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/categories', categoriesRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CategoriesModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
mocks.listCategories.mockReturnValue([cat]);
mocks.createCategory.mockReturnValue(cat);
mocks.updateCategory.mockReturnValue({ ...cat, name: 'Drinks' });
mocks.getCategoryById.mockImplementation((id: string | number) => (String(id) === '1' ? cat : undefined));
});
afterAll(async () => {
await nestApp.close();
});
it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/categories' }));
it('POST / create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/categories', body: { name: 'Food', color: '#fff', icon: '🍔' } }));
it('POST / missing name (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/categories', body: {} }));
it('PUT /:id found (200)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/categories/1', body: { name: 'Drinks' } }));
it('PUT /:id not found (404)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/categories/9', body: { name: 'X' } }));
it('DELETE /:id found (200)', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/categories/1' }));
it('DELETE /:id not found (404)', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/categories/9' }));
});
-104
View File
@@ -1,104 +0,0 @@
/**
* L5 parity tags CRUD.
*
* Fires the same request at the legacy Express /api/tags route and the migrated
* Nest controller with tagService mocked identically for both, asserting
* client-identical status + body. Auth is neutralised identically (a fixed user);
* the 401 path is covered by the e2e test against the real guard.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({
fixedUser: { id: 5, username: 'u', email: 'u@example.test', role: 'user' },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 tagsRoutes from '../../src/routes/tags';
import { TagsModule } from '../../src/nest/tags/tags.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const tag = { id: 1, user_id: 5, name: 'Beach', color: '#10b981' };
describe('L5 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/tags', tagsRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [TagsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
mocks.listTags.mockReturnValue([tag]);
mocks.createTag.mockReturnValue(tag);
mocks.updateTag.mockReturnValue({ ...tag, name: 'Hike' });
mocks.getTagByIdAndUser.mockImplementation((id: string | number) => (String(id) === '1' ? tag : undefined));
});
afterAll(async () => {
await nestApp.close();
});
it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/tags' }));
it('POST / create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/tags', body: { name: 'Beach', color: '#10b981' } }));
it('POST / missing name (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/tags', body: {} }));
it('PUT /:id found (200)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/tags/1', body: { name: 'Hike' } }));
it('PUT /:id not found (404)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/tags/9', body: { name: 'X' } }));
it('DELETE /:id found (200)', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/tags/1' }));
it('DELETE /:id not found (404)', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/tags/9' }));
});
-148
View File
@@ -1,148 +0,0 @@
/**
* L6 parity notifications.
*
* Fires the same request at the legacy Express /api/notifications route and the
* migrated Nest controller with the three notification services mocked
* identically for both, asserting client-identical status + body. Includes the
* route-ordering trap (DELETE /in-app/all must NOT be captured by /in-app/:id).
* Auth/admin are neutralised the same way (a fixed admin user); the 401/403
* paths are covered by the e2e test against the real guard.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { adminUser } = vi.hoisted(() => ({
adminUser: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin' },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = adminUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => adminUser,
}));
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 notificationsRoutes from '../../src/routes/notifications';
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('L6 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/notifications', notificationsRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [NotificationsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} });
inapp.getNotifications.mockReturnValue({ notifications: [{ id: 1 }], total: 1, unread_count: 1 });
inapp.getUnreadCount.mockReturnValue(2);
inapp.markAllRead.mockReturnValue(3);
inapp.deleteAll.mockReturnValue(4);
inapp.markRead.mockImplementation((id: number) => id === 5);
inapp.deleteNotification.mockImplementation((id: number) => id === 5);
inapp.respondToBoolean.mockResolvedValue({ success: true, notification: { id: 5, response: 'positive' } });
channels.testSmtp.mockResolvedValue({ success: true });
channels.testWebhook.mockResolvedValue({ success: true });
channels.getAdminNtfyConfig.mockReturnValue({ server: null, token: null });
channels.getUserNtfyConfig.mockReturnValue(null);
channels.testNtfy.mockResolvedValue({ success: true });
});
afterAll(async () => {
await nestApp.close();
});
it('GET /preferences', () => expectParity(expressServer, nestServer, { path: '/api/notifications/preferences' }));
it('PUT /preferences', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/preferences', body: { trip_invite: { inapp: true } } }));
it('POST /test-smtp (admin, 200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-smtp', body: { email: 'x@y.z' } }));
it('POST /test-webhook with a url (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-webhook', body: { url: 'https://hooks.example/x' } }));
it('POST /test-webhook invalid url (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-webhook', body: { url: 'not a url' } }));
it('POST /test-ntfy with a topic (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-ntfy', body: { topic: 'mytopic' } }));
it('POST /test-ntfy no topic (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-ntfy', body: {} }));
it('GET /in-app', () =>
expectParity(expressServer, nestServer, { path: '/api/notifications/in-app', query: { limit: '10', offset: '0' } }));
it('GET /in-app/unread-count', () =>
expectParity(expressServer, nestServer, { path: '/api/notifications/in-app/unread-count' }));
it('PUT /in-app/read-all', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/read-all' }));
it('DELETE /in-app/all (must not be captured by /:id)', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/notifications/in-app/all' }));
it('PUT /in-app/:id/read success', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/5/read' }));
it('PUT /in-app/:id/read 404', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/9/read' }));
it('PUT /in-app/:id/read invalid id (400)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/abc/read' }));
it('DELETE /in-app/:id success', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/notifications/in-app/5' }));
it('POST /in-app/:id/respond success (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/in-app/5/respond', body: { response: 'positive' } }));
it('POST /in-app/:id/respond invalid value (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/in-app/5/respond', body: { response: 'maybe' } }));
});
-124
View File
@@ -1,124 +0,0 @@
/**
* L7 parity atlas addon.
*
* Fires the same request at the legacy Express /api/addons/atlas route and the
* migrated Nest controller with atlasService mocked identically for both,
* asserting client-identical status + body. (Cache-Control headers are asserted
* in the controller unit test; expectParity compares status + body.) Auth is
* neutralised identically; the 401 path is covered by the e2e test.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 atlasRoutes from '../../src/routes/atlas';
import { AtlasModule } from '../../src/nest/atlas/atlas.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('L7 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/addons/atlas', atlasRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [AtlasModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
mocks.getStats.mockResolvedValue({ countries: 3, cities: 10, continents: 2 });
mocks.getVisitedRegions.mockResolvedValue({ regions: {} });
mocks.getRegionGeo.mockResolvedValue({ type: 'FeatureCollection', features: [{ id: 1 }] });
mocks.getCountryPlaces.mockReturnValue({ places: [] });
mocks.listBucketList.mockReturnValue([{ id: 1, name: 'Tokyo' }]);
mocks.createBucketItem.mockReturnValue({ id: 2, name: 'Kyoto' });
mocks.updateBucketItem.mockImplementation((_u: number, id: string | number) => (String(id) === '1' ? { id: 1, name: 'Edited' } : null));
mocks.deleteBucketItem.mockImplementation((_u: number, id: string | number) => String(id) === '1');
});
afterAll(async () => {
await nestApp.close();
});
it('GET /stats', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/stats' }));
it('GET /regions', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions' }));
it('GET /regions/geo empty', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions/geo' }));
it('GET /regions/geo non-empty', () =>
expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions/geo', query: { countries: 'DE,FR' } }));
it('GET /country/:code', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/country/de' }));
it('POST /country/:code/mark (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/country/de/mark' }));
it('DELETE /country/:code/mark', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/country/de/mark' }));
it('POST /region/:code/mark (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/region/by/mark', body: { name: 'Bavaria', country_code: 'de' } }));
it('POST /region/:code/mark missing fields (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/region/by/mark', body: { name: 'Bavaria' } }));
it('DELETE /region/:code/mark', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/region/by/mark' }));
it('GET /bucket-list', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/bucket-list' }));
it('POST /bucket-list create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/bucket-list', body: { name: 'Kyoto' } }));
it('POST /bucket-list blank name (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/bucket-list', body: { name: ' ' } }));
it('PUT /bucket-list/:id found (200)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/atlas/bucket-list/1', body: { name: 'Edited' } }));
it('PUT /bucket-list/:id not found (404)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/atlas/bucket-list/9', body: { name: 'X' } }));
it('DELETE /bucket-list/:id found (200)', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/bucket-list/1' }));
it('DELETE /bucket-list/:id not found (404)', () =>
expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/bucket-list/9' }));
});
-125
View File
@@ -1,125 +0,0 @@
/**
* S1 parity vacay addon.
*
* Fires the same request at the legacy Express /api/addons/vacay route and the
* migrated Nest controller with vacayService mocked identically for both,
* asserting client-identical status + body. Auth is neutralised identically; the
* 401 path is covered by the e2e test. Covers the validation/403/error-status
* paths and the POST-stays-200 behaviour.
*/
import { describe, it, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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 vacayRoutes from '../../src/routes/vacay';
import { VacayModule } from '../../src/nest/vacay/vacay.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S1 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/addons/vacay', vacayRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [VacayModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
svc.getActivePlanId.mockReturnValue(10);
svc.getActivePlan.mockReturnValue({ id: 10 });
svc.getPlanUsers.mockReturnValue([{ id: 1 }]);
svc.getPlanData.mockReturnValue({ plan: { id: 10 }, users: [] });
svc.addHolidayCalendar.mockReturnValue({ id: 1, region: 'DE-BY' });
svc.listYears.mockReturnValue([2026]);
svc.addYear.mockReturnValue([2026, 2027]);
svc.getEntries.mockReturnValue({ entries: [] });
svc.toggleEntry.mockReturnValue({ action: 'added' });
svc.getStats.mockReturnValue({ used: 5 });
svc.getAvailableUsers.mockReturnValue([{ id: 2 }]);
svc.sendInvite.mockReturnValue({});
svc.getCountries.mockResolvedValue({ data: [{ code: 'DE' }] });
svc.getHolidays.mockResolvedValue({ data: [{ date: '2026-01-01' }] });
});
afterAll(async () => {
await nestApp.close();
});
it('GET /plan', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/plan' }));
it('POST /plan/holiday-calendars (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/plan/holiday-calendars', body: { region: 'DE-BY', label: 'Bayern' } }));
it('POST /plan/holiday-calendars missing region (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/plan/holiday-calendars', body: {} }));
it('PUT /color in-plan (200)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/vacay/color', body: { color: '#fff' } }));
it('PUT /color not in plan (403)', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/vacay/color', body: { color: '#fff', target_user_id: 99 } }));
it('POST /invite (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/invite', body: { user_id: 2 } }));
it('POST /invite missing user_id (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/invite', body: {} }));
it('POST /dissolve (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/dissolve' }));
it('GET /available-users', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/available-users' }));
it('GET /years', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/years' }));
it('POST /years (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/years', body: { year: 2027 } }));
it('POST /years missing (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/years', body: {} }));
it('GET /entries/:year', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/entries/2026' }));
it('POST /entries/toggle (200)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/entries/toggle', body: { date: '2026-07-01' } }));
it('POST /entries/toggle missing date (400)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/entries/toggle', body: {} }));
it('GET /stats/:year', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/stats/2026' }));
it('GET /holidays/countries', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/holidays/countries' }));
it('GET /holidays/:year/:country', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/holidays/2026/DE' }));
});
@@ -1,136 +0,0 @@
/**
* S2 parity packing (trip-scoped).
*
* Fires the same request at the legacy Express /api/trips/:tripId/packing route
* (mounted with mergeParams) and the migrated Nest controller, with
* packingService, the permission check, the WebSocket broadcast and auth all
* mocked identically for both. Asserts client-identical status + body, including
* the trip-access 404, the permission 403, and POST /apply-template staying 200.
*/
import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import express from 'express';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { expectParity } from './parity';
const { fixedUser, trip } = vi.hoisted(() => ({
fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' },
trip: { id: 5, user_id: 1 },
}));
vi.mock('../../src/db/database', () => ({
db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) },
closeDb: () => {},
reinitialize: () => {},
}));
vi.mock('../../src/middleware/auth', () => ({
authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { user: unknown }).user = fixedUser;
next();
},
extractToken: () => 'token',
verifyJwtAndLoadUser: () => fixedUser,
}));
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(), 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(), reorderBags: vi.fn(),
},
}));
vi.mock('../../src/services/packingService', () => svc);
import packingRoutes from '../../src/routes/packing';
import { PackingModule } from '../../src/nest/packing/packing.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('S2 parity (Express vs Nest)', () => {
let expressServer: express.Express;
let nestServer: Server;
let nestApp: Awaited<ReturnType<typeof buildNest>>;
function buildExpress() {
const app = express();
app.use(express.json());
app.use('/api/trips/:tripId/packing', packingRoutes);
return app;
}
async function buildNest() {
const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
expressServer = buildExpress();
nestApp = await buildNest();
nestServer = nestApp.getHttpServer();
svc.listItems.mockReturnValue([{ id: 1, name: 'Socks' }]);
svc.createItem.mockReturnValue({ id: 9, name: 'Socks' });
svc.bulkImport.mockReturnValue([{ id: 1 }]);
svc.updateItem.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null));
svc.listBags.mockReturnValue([{ id: 1 }]);
svc.createBag.mockReturnValue({ id: 2 });
svc.applyTemplate.mockReturnValue([{ id: 1 }]);
svc.getCategoryAssignees.mockReturnValue([]);
});
beforeEach(() => {
svc.verifyTripAccess.mockReturnValue(trip);
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await nestApp.close();
});
it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/packing' }));
it('GET / 404 when trip not accessible', () => {
svc.verifyTripAccess.mockReturnValue(undefined);
return expectParity(expressServer, nestServer, { path: '/api/trips/5/packing' });
});
it('POST / create (201)', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: { name: 'Socks' } }));
it('POST / 403 without permission', () => {
checkPermission.mockReturnValue(false);
return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: { name: 'Socks' } });
});
it('POST / 400 missing name', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: {} }));
it('POST /import 400 empty', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/import', body: { items: [] } }));
it('PUT /reorder', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/packing/reorder', body: { orderedIds: [1, 2] } }));
it('PUT /:id 404 when item missing', () =>
expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/packing/77', body: { name: 'X' } }));
it('GET /bags', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/packing/bags' }));
it('POST /bags 400 blank name', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/bags', body: { name: ' ' } }));
it('POST /apply-template/:id stays 200', () =>
expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/apply-template/t1' }));
it('GET /category-assignees', () =>
expectParity(expressServer, nestServer, { path: '/api/trips/5/packing/category-assignees' }));
});
-42
View File
@@ -1,42 +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;
/** Request headers (e.g. a Cookie/Authorization) applied to BOTH stacks. */
headers?: Record<string, string>;
}
/**
* 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.headers) for (const [k, v] of Object.entries(req.headers)) r = r.set(k, v);
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);
}
+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: [],
};
-145
View File
@@ -1,145 +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 when NEST_PREFIXES is unset', () => {
delete process.env.NEST_PREFIXES;
expect(getNestPrefixes()).toEqual([
'/api/_nest',
'/api/weather',
'/api/airports',
'/api/config',
'/api/system-notices',
'/api/maps',
'/api/categories',
'/api/tags',
'/api/notifications',
'/api/addons/atlas',
'/api/addons/vacay',
'/api/trips/:tripId/packing',
'/api/trips/:tripId/todo',
'/api/trips/:tripId/budget',
'/api/trips/:tripId/reservations',
'/api/trips/:tripId/accommodations',
'/api/trips/:tripId/days',
'/api/trips/:tripId/assignments',
'/api/trips/:tripId/places',
'/api/trips/:tripId/collab',
'/api/trips/:tripId/files',
'/api/photos',
'/api/journeys',
'/api/public/journey',
'/api/shared',
'/api/settings',
'/api/backup',
'/api/auth/app-config',
'/api/auth/demo-login',
'/api/auth/invite',
'/api/auth/register',
'/api/auth/login',
'/api/auth/forgot-password',
'/api/auth/reset-password',
'/api/auth/me',
'/api/auth/logout',
'/api/auth/avatar',
'/api/auth/users',
'/api/auth/validate-keys',
'/api/auth/app-settings',
'/api/auth/travel-stats',
'/api/auth/mfa',
'/api/auth/mcp-tokens',
'/api/auth/ws-token',
'/api/auth/resource-token',
'/api/auth/oidc',
'/api/oauth',
'/oauth/token',
'/oauth/userinfo',
'/oauth/revoke',
'/api/admin',
'/api/trips/:tripId/share-link',
'/api/trips|',
'/api/trips/:tripId|',
'/api/trips/:tripId/members',
'/api/trips/:tripId/cover',
'/api/trips/:tripId/copy',
'/api/trips/:tripId/bundle',
'/api/trips/:tripId/export.ics',
]);
});
it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
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);
});
it('exact prefixes (trailing |) match the path only, not sub-paths', () => {
const match = makeNestPathMatcher(['/api/trips|', '/api/trips/:tripId|', '/api/trips/:tripId/members']);
expect(match('/api/trips')).toBe(true);
expect(match('/api/trips/5')).toBe(true);
expect(match('/api/trips/5/members')).toBe(true);
expect(match('/api/trips/5/members/2')).toBe(true);
// Not-yet-migrated nested mounts stay on Express:
expect(match('/api/trips/5/collab')).toBe(false);
expect(match('/api/trips/5/files')).toBe(false);
expect(match('/api/trips/5/cover')).toBe(false);
});
it('routes auth sub-paths via their own explicit prefixes (no broad /api/auth catch-all)', () => {
// The account prefixes alone must NOT swallow the separately-mounted oidc flow:
const accountOnly = makeNestPathMatcher(['/api/auth/login', '/api/auth/me', '/api/auth/mfa', '/api/auth/mcp-tokens']);
expect(accountOnly('/api/auth/login')).toBe(true);
expect(accountOnly('/api/auth/me/password')).toBe(true);
expect(accountOnly('/api/auth/mfa/verify-login')).toBe(true);
expect(accountOnly('/api/auth/mcp-tokens/abc')).toBe(true);
expect(accountOnly('/api/auth/oidc')).toBe(false);
expect(accountOnly('/api/auth/oidc/callback')).toBe(false);
// oidc is matched only by its own prefix (A2):
const withOidc = makeNestPathMatcher(['/api/auth/oidc']);
expect(withOidc('/api/auth/oidc/login')).toBe(true);
expect(withOidc('/api/auth/oidc/callback')).toBe(true);
});
it('routes the OAuth public endpoints to Nest but leaves the SDK mounts on Express (A3)', () => {
const match = makeNestPathMatcher(['/oauth/token', '/oauth/userinfo', '/oauth/revoke', '/api/oauth']);
expect(match('/oauth/token')).toBe(true);
expect(match('/oauth/userinfo')).toBe(true);
expect(match('/oauth/revoke')).toBe(true);
expect(match('/api/oauth/clients')).toBe(true);
expect(match('/api/oauth/authorize/validate')).toBe(true);
// The MCP SDK handlers must stay on Express:
expect(match('/oauth/authorize')).toBe(false);
expect(match('/oauth/register')).toBe(false);
expect(match('/oauth/consent')).toBe(false);
});
it('matches a pattern prefix with :param without capturing sibling routes', () => {
const match = makeNestPathMatcher(['/api/trips/:tripId/packing']);
expect(match('/api/trips/5/packing')).toBe(true);
expect(match('/api/trips/5/packing/bags')).toBe(true);
expect(match('/api/trips/abc/packing/123')).toBe(true);
// Sibling trip routes stay on Express:
expect(match('/api/trips/5/days')).toBe(false);
expect(match('/api/trips/5/places')).toBe(false);
expect(match('/api/trips/5')).toBe(false);
expect(match('/api/trips/5/packingx')).toBe(false);
});
});
@@ -17,7 +17,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
// Mirror the real canAccessTrip semantics against the test DB (owner or member
// → truthy access row, else undefined) so addTripToJourney's trip-access guard
// behaves as in production. (Was an unused `() => null` stub before the guard existed.)
canAccessTrip: (tripId: number | string, 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: () => false,
};
return { testDb: db, dbMock: mock };
@@ -417,6 +425,22 @@ describe('addTripToJourney / removeTripFromJourney', () => {
expect(link).toBeDefined();
});
it('JOURNEY-SVC-024b: refuses to link a trip the caller cannot access (IDOR guard)', () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, user.id);
// A trip owned by someone else, that `user` is not a member of.
const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" });
const result = addTripToJourney(journey.id, foreignTrip.id, user.id);
expect(result).toBe(false);
const link = testDb.prepare(
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
).get(journey.id, foreignTrip.id);
expect(link).toBeUndefined();
});
it('JOURNEY-SVC-025: syncs places as skeleton entries when linking a trip', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
+11 -7
View File
@@ -39,27 +39,31 @@ vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
DEFAULT_LANGUAGE: 'en',
}));
import { createApp } from '../../src/app';
import type { INestApplication } from '@nestjs/common';
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 } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { setupWebSocket } from '../../src/websocket';
import { createEphemeralToken } from '../../src/services/ephemeralTokens';
let server: http.Server;
let wsUrl: string;
let nestApp: INestApplication;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
const app = createApp();
server = http.createServer(app);
// Real WebSocket against the unified NestJS app (Express is gone). buildApp owns
// the same composition production uses; we attach the real ws server to it.
nestApp = await buildApp();
server = http.createServer(nestApp.getHttpAdapter().getInstance());
setupWebSocket(server);
await new Promise<void>(resolve => server.listen(0, resolve));
@@ -71,13 +75,13 @@ afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close(err => err ? reject(err) : resolve())
);
await nestApp.close();
testDb.close();
});
beforeEach(() => {
resetTestDb(testDb);
loginAttempts.clear();
mfaAttempts.clear();
resetRateLimits(nestApp);
});
/** Buffered WebSocket wrapper that never drops messages. */