From 6980558ca9fdc04eb24beb2061181c22a467658f Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 21 May 2026 12:38:29 +0100 Subject: [PATCH] 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 --- tests/integration/swagger-coverage.test.js | 275 +++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 tests/integration/swagger-coverage.test.js diff --git a/tests/integration/swagger-coverage.test.js b/tests/integration/swagger-coverage.test.js new file mode 100644 index 0000000..33a3505 --- /dev/null +++ b/tests/integration/swagger-coverage.test.js @@ -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: [] }); + }); +});