diff --git a/backend/schema-snapshots/get-_api_v1_transactions.json b/backend/schema-snapshots/get-_api_v1_transactions.json index 632fb780..21034640 100644 --- a/backend/schema-snapshots/get-_api_v1_transactions.json +++ b/backend/schema-snapshots/get-_api_v1_transactions.json @@ -66,8 +66,7 @@ "type": "number" }, "nextCursor": { - "type": "string", - "nullable": true + "type": "string" }, "prevCursor": { "type": "string" diff --git a/backend/src/__tests__/issues711.test.ts b/backend/src/__tests__/issues711.test.ts index c4817c79..d09f97a9 100644 --- a/backend/src/__tests__/issues711.test.ts +++ b/backend/src/__tests__/issues711.test.ts @@ -8,10 +8,11 @@ import { diffSchemaShapes, generateSnapshotFor, loadSnapshot, + snapshotPathFor, validateResponseAgainstSchema, - writeAllSnapshots, zodToJsonShape, HealthResponseSchema, + JsonSchemaShape, } from '../apiContractSnapshots'; import * as fs from 'fs'; import * as path from 'path'; @@ -19,10 +20,6 @@ import * as path from 'path'; const SNAPSHOT_DIR = path.join(__dirname, '..', '..', 'schema-snapshots'); describe('#711 API contract schema snapshots', () => { - beforeAll(() => { - writeAllSnapshots(); - }); - it('defines snapshots for all critical public endpoints', () => { expect(CRITICAL_ENDPOINTS.length).toBeGreaterThanOrEqual(4); for (const endpoint of CRITICAL_ENDPOINTS) { @@ -53,12 +50,6 @@ describe('#711 API contract schema snapshots', () => { const current = zodToJsonShape(HealthResponseSchema); const baseline = JSON.parse(JSON.stringify(current)) as typeof current; // Simulate an older snapshot that is missing the 'indexer' field - const current = zodToJsonShape(HealthResponseSchema); - const baseline = JSON.parse(JSON.stringify(current)) as typeof current; - // Simulate an older snapshot that is missing the 'indexer' field, so the - // live schema now has a required field the committed snapshot does not know - // about. `baseline` is the committed snapshot and `current` is the live - // schema, so the deletion has to happen on the baseline. delete baseline.properties?.checks?.properties?.indexer; baseline.properties!.checks!.required = (baseline.properties!.checks!.required ?? []).filter( (k: string) => k !== 'indexer', @@ -176,4 +167,26 @@ describe('#711 API contract schema snapshots', () => { const issues = diffSchemaShapes(baseline, current, 'GET /health'); expect(issues.some((issue) => issue.message === 'new field added to live schema (snapshot drift)' && issue.path === 'GET /health.newField')).toBe(true); }); + + // #1378: committed snapshots must be generator output, not hand-edited, and + // must carry the indexer check that /health and /ready emit. + it('committed snapshots match generator output byte-for-byte', () => { + for (const endpoint of CRITICAL_ENDPOINTS) { + const committed = fs.readFileSync(snapshotPathFor(endpoint), 'utf8'); + expect(committed).toBe(JSON.stringify(generateSnapshotFor(endpoint), null, 2) + '\n'); + } + }); + + it('health and ready snapshots include the indexer check', () => { + const health = loadSnapshot('GET /health'); + expect(health?.properties?.checks?.properties?.indexer).toEqual({ + type: 'string', + enum: ['up', 'down', 'degraded', 'unknown'], + }); + expect(health?.properties?.checks?.required).toContain('indexer'); + + const ready = loadSnapshot('GET /ready'); + expect(ready?.properties?.dependencies?.properties?.indexer).toEqual({ type: 'boolean' }); + expect(ready?.properties?.dependencies?.required).toContain('indexer'); + }); }); diff --git a/backend/src/apiContractSnapshots.ts b/backend/src/apiContractSnapshots.ts index 7c1aa8f9..982e3a2e 100644 --- a/backend/src/apiContractSnapshots.ts +++ b/backend/src/apiContractSnapshots.ts @@ -59,6 +59,12 @@ export const ReadyResponseSchema = z database: z.boolean(), prisma: z.boolean(), indexer: z.boolean(), + redis: z.object({ + status: z.enum(['up', 'down']), + optional: z.boolean().optional(), + latencyMs: z.number().optional(), + error: z.string().optional(), + }), }), }) .strict(); @@ -273,11 +279,7 @@ export function diffSchemaShapes( } for (const key of baselineRequired) { - // `in` binds tighter than `??`, so the parentheses are required: without - // them this reads as `(key in baseline.properties) ?? {}` and throws a - // TypeError whenever a committed snapshot has no `properties` object. if (!(key in baselineProps)) { - if (!(key in (baseline.properties ?? {}))) { issues.push({ path: at(key), message: 'required field missing from snapshot properties (orphaned reference)' }); } if (!(key in currentProps)) { @@ -288,7 +290,7 @@ export function diffSchemaShapes( } for (const key of currentRequired) { - if (!(key in (current.properties ?? {}))) { + if (!(key in currentProps)) { issues.push({ path: at(key), message: 'required field missing from live schema properties (invalid schema)' }); continue; }