dorfteich/apps/api/src/testing/test-app.ts
Claude Fable 5 04e21a0aac
All checks were successful
CD / Build and push images (push) Successful in 3m50s
CI / Lint, typecheck, test (push) Successful in 4m2s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m37s
CI / Import/export fidelity gate (push) Successful in 47s
Built-in MCP endpoint (Streamable HTTP) on top of the public API (#105)
AI clients talk to the instance directly at /api/mcp — under the /api/
path (deviation from the issue's literal /mcp) so every existing reverse
proxy already routes it; no deployment changes anywhere.

- Transport: official @modelcontextprotocol/sdk server, STATELESS — each
  POST builds a fresh server+transport pair, no session store, replicas
  stay trivial; GET/DELETE answer 405. Auth per PAT bearer (#104 tokens),
  per-token rate limit (429 + Retry-After).
- Own switches, independent of REST: instance mcp.enabled (admin
  settings, default off; off = 404, feature invisible) + pond setting
  mcpEnabled (pond-settings toggle, default off) — pinned independent in
  both directions by tests.
- Tools (thin wrappers over the #104 services, same permission gates,
  audit-logged writes): list_ponds, list_pages, read_page, search,
  create_page, update_page (replace semantics through the collab-owned
  restore path — open editors converge), add_comment, list_labels,
  set_page_labels (exact replace), export_pond (link to the REST ZIP).
  Tool errors carry the api error codes; results carry stable slugs/ids.
  MCP resources stay the documented stage-2 stretch goal.
- Deliberately on the SDK's low-level Server API with a hand-written tool
  table (mcp-tools.ts): the typed registerTool generics drove tsc out of
  memory in a program this size; manual Zod validation keeps the wire
  behavior explicit.
- PublicApiService exposure filtering parameterized ('api' | 'mcp',
  shared pondFeatureEnabled helper) — one implementation, two switches.
- Docs: "Connect Claude Code / MCP clients" section in public-api.md
  (claude mcp add one-liner + mcp-remote bridge for stdio clients).

Verification: 8-test e2e pack driving the real MCP SDK client over
Streamable HTTP against a listening api (initialize + tools/list, switch
independence in both directions, anonymous/garbage 401, opt-in 404
semantics, page roundtrip incl. restore-NOTIFY, labels/comments, read
scope blocked from writes with scope_required); live check through the
web proxy against the seeded stack (tools list, create, read, update,
search — LIVE CHECK PASSED); full api suite 61/61 files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 11:36:02 +02:00

53 lines
2.3 KiB
TypeScript

import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModuleBuilder } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { AppModule } from '../app.module';
/**
* Boots the full application for e2e tests, mirroring main.ts middleware.
* Requires TEST_DATABASE_URL; DATABASE_URL is pointed at it so the app
* under test uses the test database. `customize` can override providers
* (e.g. inject a fake converter for the conversion queue tests, issue #62).
*/
export async function createTestApp(
customize?: (builder: TestingModuleBuilder) => TestingModuleBuilder,
): Promise<INestApplication> {
process.env.NODE_ENV = 'test';
if (process.env.TEST_DATABASE_URL) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
}
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
// Fresh scratch directory per test file so upload tests never touch the
// repository or collide with each other.
process.env.UPLOADS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-uploads-'));
process.env.PLUGINS_DIR ??= mkdtempSync(join(tmpdir(), 'dorfteich-plugins-'));
const base = Test.createTestingModule({ imports: [AppModule] });
const moduleRef = await (customize ? customize(base) : base).compile();
const app = moduleRef.createNestApplication<NestExpressApplication>();
app.use(cookieParser());
// Mirrors main.ts: base64 Yjs page state needs more than Express's 100kb default.
app.useBodyParser('json', { limit: '8mb' });
// Mirrors main.ts: the public API (issue #104) declares its full path.
app.setGlobalPrefix('api/v1', {
exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'],
});
await app.init();
return app;
}
/** Extracts the dt_session cookie pair ("name=value") from a response. */
export function sessionCookieOf(res: { headers: Record<string, unknown> }): string {
const header = res.headers['set-cookie'];
const cookies = Array.isArray(header) ? header : [header].filter(Boolean);
const session = (cookies as string[]).find((c) => c.startsWith('dt_session='));
if (!session) throw new Error('response carries no dt_session cookie');
return session.split(';')[0]!;
}