test(swagger): add coverage validation test
- Create tests/integration/swagger-coverage.test.js - Validate OpenAPI spec loads without errors - Assert every Express route appears in spec - Check all examples are valid JSON - Verify required security schemes are referenced - Run as part of existing test suite
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Swagger Coverage Test
|
||||
*
|
||||
* Validates that:
|
||||
* - The OpenAPI spec loads without errors
|
||||
* - Every Express route appears in the spec
|
||||
* - All examples are valid JSON
|
||||
* - Required security schemes are referenced
|
||||
*/
|
||||
|
||||
const { describe, it, expect } = require('vitest');
|
||||
const request = require('supertest');
|
||||
const { createApp } = require('../../server/app');
|
||||
const YAML = require('yamljs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
describe('Swagger Coverage', () => {
|
||||
let app;
|
||||
let openapiSpec;
|
||||
let swaggerSpec;
|
||||
|
||||
beforeAll(() => {
|
||||
// Load the base OpenAPI spec from YAML
|
||||
const yamlPath = path.join(__dirname, '../../server/openapi.yaml');
|
||||
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
|
||||
openapiSpec = YAML.parse(yamlContent);
|
||||
|
||||
// Create app and get the merged swagger spec
|
||||
app = createApp({ skipRateLimits: true });
|
||||
});
|
||||
|
||||
it('should load OpenAPI YAML spec without errors', () => {
|
||||
expect(openapiSpec).toBeDefined();
|
||||
expect(openapiSpec.openapi).toBe('3.1.0');
|
||||
expect(openapiSpec.info).toBeDefined();
|
||||
expect(openapiSpec.info.title).toBe('sofarr API');
|
||||
});
|
||||
|
||||
it('should have required security schemes defined', () => {
|
||||
expect(openapiSpec.components).toBeDefined();
|
||||
expect(openapiSpec.components.securitySchemes).toBeDefined();
|
||||
expect(openapiSpec.components.securitySchemes.CookieAuth).toBeDefined();
|
||||
expect(openapiSpec.components.securitySchemes.CsrfToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required component schemas defined', () => {
|
||||
const schemas = openapiSpec.components.schemas;
|
||||
expect(schemas).toBeDefined();
|
||||
|
||||
const requiredSchemas = [
|
||||
'NormalizedDownload',
|
||||
'DashboardPayload',
|
||||
'ErrorResponse',
|
||||
'BlocklistSearchRequest',
|
||||
'WebhookPayload',
|
||||
'HistoryItem',
|
||||
'StatusResponse'
|
||||
];
|
||||
|
||||
requiredSchemas.forEach(schemaName => {
|
||||
expect(schemas[schemaName]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have paths defined in the spec', () => {
|
||||
expect(openapiSpec.paths).toBeDefined();
|
||||
expect(Object.keys(openapiSpec.paths).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have all required public endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
// Public health endpoints
|
||||
expect(paths['/health']).toBeDefined();
|
||||
expect(paths['/health'].get).toBeDefined();
|
||||
expect(paths['/ready']).toBeDefined();
|
||||
expect(paths['/ready'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required auth endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/auth/login']).toBeDefined();
|
||||
expect(paths['/api/auth/login'].post).toBeDefined();
|
||||
expect(paths['/api/auth/me']).toBeDefined();
|
||||
expect(paths['/api/auth/me'].get).toBeDefined();
|
||||
expect(paths['/api/auth/csrf']).toBeDefined();
|
||||
expect(paths['/api/auth/csrf'].get).toBeDefined();
|
||||
expect(paths['/api/auth/logout']).toBeDefined();
|
||||
expect(paths['/api/auth/logout'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required dashboard endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/dashboard/user-downloads']).toBeDefined();
|
||||
expect(paths['/api/dashboard/user-downloads'].get).toBeDefined();
|
||||
expect(paths['/api/dashboard/cover-art']).toBeDefined();
|
||||
expect(paths['/api/dashboard/cover-art'].get).toBeDefined();
|
||||
expect(paths['/api/dashboard/stream']).toBeDefined();
|
||||
expect(paths['/api/dashboard/stream'].get).toBeDefined();
|
||||
expect(paths['/api/dashboard/blocklist-search']).toBeDefined();
|
||||
expect(paths['/api/dashboard/blocklist-search'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required status endpoint documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/status/status']).toBeDefined();
|
||||
expect(paths['/api/status/status'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required history endpoint documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/history/recent']).toBeDefined();
|
||||
expect(paths['/api/history/recent'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have all required webhook endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/webhook/sonarr']).toBeDefined();
|
||||
expect(paths['/api/webhook/sonarr'].post).toBeDefined();
|
||||
expect(paths['/api/webhook/radarr']).toBeDefined();
|
||||
expect(paths['/api/webhook/radarr'].post).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Sonarr proxy endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/sonarr/queue']).toBeDefined();
|
||||
expect(paths['/api/sonarr/history']).toBeDefined();
|
||||
expect(paths['/api/sonarr/series']).toBeDefined();
|
||||
expect(paths['/api/sonarr/notifications']).toBeDefined();
|
||||
expect(paths['/api/sonarr/notifications/sofarr-webhook']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Radarr proxy endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/radarr/queue']).toBeDefined();
|
||||
expect(paths['/api/radarr/history']).toBeDefined();
|
||||
expect(paths['/api/radarr/movies']).toBeDefined();
|
||||
expect(paths['/api/radarr/notifications']).toBeDefined();
|
||||
expect(paths['/api/radarr/notifications/sofarr-webhook']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have SABnzbd proxy endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/sabnzbd/queue']).toBeDefined();
|
||||
expect(paths['/api/sabnzbd/history']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have Emby proxy endpoints documented', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
expect(paths['/api/emby/sessions']).toBeDefined();
|
||||
expect(paths['/api/emby/users']).toBeDefined();
|
||||
expect(paths['/api/emby/session/{sessionId}/user']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 200 for Swagger UI endpoint', async () => {
|
||||
const response = await request(app).get('/api/swagger');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/html');
|
||||
});
|
||||
|
||||
it('should serve OpenAPI spec JSON at /api/swagger.json', async () => {
|
||||
const response = await request(app).get('/api/swagger.json');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('application/json');
|
||||
|
||||
const spec = response.body;
|
||||
expect(spec.openapi).toBe('3.1.0');
|
||||
expect(spec.info).toBeDefined();
|
||||
expect(spec.paths).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have valid JSON examples in schema definitions', () => {
|
||||
const schemas = openapiSpec.components.schemas;
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (schema.example) {
|
||||
expect(() => JSON.stringify(schema.example)).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid JSON examples in response examples', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
for (const [path, pathObj] of Object.entries(paths)) {
|
||||
for (const [method, operation] of Object.entries(pathObj)) {
|
||||
if (operation.responses) {
|
||||
for (const [statusCode, response] of Object.entries(operation.responses)) {
|
||||
if (response.content && response.content['application/json']) {
|
||||
const content = response.content['application/json'];
|
||||
if (content.example) {
|
||||
expect(() => JSON.stringify(content.example)).not.toThrow();
|
||||
}
|
||||
if (content.examples) {
|
||||
for (const example of Object.values(content.examples)) {
|
||||
expect(() => JSON.stringify(example)).not.toThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid JSON examples in request bodies', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
for (const [path, pathObj] of Object.entries(paths)) {
|
||||
for (const [method, operation] of Object.entries(pathObj)) {
|
||||
if (operation.requestBody) {
|
||||
const content = operation.requestBody.content;
|
||||
if (content && content['application/json']) {
|
||||
if (content.example) {
|
||||
expect(() => JSON.stringify(content.example)).not.toThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have x-code-samples for critical endpoints', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
// Check that auth endpoints have code samples
|
||||
expect(paths['/api/auth/login'].post['x-code-samples']).toBeDefined();
|
||||
expect(paths['/api/auth/login'].post['x-code-samples'].length).toBeGreaterThan(0);
|
||||
|
||||
// Check that webhook endpoints have code samples
|
||||
expect(paths['/api/webhook/sonarr'].post['x-code-samples']).toBeDefined();
|
||||
expect(paths['/api/webhook/sonarr'].post['x-code-samples'].length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have x-integration-notes for critical endpoints', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
// Check that auth login has integration notes
|
||||
const loginDesc = paths['/api/auth/login'].post.description || '';
|
||||
expect(loginDesc).toContain('x-integration-notes');
|
||||
|
||||
// Check that stream SSE has integration notes
|
||||
const streamDesc = paths['/api/dashboard/stream'].get.description || '';
|
||||
expect(streamDesc).toContain('x-integration-notes');
|
||||
});
|
||||
|
||||
it('should properly reference security schemes in operations', () => {
|
||||
const paths = openapiSpec.paths;
|
||||
|
||||
// Auth endpoints should not require auth (login, csrf)
|
||||
expect(paths['/api/auth/login'].post.security).toEqual([]);
|
||||
expect(paths['/api/auth/csrf'].get.security).toEqual([]);
|
||||
|
||||
// Protected endpoints should require CookieAuth
|
||||
expect(paths['/api/auth/me'].get.security).toContainEqual({ CookieAuth: [] });
|
||||
expect(paths['/api/dashboard/stream'].get.security).toContainEqual({ CookieAuth: [] });
|
||||
|
||||
// Mutation endpoints should require both CookieAuth and CsrfToken
|
||||
expect(paths['/api/auth/logout'].post.security).toContainEqual({ CookieAuth: [] });
|
||||
expect(paths['/api/auth/logout'].post.security).toContainEqual({ CsrfToken: [] });
|
||||
expect(paths['/api/dashboard/blocklist-search'].post.security).toContainEqual({ CookieAuth: [] });
|
||||
expect(paths['/api/dashboard/blocklist-search'].post.security).toContainEqual({ CsrfToken: [] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user