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
3 changes: 1 addition & 2 deletions backend/schema-snapshots/get-_api_v1_transactions.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@
"type": "number"
},
"nextCursor": {
"type": "string",
"nullable": true
"type": "string"
},
"prevCursor": {
"type": "string"
Expand Down
35 changes: 24 additions & 11 deletions backend/src/__tests__/issues711.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,18 @@ import {
diffSchemaShapes,
generateSnapshotFor,
loadSnapshot,
snapshotPathFor,
validateResponseAgainstSchema,
writeAllSnapshots,
zodToJsonShape,
HealthResponseSchema,
JsonSchemaShape,
} from '../apiContractSnapshots';
import * as fs from 'fs';
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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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');
});
});
12 changes: 7 additions & 5 deletions backend/src/apiContractSnapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}
Expand Down
Loading