app.get(['/', '/index.html', '/assistant']) reached the OpenAPI generator as one route whose path was the array, joined with commas; the e2e reachability check then probed "/,/index.html,/assistant" and found a 404. Each path is now its own route, and routes outside /api/ are left out: the document describes what a client calls, and a client does not call index.html. The starter-question pool logs when a build starts, what each category kept, and how long it took, so a build that produces nothing can be traced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
126 lines
5.5 KiB
JavaScript
126 lines
5.5 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
|
|
app.get(['/', '/index.html', '/assistant'], (req, res) => res.end()); // pages, not API
|
|
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');
|
|
});
|