// 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); }); });