mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* S7 parity — assignments (place↔day 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' } }));
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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: {} }));
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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: {} });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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' } }));
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 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' } }));
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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: {} }));
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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' } }));
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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' }));
|
||||
});
|
||||
@@ -7,6 +7,8 @@ export interface ParityRequest {
|
||||
path: string;
|
||||
query?: Record<string, string>;
|
||||
body?: unknown;
|
||||
/** Request headers (e.g. a Cookie/Authorization) applied to BOTH stacks. */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +28,7 @@ export async function expectParity(
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user