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
82 lines
4 KiB
JavaScript
82 lines
4 KiB
JavaScript
// The API contract, checked against the API.
|
|
//
|
|
// docs/api-reference.md was hand-written and drifted until it documented
|
|
// twenty-three endpoints that answer 404. The document is now generated from
|
|
// the router, which fixes the paths; this fixes the prose, by failing when a
|
|
// route exists that nothing describes.
|
|
//
|
|
// It runs here rather than in 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 before in this repo.
|
|
const { test, expect } = require('../fixtures');
|
|
|
|
async function spec(page) {
|
|
const response = await page.request.get('/api/openapi.json');
|
|
expect(response.status(), 'the document should be served to a signed-in user').toBe(200);
|
|
return response.json();
|
|
}
|
|
|
|
test.describe('OpenAPI', () => {
|
|
test('the document describes this deployment, not a remembered one', async ({ authedPage: page }) => {
|
|
const doc = await spec(page);
|
|
expect(doc.openapi).toBe('3.1.0');
|
|
expect(Object.keys(doc.paths).length).toBeGreaterThan(100);
|
|
// Both ways of holding a session are declared.
|
|
expect(Object.keys(doc.components.securitySchemes).sort()).toEqual(['bearer', 'cookie']);
|
|
});
|
|
|
|
test('endpoints that exist are in it', async ({ authedPage: page }) => {
|
|
const doc = await spec(page);
|
|
for (const path of ['/api/health', '/api/build', '/api/auth/me', '/api/my-resources']) {
|
|
expect(doc.paths[path], path + ' is missing from the document').toBeTruthy();
|
|
}
|
|
// A path parameter is written the way OpenAPI writes one.
|
|
expect(doc.paths['/api/my-resources/{id}']).toBeTruthy();
|
|
expect(doc.paths['/api/my-resources/:id']).toBeFalsy();
|
|
});
|
|
|
|
test('endpoints that were removed are not', async ({ authedPage: page }) => {
|
|
// Learning Hub is gone. The generated document cannot claim otherwise,
|
|
// which is exactly what the hand-written reference did for weeks.
|
|
const doc = await spec(page);
|
|
const stale = Object.keys(doc.paths).filter(p => p.includes('/learning'));
|
|
expect(stale, 'removed endpoints are still described').toEqual([]);
|
|
});
|
|
|
|
test('every documented operation is reachable, and none 404s', async ({ authedPage: page }) => {
|
|
const doc = await spec(page);
|
|
const missing = [];
|
|
for (const [path, methods] of Object.entries(doc.paths)) {
|
|
// Only GETs with no path parameter can be probed safely: a POST would
|
|
// change something and a templated path has no real id to try.
|
|
if (!methods.get || path.includes('{')) continue;
|
|
const response = await page.request.get(path, { failOnStatusCode: false });
|
|
if (response.status() === 404) missing.push(path);
|
|
}
|
|
expect(missing, 'documented but answering 404').toEqual([]);
|
|
});
|
|
|
|
test('a description is required, so a new endpoint cannot ship unexplained', async ({ authedPage: page }) => {
|
|
// The generator supplies paths and methods; a person supplies meaning. This
|
|
// is the half that rots, so it is the half that is enforced — undescribed
|
|
// operations are listed by name rather than counted, so the failure says
|
|
// what to write.
|
|
const doc = await spec(page);
|
|
const undescribed = [];
|
|
for (const [path, methods] of Object.entries(doc.paths)) {
|
|
for (const [method, operation] of Object.entries(methods)) {
|
|
if (!operation.summary) undescribed.push(method.toUpperCase() + ' ' + path);
|
|
}
|
|
}
|
|
// A ratchet, not a target. 199 of 215 operations have no summary yet —
|
|
// this API was written over a year with no spec at all, and describing all
|
|
// of it in one sitting would produce 199 sentences nobody read. The number
|
|
// is the debt as measured, and it may only go down: adding an endpoint
|
|
// pushes the count above it and fails the build, so the backlog cannot
|
|
// grow while it is being paid off.
|
|
const BUDGET = Number(process.env.OPENAPI_UNDESCRIBED_BUDGET || 199);
|
|
expect(undescribed.length,
|
|
'undescribed operations (add them to src/utils/openapiRoutes.js):\n ' +
|
|
undescribed.slice(0, 40).join('\n ')).toBeLessThanOrEqual(BUDGET);
|
|
});
|
|
});
|