Files
sofarr/tests/integration/swagger-coverage.test.js
T
gronod 6980558ca9 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
2026-05-21 12:38:29 +01:00

276 lines
10 KiB
JavaScript

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