Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -154,5 +154,6 @@ GEMINI.md
# Local planning/spec docs (not committed)
docs/superpowers/
plan.md
tasks/
.playwright-mcp/
dump.rdb
11 changes: 11 additions & 0 deletions app/backend/src/test-error/test-error.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
UnauthorizedException,
ForbiddenException,
NotFoundException,
ServiceUnavailableException,
UsePipes,
} from '@nestjs/common';
import { CreateVerificationDto } from '../verification/dto/create-verification.dto';
Expand All @@ -22,6 +23,7 @@ import {
ApiUnauthorizedResponse,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiServiceUnavailableResponse,
} from '@nestjs/swagger';

@ApiTags('Test Error')
Expand Down Expand Up @@ -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')
Expand Down
125 changes: 125 additions & 0 deletions app/backend/test/error-envelope-coverage.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>): {
statuses: Set<number>;
endpointsByStatus: Map<number, string[]>;
} {
const statuses = new Set<number>();
const endpointsByStatus = new Map<number, string[]>();
const paths = (spec.paths ?? {}) as Record<
string,
Record<string, { responses?: Record<string, unknown> }>
>;

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<number> {
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<number>();
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.`,
);
}
});
});
43 changes: 40 additions & 3 deletions app/backend/test/error-handling.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -66,14 +81,15 @@ 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',
});
});
});

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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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 => {
Expand All @@ -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);
Expand All @@ -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');
Expand All @@ -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 => {
Expand Down
1 change: 1 addition & 0 deletions app/backend/test/jest-e2e.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1",
"^cache/(.*)$": "<rootDir>/cache/$1",
"^@stellar/stellar-sdk$": "<rootDir>/test/mocks/stellar-sdk.mock.ts",
"^openai$": "<rootDir>/test/mocks/openai.mock.ts"
}
Expand Down
15 changes: 15 additions & 0 deletions app/frontend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading