diff --git a/.gitignore b/.gitignore index d9b2868e..191e3258 100644 --- a/.gitignore +++ b/.gitignore @@ -154,5 +154,6 @@ GEMINI.md # Local planning/spec docs (not committed) docs/superpowers/ plan.md +tasks/ .playwright-mcp/ dump.rdb \ No newline at end of file diff --git a/app/backend/src/test-error/test-error.controller.ts b/app/backend/src/test-error/test-error.controller.ts index d08d8b9f..69828c19 100644 --- a/app/backend/src/test-error/test-error.controller.ts +++ b/app/backend/src/test-error/test-error.controller.ts @@ -9,6 +9,7 @@ import { UnauthorizedException, ForbiddenException, NotFoundException, + ServiceUnavailableException, UsePipes, } from '@nestjs/common'; import { CreateVerificationDto } from '../verification/dto/create-verification.dto'; @@ -22,6 +23,7 @@ import { ApiUnauthorizedResponse, ApiForbiddenResponse, ApiNotFoundResponse, + ApiServiceUnavailableResponse, } from '@nestjs/swagger'; @ApiTags('Test Error') @@ -64,6 +66,15 @@ export class TestErrorController { throw new ForbiddenException('Access denied'); } + @ApiOperation({ summary: 'Trigger a ServiceUnavailableException' }) + @ApiServiceUnavailableResponse({ + description: 'Service unavailable error triggered.', + }) + @Get('service-unavailable') + getServiceUnavailable() { + throw new ServiceUnavailableException('Service temporarily unavailable'); + } + @ApiOperation({ summary: 'Trigger a NotFoundException' }) @ApiNotFoundResponse({ description: 'Not found error triggered.' }) @Get('not-found') diff --git a/app/backend/test/error-envelope-coverage.spec.ts b/app/backend/test/error-envelope-coverage.spec.ts new file mode 100644 index 00000000..594e998a --- /dev/null +++ b/app/backend/test/error-envelope-coverage.spec.ts @@ -0,0 +1,125 @@ +/** + * Error-envelope coverage matrix (issue #286). + * + * Static-analysis meta-test: verifies that every 4xx/5xx status code + * documented in the committed OpenAPI spec (app/frontend/openapi.json, + * kept fresh by CI's `spec:export` drift check) has a corresponding + * envelope test in test/error-handling.e2e-spec.ts. + * + * Granularity is distinct status codes — not per-endpoint pairs — because + * the envelope is produced by a single global filter + * (src/common/filters/http-exception.filter.ts), so one test per status + * proves the envelope shape for every endpoint returning that status. + * + * CONVENTION: this test detects tested statuses by scanning the e2e file + * for literal `.expect(NNN)` calls. Tests using a variable status + * (`.expect(status)`) are NOT counted — always assert with a literal. + * + * No app bootstrap, DB, or Redis required — pure file reading, safe under + * the unit jest config (`npm test`) in CI. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const OPENAPI_SPEC_PATH = path.resolve( + __dirname, + '../../frontend/openapi.json', +); +const E2E_SPEC_PATH = path.resolve(__dirname, 'error-handling.e2e-spec.ts'); + +function readOpenApiSpec(): Record { + if (!fs.existsSync(OPENAPI_SPEC_PATH)) { + throw new Error( + `OpenAPI spec not found at ${OPENAPI_SPEC_PATH}. ` + + `Run \`npm run spec:export\` in app/backend to regenerate it.`, + ); + } + try { + return JSON.parse(fs.readFileSync(OPENAPI_SPEC_PATH, 'utf8')); + } catch (err) { + throw new Error( + `OpenAPI spec at ${OPENAPI_SPEC_PATH} is not valid JSON ` + + `(${(err as Error).message}). Run \`npm run spec:export\` in app/backend.`, + ); + } +} + +/** Distinct 4xx/5xx statuses documented anywhere in the spec's paths. */ +function collectDocumentedErrorStatuses(spec: Record): { + statuses: Set; + endpointsByStatus: Map; +} { + const statuses = new Set(); + const endpointsByStatus = new Map(); + const paths = (spec.paths ?? {}) as Record< + string, + Record }> + >; + + for (const [route, operations] of Object.entries(paths)) { + for (const [method, operation] of Object.entries(operations)) { + if (!operation || typeof operation !== 'object') continue; + for (const statusKey of Object.keys(operation.responses ?? {})) { + const status = Number(statusKey); + if (Number.isInteger(status) && status >= 400 && status <= 599) { + statuses.add(status); + const endpoints = endpointsByStatus.get(status) ?? []; + endpoints.push(`${method.toUpperCase()} ${route}`); + endpointsByStatus.set(status, endpoints); + } + } + } + } + return { statuses, endpointsByStatus }; +} + +/** Statuses asserted via literal `.expect(NNN)` calls in the e2e spec. */ +function collectTestedStatuses(): Set { + if (!fs.existsSync(E2E_SPEC_PATH)) { + throw new Error(`Envelope e2e spec not found at ${E2E_SPEC_PATH}.`); + } + const source = fs + .readFileSync(E2E_SPEC_PATH, 'utf8') + // Strip comments so status codes mentioned in docs don't count as tested + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, ''); + const tested = new Set(); + for (const match of source.matchAll(/\.expect\((\d{3})\)/g)) { + tested.add(Number(match[1])); + } + return tested; +} + +describe('Error-envelope coverage matrix', () => { + it('every documented 4xx/5xx status has an envelope test in error-handling.e2e-spec.ts', () => { + const spec = readOpenApiSpec(); + const { statuses: documented, endpointsByStatus } = + collectDocumentedErrorStatuses(spec); + const tested = collectTestedStatuses(); + + expect(documented.size).toBeGreaterThan(0); + + const missing = [...documented] + .filter(status => !tested.has(status)) + .sort((a, b) => a - b); + + if (missing.length > 0) { + const detail = missing + .map(status => { + const endpoints = endpointsByStatus.get(status) ?? []; + const sample = endpoints.slice(0, 3).join(', '); + const more = + endpoints.length > 3 ? ` (+${endpoints.length - 3} more)` : ''; + return ` - ${status} — documented on: ${sample}${more}`; + }) + .join('\n'); + throw new Error( + `Documented error status(es) with no envelope test in ` + + `test/error-handling.e2e-spec.ts:\n${detail}\n\n` + + `Add a test asserting the global error envelope with a LITERAL ` + + `status (e.g. \`.expect(${missing[0]})\`) — variable statuses are ` + + `not detected by this matrix.`, + ); + } + }); +}); diff --git a/app/backend/test/error-handling.e2e-spec.ts b/app/backend/test/error-handling.e2e-spec.ts index c0629438..bc8d3663 100644 --- a/app/backend/test/error-handling.e2e-spec.ts +++ b/app/backend/test/error-handling.e2e-spec.ts @@ -1,3 +1,12 @@ +/** + * Error-envelope e2e tests. + * + * CONVENTION: every test must assert its status with a literal number, + * e.g. `.expect(503)` — never a variable. The coverage matrix in + * `test/error-envelope-coverage.spec.ts` statically scans this file for + * `.expect(NNN)` literals to verify that every 4xx/5xx status documented + * in the OpenAPI spec has an envelope test here. + */ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, @@ -11,7 +20,12 @@ import { RequestIdInterceptor } from '../src/common/interceptors/request-id.inte describe('Error Handling (e2e)', () => { let app: INestApplication; + // ApiKeyGuard is global; use its env-var fallback so requests reach the + // test-error controller instead of short-circuiting with 401. + const API_KEY = process.env.API_KEY || 'test-api-key-error-handling'; + beforeEach(async () => { + process.env.API_KEY = API_KEY; const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); @@ -52,9 +66,10 @@ describe('Error Handling (e2e)', () => { expect(typeof body.path).toBe('string'); }; - it('/test-error/generic-error (GET) - should return standardized error response', () => { + it('/test-error/generic (GET) - should return standardized error response', () => { return request(app.getHttpServer()) - .get('/api/v1/test-error/generic-error') + .get('/api/v1/test-error/generic') + .set('x-api-key', API_KEY) .expect(500) .then(response => { expectErrorEnvelope(response.body); @@ -66,7 +81,7 @@ describe('Error Handling (e2e)', () => { }), traceId: expect.any(String), timestamp: expect.any(String), - path: '/api/v1/test-error/generic-error', + path: '/api/v1/test-error/generic', }); }); }); @@ -74,6 +89,7 @@ describe('Error Handling (e2e)', () => { it('/test-error/bad-request (GET) - should return standardized error response', () => { return request(app.getHttpServer()) .get('/api/v1/test-error/bad-request') + .set('x-api-key', API_KEY) .expect(400) .then(response => { expectErrorEnvelope(response.body); @@ -91,6 +107,7 @@ describe('Error Handling (e2e)', () => { it('/test-error/internal-server-error (GET) - should return standardized error response', () => { return request(app.getHttpServer()) .get('/api/v1/test-error/internal-server-error') + .set('x-api-key', API_KEY) .expect(500) .then(response => { expectErrorEnvelope(response.body); @@ -108,6 +125,7 @@ describe('Error Handling (e2e)', () => { it('/test-error/unauthorized (GET) - should return 401 with standardized envelope', () => { return request(app.getHttpServer()) .get('/api/v1/test-error/unauthorized') + .set('x-api-key', API_KEY) .expect(401) .then(response => { expectErrorEnvelope(response.body); @@ -120,6 +138,7 @@ describe('Error Handling (e2e)', () => { it('/test-error/forbidden (GET) - should return 403 with standardized envelope', () => { return request(app.getHttpServer()) .get('/api/v1/test-error/forbidden') + .set('x-api-key', API_KEY) .expect(403) .then(response => { expectErrorEnvelope(response.body); @@ -132,6 +151,7 @@ describe('Error Handling (e2e)', () => { it('/test-error/not-found (GET) - should return 404 with standardized envelope', () => { return request(app.getHttpServer()) .get('/api/v1/test-error/not-found') + .set('x-api-key', API_KEY) .expect(404) .then(response => { expectErrorEnvelope(response.body); @@ -141,9 +161,23 @@ describe('Error Handling (e2e)', () => { }); }); + it('/test-error/service-unavailable (GET) - should return 503 with standardized envelope', () => { + return request(app.getHttpServer()) + .get('/api/v1/test-error/service-unavailable') + .set('x-api-key', API_KEY) + .expect(503) + .then(response => { + expectErrorEnvelope(response.body); + expect(response.body.code).toBe(503); + expect(response.body.message).toBe('Service temporarily unavailable'); + expect(response.body).toHaveProperty('traceId'); + }); + }); + it('/test-error/validation-error (POST) - should return standardized validation error response', () => { return request(app.getHttpServer()) .post('/api/v1/test-error/validation-error') + .set('x-api-key', API_KEY) .send({ invalidField: 'invalid' }) .expect(400) .then(response => { @@ -156,6 +190,7 @@ describe('Error Handling (e2e)', () => { it('/test-error/prisma-error-simulation (GET) - should return standardized Prisma error response', () => { return request(app.getHttpServer()) .get('/api/v1/test-error/prisma-error-simulation') + .set('x-api-key', API_KEY) .expect(409) .then(response => { expectErrorEnvelope(response.body); @@ -176,6 +211,7 @@ describe('Error Handling (e2e)', () => { it('should include X-Request-ID header in response', () => { return request(app.getHttpServer()) .get('/api/v1/test-error/bad-request') + .set('x-api-key', API_KEY) .expect(400) .then(response => { expect(response.headers).toHaveProperty('x-request-id'); @@ -187,6 +223,7 @@ describe('Error Handling (e2e)', () => { const customTraceId = 'MY-CUSTOM-TRACE-ID'; return request(app.getHttpServer()) .get('/api/v1/test-error/bad-request') + .set('x-api-key', API_KEY) .set('X-Request-ID', customTraceId) .expect(400) .then(response => { diff --git a/app/backend/test/jest-e2e.json b/app/backend/test/jest-e2e.json index 988edaeb..3e8d8151 100644 --- a/app/backend/test/jest-e2e.json +++ b/app/backend/test/jest-e2e.json @@ -8,6 +8,7 @@ }, "moduleNameMapper": { "^src/(.*)$": "/src/$1", + "^cache/(.*)$": "/cache/$1", "^@stellar/stellar-sdk$": "/test/mocks/stellar-sdk.mock.ts", "^openai$": "/test/mocks/openai.mock.ts" } diff --git a/app/frontend/openapi.json b/app/frontend/openapi.json index c7f11262..0dce0ed1 100644 --- a/app/frontend/openapi.json +++ b/app/frontend/openapi.json @@ -2050,6 +2050,21 @@ ] } }, + "/api/v1/test-error/service-unavailable": { + "get": { + "operationId": "TestErrorController_getServiceUnavailable_v1", + "parameters": [], + "responses": { + "503": { + "description": "Service unavailable error triggered." + } + }, + "summary": "Trigger a ServiceUnavailableException", + "tags": [ + "Test Error" + ] + } + }, "/api/v1/test-error/not-found": { "get": { "operationId": "TestErrorController_getNotFound_v1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd7d856..e393c248 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: class-validator: specifier: ^0.14.3 version: 0.14.4 + compression: + specifier: 1.7.5 + version: 1.7.5 dotenv: specifier: ^17.2.3 version: 17.4.2 @@ -185,6 +188,9 @@ importers: '@nestjs/testing': specifier: ^11.0.1 version: 11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)) + '@types/compression': + specifier: 1.7.5 + version: 1.7.5 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -3242,6 +3248,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/compression@1.7.5': + resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -4370,8 +4379,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + compression@1.7.5: + resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -6973,8 +6982,8 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} once@1.4.0: @@ -9792,7 +9801,7 @@ snapshots: bplist-parser: 0.3.2 chalk: 4.1.2 ci-info: 3.9.0 - compression: 1.8.1 + compression: 1.7.5 connect: 3.7.0 debug: 4.4.3(supports-color@10.2.2) env-editor: 0.4.2 @@ -12192,6 +12201,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.9.1 + '@types/compression@1.7.5': + dependencies: + '@types/express': 5.0.6 + '@types/connect@3.4.38': dependencies: '@types/node': 25.9.1 @@ -13661,13 +13674,13 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.7.5: dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9 negotiator: 0.6.4 - on-headers: 1.1.0 + on-headers: 1.0.2 safe-buffer: 5.2.1 vary: 1.1.2 transitivePeerDependencies: @@ -14157,7 +14170,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -17178,7 +17191,7 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.1.0: {} + on-headers@1.0.2: {} once@1.4.0: dependencies: