camofox-browser/tests/unit/openapi.test.js

203 lines
6.8 KiB
JavaScript

/**
* Tests for auto-generated OpenAPI spec.
*
* Verifies:
* 1. Every server.js route appears in the spec (no drift)
* 2. No stale routes in the spec that aren't in server.js
* 3. Spec structure is valid OpenAPI 3.0.x
* 4. info.version matches package.json
* 5. Every operation has responses and tags
* 6. Enriched routes have proper metadata
*/
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import swaggerJsdoc from 'swagger-jsdoc';
import { swaggerDefinition } from '../../lib/openapi.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const serverPath = join(__dirname, '..', '..', 'server.js');
const serverSrc = readFileSync(serverPath, 'utf8');
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
// Build spec from JSDoc in server.js
const spec = swaggerJsdoc({
definition: swaggerDefinition,
apis: [serverPath],
});
/**
* Extract all app.get/post/delete routes from server.js source.
* Returns Set of "METHOD /path" strings (Express format with :params).
*/
function parseServerRoutes(source) {
const routes = new Set();
const re = /app\.(get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]/g;
let m;
while ((m = re.exec(source)) !== null) {
routes.add(`${m[1].toUpperCase()} ${m[2]}`);
}
return routes;
}
const serverRoutes = parseServerRoutes(serverSrc);
describe('OpenAPI spec', () => {
test('is valid OpenAPI 3.0.x shape', () => {
expect(spec.openapi).toMatch(/^3\.0\.\d+$/);
expect(spec.info).toBeDefined();
expect(spec.info.title).toBe('camofox-browser');
expect(spec.paths).toBeDefined();
expect(typeof spec.paths).toBe('object');
expect(spec.components).toBeDefined();
});
test('info.version matches package.json', () => {
expect(spec.info.version).toBe(pkg.version);
});
test('every server.js route appears in the spec', () => {
const missing = [];
for (const route of serverRoutes) {
const [method, expressPath] = route.split(' ');
const oaPath = expressPath.replace(/:(\w+)/g, '{$1}');
const pathObj = spec.paths[oaPath];
if (!pathObj || !pathObj[method.toLowerCase()]) {
missing.push(route);
}
}
expect(missing).toEqual([]);
});
test('no stale routes in spec that are not in server.js', () => {
const stale = [];
for (const [oaPath, methods] of Object.entries(spec.paths)) {
for (const method of Object.keys(methods)) {
if (method.startsWith('x-')) continue; // skip extensions
const expressPath = oaPath.replace(/\{(\w+)\}/g, ':$1');
const key = `${method.toUpperCase()} ${expressPath}`;
if (!serverRoutes.has(key)) {
stale.push(key);
}
}
}
expect(stale).toEqual([]);
});
test('spec covers at least 30 routes from server.js', () => {
expect(serverRoutes.size).toBeGreaterThanOrEqual(30);
let covered = 0;
for (const route of serverRoutes) {
const [method, expressPath] = route.split(' ');
const oaPath = expressPath.replace(/:(\w+)/g, '{$1}');
if (spec.paths[oaPath]?.[method.toLowerCase()]) covered++;
}
expect(covered).toBe(serverRoutes.size);
});
test('every operation has at least one response', () => {
const noResponses = [];
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
if (method.startsWith('x-')) continue;
if (!op.responses || Object.keys(op.responses).length === 0) {
noResponses.push(`${method.toUpperCase()} ${path}`);
}
}
}
expect(noResponses).toEqual([]);
});
test('every operation has at least one tag', () => {
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
if (method.startsWith('x-')) continue;
expect(op.tags?.length).toBeGreaterThanOrEqual(1);
}
}
});
test('parameterized routes have path parameters', () => {
const navOp = spec.paths['/tabs/{tabId}/navigate']?.post;
expect(navOp).toBeDefined();
const tabIdParam = navOp.parameters?.find(p => p.name === 'tabId' && p.in === 'path');
expect(tabIdParam).toBeDefined();
expect(tabIdParam.required).toBe(true);
expect(tabIdParam.schema.type).toBe('string');
});
test('POST /tabs has request body and proper tag', () => {
const createTab = spec.paths['/tabs']?.post;
expect(createTab).toBeDefined();
expect(createTab.summary).toBe('Create a new tab');
expect(createTab.tags).toContain('Tabs');
expect(createTab.requestBody).toBeDefined();
expect(createTab.requestBody.content['application/json']).toBeDefined();
});
test('legacy routes are marked deprecated', () => {
const legacyPaths = {
'/act': 'post',
'/navigate': 'post',
'/snapshot': 'get',
'/tabs/open': 'post',
};
for (const [path, method] of Object.entries(legacyPaths)) {
const op = spec.paths[path]?.[method];
expect(op).toBeDefined();
expect(op.deprecated).toBe(true);
}
});
test('Error schema is defined in components', () => {
expect(spec.components.schemas.Error).toBeDefined();
expect(spec.components.schemas.Error.required).toContain('error');
});
test('tags include well-known categories', () => {
const tagNames = spec.tags.map(t => t.name);
for (const expected of ['System', 'Tabs', 'Navigation', 'Interaction', 'Content', 'Sessions', 'Legacy', 'Browser']) {
expect(tagNames).toContain(expected);
}
});
test('security scheme BearerAuth is defined', () => {
expect(spec.components.securitySchemes.BearerAuth).toBeDefined();
expect(spec.components.securitySchemes.BearerAuth.type).toBe('http');
expect(spec.components.securitySchemes.BearerAuth.scheme).toBe('bearer');
});
test('cookie import route has security requirement', () => {
const op = spec.paths['/sessions/{userId}/cookies']?.post;
expect(op).toBeDefined();
expect(op.security).toEqual([{ BearerAuth: [] }]);
});
test('$ref references resolve to existing component schemas', () => {
const schemaNames = Object.keys(spec.components?.schemas || {});
const refs = [];
JSON.stringify(spec, (key, val) => {
if (key === '$ref' && typeof val === 'string') refs.push(val);
return val;
});
const unresolved = refs.filter(ref => {
const match = ref.match(/^#\/components\/schemas\/(.+)$/);
return match && !schemaNames.includes(match[1]);
});
expect(unresolved).toEqual([]);
});
test('openapi.json in repo root is up to date', () => {
let committed;
try {
committed = JSON.parse(readFileSync(join(__dirname, '..', '..', 'openapi.json'), 'utf8'));
} catch {
throw new Error('openapi.json not found -- run: npm run generate-openapi');
}
expect(committed).toEqual(spec);
});
});