morphit/apps/indexer/scripts/clearing-price-history-smoke.ts

322 lines
8.5 KiB
TypeScript

/**
* Clearing-price history smoke.
*
* Validates the pure helpers that shape the
* /v1/orderbook/featured/clearing-price-history endpoint
* response and parse its `?window=` query param. The SQL
* itself isn't exercised here (no Postgres in the smoke
* harness); an integration test would cover that. This
* smoke catches regressions in:
*
* - Query-param validation (allowlist, default fallback)
* - Wire-format shaping (NUMERIC → number, NULL → 0,
* date → YYYY-MM-DD ISO portion, max_slots constant)
* - Edge cases: empty window, dense window, under-filled
* days, decimal blurt_per_hour, single-day series
*
* Usage:
* tsx apps/indexer/scripts/clearing-price-history-smoke.ts
*/
import {
parseWindowParam,
shapeClearingResponse,
type ClearingPriceResponse
} from '../src/api/clearingPriceHistory.ts';
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
}
function expect(actual: unknown, expected: unknown, label = ''): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`${label ? label + ': ' : ''}expected ${e}, got ${a}`);
}
}
console.log('clearing-price-history smoke:\n');
// ─── parseWindowParam ─────────────────────────────────────
scenario('missing window → 30 (default)', () => {
expect(parseWindowParam(null), 30);
expect(parseWindowParam(undefined), 30);
});
scenario("'7' → 7 (allowed)", () => {
expect(parseWindowParam('7'), 7);
});
scenario("'30' → 30 (allowed)", () => {
expect(parseWindowParam('30'), 30);
});
scenario("'90' → 90 (allowed)", () => {
expect(parseWindowParam('90'), 90);
});
scenario("'14' (not in allowlist) → 30 (default)", () => {
expect(parseWindowParam('14'), 30);
});
scenario("'365' (above max) → 30 (default, query-bounding)", () => {
expect(parseWindowParam('365'), 30);
});
scenario("'-7' → 30 (defaults — negative not allowed)", () => {
expect(parseWindowParam('-7'), 30);
});
scenario("'abc' (non-numeric) → 30 (default)", () => {
expect(parseWindowParam('abc'), 30);
});
scenario("empty string '' → 30 (default)", () => {
expect(parseWindowParam(''), 30);
});
scenario("'0' → 30 (default — 0-day windows are silly)", () => {
expect(parseWindowParam('0'), 30);
});
scenario("'7.5' (parseInt drops decimals → 7) → 7", () => {
// parseInt('7.5', 10) === 7; this is a quirk of parseInt
// that we accept rather than guard against. Document it.
expect(parseWindowParam('7.5'), 7);
});
// ─── shapeClearingResponse: empty + single-row ────────────
scenario('empty rows → empty points array', () => {
const r = shapeClearingResponse([], 30);
expect(r.points, []);
expect(r.window_days, 30);
expect(r.max_slots, 3);
});
scenario('single day with full slots → clearing price exposed', () => {
const r = shapeClearingResponse(
[
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: '12.500',
active_visible_count: 3
}
],
7
);
expect(r.points.length, 1);
expect(r.points[0]!.day, '2026-05-01');
expect(r.points[0]!.clearing_blurt_per_hour, 12.5);
expect(r.points[0]!.active_visible_count, 3);
expect(r.points[0]!.max_slots, 3);
expect(r.window_days, 7);
expect(r.max_slots, 3);
});
// ─── shapeClearingResponse: under-filled days ─────────────
scenario('under-filled day (NULL clearing) → 0 in wire shape', () => {
const r = shapeClearingResponse(
[
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: null,
active_visible_count: 2 // only 2 of 3 slots filled
}
],
7
);
expect(r.points[0]!.clearing_blurt_per_hour, 0);
expect(r.points[0]!.active_visible_count, 2);
});
scenario('completely empty day (no bids) → 0 / 0', () => {
const r = shapeClearingResponse(
[
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: null,
active_visible_count: 0
}
],
7
);
expect(r.points[0]!.clearing_blurt_per_hour, 0);
expect(r.points[0]!.active_visible_count, 0);
});
// ─── Numeric edge cases ───────────────────────────────────
scenario('NUMERIC string with high precision → preserved as number', () => {
const r = shapeClearingResponse(
[
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: '0.123456',
active_visible_count: 3
}
],
7
);
expect(r.points[0]!.clearing_blurt_per_hour, 0.123456);
});
scenario('whole-number NUMERIC string → integer-typed number', () => {
const r = shapeClearingResponse(
[
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: '100',
active_visible_count: 3
}
],
7
);
expect(r.points[0]!.clearing_blurt_per_hour, 100);
});
// ─── Multi-day series (window-traversal shape) ────────────
scenario('7-day series with mixed under/full days', () => {
const rows = [
{
day: new Date('2026-04-25T00:00:00Z'),
clearing_blurt_per_hour: '5.000',
active_visible_count: 3
},
{
day: new Date('2026-04-26T00:00:00Z'),
clearing_blurt_per_hour: '5.000',
active_visible_count: 3
},
{
day: new Date('2026-04-27T00:00:00Z'),
clearing_blurt_per_hour: null,
active_visible_count: 3
},
{
day: new Date('2026-04-28T00:00:00Z'),
clearing_blurt_per_hour: null,
active_visible_count: 0
},
{
day: new Date('2026-04-29T00:00:00Z'),
clearing_blurt_per_hour: '8.000',
active_visible_count: 3
},
{
day: new Date('2026-04-30T00:00:00Z'),
clearing_blurt_per_hour: '12.500',
active_visible_count: 3
},
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: '15.000',
active_visible_count: 3
}
];
const r = shapeClearingResponse(rows, 7);
expect(r.points.length, 7);
expect(
r.points.map((p) => p.clearing_blurt_per_hour),
[5, 5, 0, 0, 8, 12.5, 15]
);
expect(
r.points.map((p) => p.day),
[
'2026-04-25',
'2026-04-26',
'2026-04-27',
'2026-04-28',
'2026-04-29',
'2026-04-30',
'2026-05-01'
]
);
});
// ─── Defensive shaping ────────────────────────────────────
scenario('day with timestamp not at midnight UTC still produces YYYY-MM-DD', () => {
// Postgres ::date casts strip time, but defensive: even if
// the row arrives with a non-midnight timestamp, the slice
// pulls the date portion.
const r = shapeClearingResponse(
[
{
day: new Date('2026-05-01T15:30:45Z'),
clearing_blurt_per_hour: '1.000',
active_visible_count: 3
}
],
7
);
expect(r.points[0]!.day, '2026-05-01');
});
// ─── Window value pass-through ────────────────────────────
scenario('window_days reflects what was passed in', () => {
const r1 = shapeClearingResponse([], 7);
const r2 = shapeClearingResponse([], 30);
const r3 = shapeClearingResponse([], 90);
expect(r1.window_days, 7);
expect(r2.window_days, 30);
expect(r3.window_days, 90);
});
// ─── max_slots invariant ──────────────────────────────────
scenario('max_slots is constant 3 in both wire shape and per-point', () => {
const r = shapeClearingResponse(
[
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: '1.000',
active_visible_count: 3
}
],
7
);
expect(r.max_slots, 3);
expect(r.points[0]!.max_slots, 3);
});
// ─── Type-narrowing sanity ────────────────────────────────
scenario('shape is well-typed and JSON-serializable', () => {
const r: ClearingPriceResponse = shapeClearingResponse(
[
{
day: new Date('2026-05-01T00:00:00Z'),
clearing_blurt_per_hour: '12.500',
active_visible_count: 3
}
],
7
);
const json = JSON.stringify(r);
const parsed = JSON.parse(json);
expect(parsed.points[0].day, '2026-05-01');
expect(parsed.points[0].clearing_blurt_per_hour, 12.5);
expect(parsed.window_days, 7);
});
console.log(
`\n${failures === 0 ? '✓ all' : '✗'} ${scenarios - failures}${failures === 0 ? '' : '/' + scenarios} scenarios passed`
);
process.exit(failures === 0 ? 0 : 1);