From 23f4224846c2f1c8e995b2f3c94f9d9a9bce7361 Mon Sep 17 00:00:00 2001 From: Sanskar Kharya Date: Mon, 28 Sep 2026 06:04:26 -0500 Subject: [PATCH 1/3] fix(utils): close withRequestId docstring (unblocks build on main) --- listener/src/utils/request-id.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/listener/src/utils/request-id.ts b/listener/src/utils/request-id.ts index 0a9c61f7..ef670efc 100644 --- a/listener/src/utils/request-id.ts +++ b/listener/src/utils/request-id.ts @@ -14,6 +14,9 @@ export function generateRequestId(): string { */ export function generateCorrelationId(): string { return randomUUID(); +} + +/** * Client-supplied request IDs must be printable ASCII tokens of bounded length. * Rejects empty values, control characters, whitespace, and oversized strings * so untrusted header content is never reused as a log/trace key (#686). From cd8386425eb90fe220a4609b92d4b3dfcf53b5df Mon Sep 17 00:00:00 2001 From: Sanskar Kharya Date: Mon, 28 Sep 2026 06:04:49 -0500 Subject: [PATCH 2/3] test(api): listener API contract tests Add API contract tests for events API responses and error handling. --- listener/src/__tests__/api-contract.test.ts | 143 ++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 listener/src/__tests__/api-contract.test.ts diff --git a/listener/src/__tests__/api-contract.test.ts b/listener/src/__tests__/api-contract.test.ts new file mode 100644 index 00000000..30a17359 --- /dev/null +++ b/listener/src/__tests__/api-contract.test.ts @@ -0,0 +1,143 @@ +/** + * API contract tests (#811). + * + * Pin the request/response structure of the events API so unintended + * breaking changes fail loudly in CI instead of reaching consumers. + * + * Covered contracts: + * - Success envelope: { success: true, data: ... } on every 2xx. + * - Error envelope: { success: false, error: { code, message } } on + * 4xx/5xx, including expected error responses. + * - Per-endpoint payload shape for the important read endpoints. + * + * These tests intentionally assert structure (field presence + types), + * not exact values, so legitimate data changes don't break the build + * while schema changes do. + */ +import http from 'http'; +import { createEventsServer } from '../api/events-server'; + +const TEST_PORT = 19878; + +type Shape = + | 'number' + | 'string' + | 'boolean' + | 'array' + | 'object' + | 'null' + | 'any' + | { [key: string]: Shape }; + +function shapeOf(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function expectShape(value: unknown, spec: Shape, path: string): void { + if (spec === 'any') return; + if (typeof spec === 'string') { + expect({ path, actual: shapeOf(value) }).toEqual({ path, actual: spec }); + return; + } + expect({ path, actual: shapeOf(value) }).toEqual({ path, actual: 'object' }); + const obj = value as Record; + for (const [key, sub] of Object.entries(spec)) { + expect({ path: `${path}.${key}`, present: key in obj }).toEqual({ path: `${path}.${key}`, present: true }); + expectShape(obj[key], sub, `${path}.${key}`); + } +} + +const SUCCESS_ENVELOPE: Shape = { success: 'boolean', data: 'any' }; +const ERROR_ENVELOPE: Shape = { + success: 'boolean', + error: { code: 'string', message: 'string' }, +}; + +describe('events API contract', () => { + let server: http.Server; + + function getJson(path: string, method = 'GET'): Promise<{ status: number; body: any }> { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: '127.0.0.1', port: TEST_PORT, path, method }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + let body: any = null; + try { body = JSON.parse(data); } catch { /* non-JSON body */ } + resolve({ status: res.statusCode ?? 0, body }); + }); + }); + req.on('error', reject); + req.end(); + }); + } + + beforeAll((done) => { + server = createEventsServer({ + port: TEST_PORT, + stellarRpcUrl: 'http://localhost:8000', + stellarNetworkPassphrase: 'Test SDF Network ; September 2015', + contractAddresses: [], + }); + server.listen(TEST_PORT, done); + }); + + afterAll((done) => { + server.close(done); + }); + + test('GET /api/events returns the success envelope with count + events array', async () => { + const { status, body } = await getJson('/api/events'); + expect(status).toBe(200); + expectShape(body, SUCCESS_ENVELOPE, '$'); + expect(body.success).toBe(true); + expectShape(body.data, { count: 'number', events: 'array' }, '$.data'); + }); + + test('GET /api/schedule/jobs returns monitoring snapshot contract', async () => { + const { status, body } = await getJson('/api/schedule/jobs'); + expect(status).toBe(200); + expect(body.success).toBe(true); + expectShape(body.data, { recentJobs: 'array', recentFailures: 'array' }, '$.data'); + }); + + test('GET /api/schedule/jobs/failures returns failures list contract', async () => { + const { status, body } = await getJson('/api/schedule/jobs/failures'); + expect(status).toBe(200); + expect(body.success).toBe(true); + expectShape(body.data, { failures: 'array', count: 'number' }, '$.data'); + }); + + test('unknown route returns the error envelope with 404', async () => { + const { status, body } = await getJson('/api/definitely-not-a-route'); + expect(status).toBe(404); + expectShape(body, ERROR_ENVELOPE, '$'); + expect(body.success).toBe(false); + }); + + test('service-unavailable responses use the same error envelope (expected error response)', async () => { + // No analytics aggregator is configured in this test server, so this + // endpoint exercises the expected-error contract path. + const { status, body } = await getJson('/api/analytics'); + if (status === 503) { + expectShape(body, ERROR_ENVELOPE, '$'); + expect(body.success).toBe(false); + expect(typeof body.error.code).toBe('string'); + } else { + // Aggregator available: success envelope must hold instead. + expect(status).toBe(200); + expectShape(body, SUCCESS_ENVELOPE, '$'); + } + }); + + test('responses carry correlation/request tracing headers (part of the API contract)', async () => { + const { status, body } = await getJson('/api/events'); + expect(status).toBe(200); + expect(body.success).toBe(true); + // Header contract checked separately in events-server.test.ts; here we + // assert the body envelope stays stable regardless of tracing headers. + expectShape(body, SUCCESS_ENVELOPE, '$'); + }); +}); From 1c553b5a4045fcd71750283393af2a3db94a0f34 Mon Sep 17 00:00:00 2001 From: Sanskar Kharya Date: Mon, 28 Sep 2026 06:05:18 -0500 Subject: [PATCH 3/3] ci: run contract tests on pull requests This workflow runs API contract tests on every push to main and pull request, ensuring no unintended changes occur in the events API request/response structures. --- .github/workflows/contract-tests.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/contract-tests.yml diff --git a/.github/workflows/contract-tests.yml b/.github/workflows/contract-tests.yml new file mode 100644 index 00000000..dcf6dd42 --- /dev/null +++ b/.github/workflows/contract-tests.yml @@ -0,0 +1,26 @@ +name: API Contract Tests + +# Detects unintended changes to events API request/response structures +# (issue #811). Runs the contract suite on every PR and push to main. + +on: + push: + branches: [main] + pull_request: + +jobs: + contract-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: listener + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: listener/package-lock.json + - run: npm ci + - name: Run API contract tests + run: node ./node_modules/jest/bin/jest.js src/__tests__/api-contract.test.ts --ci