203 lines
6.8 KiB
JavaScript
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);
|
|
});
|
|
});
|