Skip to content
Open
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: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ jobs:
- name: Lint backend
run: npm --workspace=backend run lint

- name: Validate API fixtures
run: npm --workspace=backend run fixtures:validate

- name: Build backend
run: npm --workspace=backend run build

Expand Down
65 changes: 65 additions & 0 deletions backend/docs/FIXTURE_VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# API Fixture Validation

Test fixtures (the static JSON payloads under `e2e/fixtures/`) are easy to forget
when an API response shape changes. When they drift, tests pass against payloads
that no longer resemble what the API actually returns. The fixture validator
guards against this by checking every registered fixture against a schema that
mirrors the current API/domain shape.

## What it checks

- **Fixture drift** — fields that have been added, removed, or renamed in the API
but not in the fixture.
- **Schema matching** — types and enum values match the current shapes defined in
`frontend/src/types`.
- **Readable diffs** — each mismatch is reported with the exact path
(e.g. `USDC.factors.liquidityDepth`) and a description.

Findings are classified by severity:

| Severity | Meaning | Fails CI? |
| --- | --- | --- |
| `error` | Missing/mistyped required field, bad enum, unreadable/invalid JSON | Yes |
| `warning` | A property that is no longer part of the current API shape | Only with `--strict` |

## Running it

```bash
# From the repo root
npm --workspace=backend run fixtures:validate

# Treat warnings (stale extra fields) as failures too
npm --workspace=backend run fixtures:validate -- --strict

# Machine-readable output
npm --workspace=backend run fixtures:validate -- --json
```

The command exits non-zero when there are errors (or any findings under
`--strict`), which is what makes it usable as a CI gate. It runs in the `node-ci`
job in `.github/workflows/ci.yml`, right after linting.

The same checks also run as part of the backend unit test suite
(`tests/testing/fixtureValidator.test.ts`), so `npm --workspace=backend run test`
will fail if a fixture drifts.

## Updating fixtures when the API changes

1. Update the schema in
`backend/src/testing/fixtureValidator/schemas.ts` to reflect the new API shape
(keep it aligned with the canonical types in `frontend/src/types`).
2. Run `npm --workspace=backend run fixtures:validate`. The report lists every
fixture and field that no longer matches.
3. Update the offending fixture JSON in `e2e/fixtures/` to match.
4. Re-run the validator until it reports **PASS**.

## Adding a new fixture

1. Add the fixture file under `e2e/fixtures/`.
2. Add a schema describing its shape to `schemas.ts`.
3. Register the fixture in
`backend/src/testing/fixtureValidator/registry.ts` with its file path, schema,
and a short description of the API surface it represents.

That's it — the new fixture is now covered by both the CLI check and the test
suite.
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"migrate:unlock": "tsx src/database/migrate.ts unlock",
"seed": "tsx src/database/seed.ts",
"seed:specific": "tsx src/database/seed.ts run",
"docs:generate": "tsx scripts/generate-openapi.ts"
"docs:generate": "tsx scripts/generate-openapi.ts",
"fixtures:validate": "tsx scripts/validate-fixtures.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
Expand Down
24 changes: 24 additions & 0 deletions backend/scripts/validate-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
formatReport,
summarise,
validateAllFixtures,
} from "../src/testing/fixtureValidator/index.js";

function main() {
const args = process.argv.slice(2);
const asJson = args.includes("--json");
const strict = args.includes("--strict");

const results = validateAllFixtures();
const summary = summarise(results, { strict });

if (asJson) {
console.log(JSON.stringify(summary, null, 2));
} else {
console.log(formatReport(summary));
}

process.exit(summary.failed ? 1 : 0);
}

main();
41 changes: 41 additions & 0 deletions backend/src/testing/fixtureValidator/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { FixtureValidationResult, ValidationSummary } from "./validator.js";

const SYMBOLS = {
pass: "✓",
fail: "✗",
warn: "!",
};

function formatResult(result: FixtureValidationResult): string {
const lines: string[] = [];
const status = result.ok ? SYMBOLS.pass : SYMBOLS.fail;
lines.push(`${status} ${result.name} (${result.file})`);
lines.push(` ${result.description}`);

for (const finding of result.findings) {
const marker = finding.severity === "error" ? SYMBOLS.fail : SYMBOLS.warn;
const label = finding.severity.toUpperCase();
lines.push(` ${marker} ${label} at ${finding.path}: ${finding.message}`);
}

return lines.join("\n");
}

