Files
TREK/server/tests/e2e/journey.e2e.test.ts
T
Maurice f0f69df67e Fold the journey domain into Nest and finish the MCP migration
journeyService (1324 LOC, 36 exports) and journeyShareService (203) become
JourneyDomainService and JourneyShareService. Every SQL statement, error string
and broadcast is the one that shipped.

The two could never be split: the share service gates on isOwner, which lives in
the domain service, so it injects it.

JourneyDomainModule is a leaf carrying just the two services. Places,
assignments and the plugin host each need a handful of journey functions;
importing JourneyModule for that would drag MemoriesModule and both photo
providers into their graphs. Same shape as TrekPhotosModule.

The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is
down to _shared.ts and registerTools() no longer fans out to anything.

journey.bridge.ts keeps four exports, for the journey resources in
src/mcp/resources.ts that register at import time.

McpAccessMode is host-augmentable now. journey:share is neither read nor write,
so the three share tools would have had to be opaque predicates - and predicates
bypass the access policy entirely, invisible to the boot-time scope gate. A
scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry
mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'.

Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed
PlacesService with five of eight arguments. tsconfig only includes src, so
nothing typechecked it, and the missing dependencies were harmless until the
journey skeleton hooks landed on the place write paths.

journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy
registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25
cases for the skeleton lifecycle, the photo gallery and the preference row take
the domain service from 73.9% to 82.8%.
2026-08-08 23:25:57 +02:00

142 lines
6.0 KiB
TypeScript

/**
* Journey e2e — exercises the migrated /api/journeys and /api/public/journey
* endpoints through the real JwtAuthGuard against a temp SQLite db. The journey
* services + addon gate are mocked; this focuses on the addon-gate-before-auth
* ordering (404 wins over 401), auth, the service-owned 403/404 mapping, status
* codes and the unguarded public route.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
// The controller's pure helpers (isVideoExtension/isVideoMime/MAX_VIDEO_SIZE)
// now come from the real files.constants; only the request-time app_settings
// read is mocked, preserving the old '*'-allowlist semantics for the fixtures.
vi.mock('../../src/nest/files/files.bridge', () => ({ getAllowedExtensions: () => '*' }));
// The memories providers are injected since the fold — stubbed on the prototype
// so JourneyModule still resolves them through DI.
vi.mock('../../src/nest/memories/immich.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/nest/memories/immich.service')>();
actual.ImmichService.prototype.uploadToImmich = vi.fn();
actual.ImmichService.prototype.streamImmichAsset = vi.fn();
return actual;
});
vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/nest/memories/photo-resolver.service')>();
actual.PhotoResolverService.prototype.streamPhoto = vi.fn();
return actual;
});
const { jsvc } = vi.hoisted(() => ({
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
}));
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
import { JourneyModule } from '../../src/nest/journey/journey.module';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
describe('Journey e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] })
.overrideProvider(JourneyDomainService)
.useValue(jsvc)
.overrideProvider(JourneyShareService)
.useValue(sharesvc)
.overrideProvider(AddonsService)
.useValue({ isAddonEnabled })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
});
beforeEach(() => {
isAddonEnabled.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('404 (addon gate wins over auth) when the Journey addon is disabled', async () => {
isAddonEnabled.mockReturnValue(false);
const res = await request(server).get('/api/journeys');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
});
it('401 with the addon enabled but no session cookie', async () => {
expect((await request(server).get('/api/journeys')).status).toBe(401);
});
it('200 list with a session', async () => {
const res = await request(server).get('/api/journeys').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ journeys: [{ id: 1, title: 'J' }] });
});
it('201 create, 400 without a title', async () => {
const ok = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({ title: 'J' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ id: 9, title: 'J' });
const bad = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({});
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Title is required' });
});
it('404 for an inaccessible journey', async () => {
jsvc.getJourneyFull.mockReturnValue(null);
const res = await request(server).get('/api/journeys/9').set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Journey not found' });
});
it('public journey read is unguarded (200 with a valid token, no cookie)', async () => {
const res = await request(server).get('/api/public/journey/tok');
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: 9 });
});
it('public journey 404 for an unknown token', async () => {
sharesvc.getPublicJourney.mockReturnValueOnce(null);
const res = await request(server).get('/api/public/journey/bad');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Not found' });
});
});