docs/api-reference.md was hand-written, and by the time anyone checked it was documenting twenty-three endpoints that answer 404 while missing others that exist. That is what hand-written reference material does: it is correct on the day it is written and silently wrong afterwards. A second hand-written document, in YAML this time, would rot the same way. So paths, methods and mount points are read from the Express router stack at request time. They cannot disagree with the app, because they are the app: 186 paths, 215 operations, and — checked — no /learning endpoints, which is what the prose version went on claiming for weeks after that feature was deleted. What introspection cannot know is what an endpoint is *for*. That half lives in src/utils/openapiRoutes.js, keyed by "METHOD /path", and it is the half that rots, so it is the half that is enforced: a Playwright spec fetches the live document and fails when the number of operations without a summary rises above 199 — the debt as measured today. A ratchet, not a target. Adding an endpoint pushes the count over and fails the build; describing one lowers the number. The failure lists the operations by name, so it says what to write. Whether an operation is public is stated per route rather than inferred from middleware. Guessing wrong there is worse in both directions: calling a public endpoint protected hides a hole, and the reverse invites a bug report. The contract spec lives in e2e rather than the unit suite because it needs the whole app mounted, and requiring server.js from node:test pulls in the database pool and hangs the run — that has happened here before. Also: e2e now runs in CI on dev, gated by a shell check inside the step rather than a job-level "if", which this Forgejo dispatches anyway and then kills with "Early termination". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
125 lines
5.4 KiB
JavaScript
125 lines
5.4 KiB
JavaScript
// docs/api-reference.md was hand-written, and by the time anyone checked it
|
|
// documented twenty-three endpoints that answer 404. Reference material written
|
|
// by hand is correct on the day it is written and silently wrong afterwards.
|
|
//
|
|
// So the document is generated from the Express router stack. These tests pin
|
|
// the two things that can break that: the shape of the stack (undocumented, and
|
|
// an Express upgrade could change it) and the rule that every route says what
|
|
// it is for.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const express = require('express');
|
|
const openapi = require('../src/utils/openapi');
|
|
|
|
// A stand-in for the real app: mounted routers, a route straight on the app,
|
|
// plain middleware that is not a router, and a duplicate path across two
|
|
// routers mounted on the same prefix.
|
|
function syntheticApp() {
|
|
const app = express();
|
|
const users = express.Router();
|
|
users.get('/me', (req, res) => res.end());
|
|
users.post('/login', (req, res) => res.end());
|
|
users.get('/thing/:id', (req, res) => res.end());
|
|
|
|
const also = express.Router();
|
|
also.get('/me', (req, res) => res.end()); // duplicate, loses to the first
|
|
also.delete('/thing/:id', (req, res) => res.end());
|
|
|
|
app.use((req, res, next) => next()); // not a router
|
|
app.use('/api/auth', users);
|
|
app.use('/api/auth', also);
|
|
app.get('/api/health', (req, res) => res.end()); // straight on the app
|
|
return app;
|
|
}
|
|
|
|
test('every mounted route is found, at its full path', () => {
|
|
const found = openapi.routes(syntheticApp());
|
|
const seen = found.map(r => r.method + ' ' + r.path).sort();
|
|
assert.deepEqual(seen, [
|
|
'DELETE /api/auth/thing/:id',
|
|
'GET /api/auth/me',
|
|
'GET /api/auth/thing/:id',
|
|
'GET /api/health',
|
|
'POST /api/auth/login'
|
|
]);
|
|
});
|
|
|
|
test('a duplicate path is documented once — Express answers with the first', () => {
|
|
const found = openapi.routes(syntheticApp());
|
|
assert.equal(found.filter(r => r.method === 'GET' && r.path === '/api/auth/me').length, 1);
|
|
});
|
|
|
|
test('plain middleware is not mistaken for a router', () => {
|
|
// app.use(fn) has a regexp matching everything; treating it as a mount would
|
|
// invent a path for every route under it.
|
|
assert.equal(openapi.mountPath({ regexp: /^\/?(?=\/|$)/i }), '');
|
|
});
|
|
|
|
test('a mount regexp reads back as the path it was mounted at', () => {
|
|
const app = syntheticApp();
|
|
const mounts = app._router.stack.filter(l => l.handle && l.handle.stack).map(openapi.mountPath);
|
|
assert.ok(mounts.includes('/api/auth'), 'got: ' + JSON.stringify(mounts));
|
|
});
|
|
|
|
test('an app with no router yields nothing rather than throwing', () => {
|
|
// The endpoint must fail soft: a broken document is better than a 500 on a
|
|
// page that was only ever informational.
|
|
assert.deepEqual(openapi.routes(null), []);
|
|
assert.deepEqual(openapi.routes({}), []);
|
|
});
|
|
|
|
test('path params become OpenAPI templates and are declared', () => {
|
|
assert.equal(openapi.toTemplate('/api/my-resources/:id/export'), '/api/my-resources/{id}/export');
|
|
const doc = openapi.document(syntheticApp());
|
|
const op = doc.paths['/api/auth/thing/{id}'].get;
|
|
assert.equal(op.parameters.length, 1);
|
|
assert.equal(op.parameters[0].name, 'id');
|
|
assert.equal(op.parameters[0].in, 'path');
|
|
assert.equal(op.parameters[0].required, true);
|
|
});
|
|
|
|
test('the document is valid OpenAPI 3.1 with both auth schemes declared', () => {
|
|
const doc = openapi.document(syntheticApp(), { version: '1.2.3' });
|
|
assert.equal(doc.openapi, '3.1.0');
|
|
assert.equal(doc.info.version, '1.2.3');
|
|
assert.ok(doc.paths['/api/health']);
|
|
assert.deepEqual(Object.keys(doc.components.securitySchemes).sort(), ['bearer', 'cookie']);
|
|
// Cookie and bearer are alternatives, not both required.
|
|
assert.equal(doc.security.length, 2);
|
|
});
|
|
|
|
test('routes are grouped so a reader can find them', () => {
|
|
const doc = openapi.document(syntheticApp());
|
|
assert.deepEqual(doc.paths['/api/auth/me'].get.tags, ['auth']);
|
|
assert.deepEqual(doc.paths['/api/health'].get.tags, ['health']);
|
|
});
|
|
|
|
// ---- the metadata half -----------------------------------------------------
|
|
|
|
test('metadata is keyed exactly as routes are mounted', () => {
|
|
const meta = require('../src/utils/openapiRoutes');
|
|
for (const key of Object.keys(meta.operations)) {
|
|
assert.match(key, /^(GET|POST|PUT|DELETE|PATCH) \/api\//, 'malformed key: ' + key);
|
|
}
|
|
});
|
|
|
|
test('a public operation says so, and is the only kind that does', () => {
|
|
// Guessing this from middleware gets it wrong in the direction that hides a
|
|
// hole, so it is stated per route. Registration status must be public — the
|
|
// sign-in screen reads it before anyone has a session.
|
|
const meta = require('../src/utils/openapiRoutes');
|
|
assert.equal(meta.operations['GET /api/auth/registration-status'].public, true);
|
|
assert.equal(meta.operations['GET /api/health'].public, true);
|
|
assert.equal(meta.operations['POST /api/auth/logout'].public, undefined);
|
|
});
|
|
|
|
test('the endpoint is mounted after every router, and behind auth', () => {
|
|
const fs = require('fs');
|
|
const server = fs.readFileSync(require('path').join(__dirname, '..', 'server.js'), 'utf8');
|
|
const at = server.indexOf("app.get('/api/openapi.json'");
|
|
assert.ok(at > -1, 'the endpoint is not mounted');
|
|
assert.match(server.slice(at, at + 200), /authMiddleware/);
|
|
// Anything mounted below it would be missing from the document.
|
|
const lastMount = server.lastIndexOf("app.use('/api");
|
|
assert.ok(lastMount < at, 'a router is mounted after the openapi endpoint');
|
|
});
|