export function formatReport(summary: ValidationSummary): string {
const sections = summary.results.map(formatResult);
const total = summary.results.length;
const failedFixtures = summary.results.filter((r) => !r.ok).length;

const footer = [
"",
`Fixtures checked: ${total} | ` +
`passed: ${total - failedFixtures} | ` +
`with errors: ${failedFixtures}`,
`Findings: ${summary.errorCount} error(s), ${summary.warningCount} warning(s)`,
summary.failed
? "Result: FAIL — fixtures have drifted from the current API shapes."
: "Result: PASS — all fixtures match the current API shapes.",
];

return [...sections, ...footer].join("\n");
}
4 changes: 4 additions & 0 deletions backend/src/testing/fixtureValidator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./schemas.js";
export * from "./registry.js";
export * from "./validator.js";
export * from "./format.js";
55 changes: 55 additions & 0 deletions backend/src/testing/fixtureValidator/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { existsSync } from "fs";
import { dirname, join, relative } from "path";
import type { ZodTypeAny } from "zod";
import {
AssetHealthFixtureSchema,
AssetsFixtureSchema,
BridgesFixtureSchema,
} from "./schemas.js";

function findRepoRoot(start: string = process.cwd()): string {
let dir = start;
let parent = dirname(dir);
while (parent !== dir) {
if (existsSync(join(dir, "e2e", "fixtures"))) return dir;
dir = parent;
parent = dirname(dir);
}
return existsSync(join(dir, "e2e", "fixtures")) ? dir : start;
}

export const REPO_ROOT = findRepoRoot();

const FIXTURES_DIR = join(REPO_ROOT, "e2e", "fixtures");

export interface FixtureRegistryEntry {
name: string;
file: string;
schema: ZodTypeAny;
description: string;
}

export const fixtureRegistry: FixtureRegistryEntry[] = [
{
name: "assets",
file: join(FIXTURES_DIR, "assets.json"),
schema: AssetsFixtureSchema,
description: "Asset listing payload (GET /api/v1/assets)",
},
{
name: "bridges",
file: join(FIXTURES_DIR, "bridges.json"),
schema: BridgesFixtureSchema,
description: "Bridge status payload (GET /api/v1/bridges)",
},
{
name: "asset-health",
file: join(FIXTURES_DIR, "asset-health.json"),
schema: AssetHealthFixtureSchema,
description: "Per-asset health scores (GET /api/v1/assets/:symbol/health)",
},
];

export function toRepoRelative(file: string): string {
return relative(REPO_ROOT, file);
}
60 changes: 60 additions & 0 deletions backend/src/testing/fixtureValidator/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { z } from "zod";

export const AssetSchema = z
.object({
symbol: z.string().min(1),
name: z.string().min(1),
sourceChain: z.string().min(1).optional(),
issuerAddress: z.string().min(1).optional(),
})
.strict();

export const HealthFactorsSchema = z
.object({
liquidityDepth: z.number(),
priceStability: z.number(),
bridgeUptime: z.number(),
reserveBacking: z.number(),
volumeTrend: z.number(),
})
.strict();

export const HealthTrendSchema = z.enum(["improving", "stable", "deteriorating"]);

export const HealthScoreSchema = z
.object({
symbol: z.string().min(1),
overallScore: z.number(),
factors: HealthFactorsSchema,
trend: HealthTrendSchema,
lastUpdated: z.string().min(1),
})
.strict();

export const BridgeStatusSchema = z.enum(["healthy", "degraded", "down", "unknown"]);

export const BridgeSchema = z
.object({
name: z.string().min(1),
status: BridgeStatusSchema,
totalValueLocked: z.number(),
supplyOnStellar: z.number(),
supplyOnSource: z.number(),
mismatchPercentage: z.number(),
})
.strict();

export const AssetsFixtureSchema = z
.object({
assets: z.array(AssetSchema),
total: z.number().int().nonnegative(),
})
.strict();

export const BridgesFixtureSchema = z
.object({
bridges: z.array(BridgeSchema),
})
.strict();

export const AssetHealthFixtureSchema = z.record(z.string(), HealthScoreSchema);
Loading