morphit/apps/matrix-bot/scripts/sse-stream-shape-smoke.ts

308 lines
9 KiB
TypeScript

#!/usr/bin/env tsx
/**
* sse-stream-shape-smoke — validates the Server-Sent Events
* wire-format shapes for the three streaming endpoints:
* - /v1/orderbook/stream (orderbookStream.ts)
* - /v1/instances/stream (instancesStream.ts)
* - /v1/chat/:a/:b/stream (chatStream.ts)
*
* Each stream emits multiple event types (typically a `snapshot`
* on connect, then incremental `*_added` / `*_upserted` / etc.
* events as data changes). Each event's `data:` payload has a
* specific JSON shape the frontend's EventSource handler parses.
*
* Same pattern as api-response-shape-smoke (cp15):
* 1. zod schema for each event-type payload
* 2. sample literal with `satisfies` cross-check against the
* canonical TS interface where one exists in
* @morphit/indexer-client
* 3. negative-test invalidator
*
* Why this matters: SSE consumers are LIVE. A wire-format
* drift between server emit and client parse breaks every
* connected user simultaneously, with no graceful "ask user to
* refresh" affordance. Validating the shapes locks the contract
* at CI time.
*/
import { z } from 'zod';
import type {
OrderRecord,
InstanceDirectoryEntry,
ChatMessageRecord
} from '@morphit/indexer-client';
// ─── Reusable sub-schemas ──────────────────────────────────────
// Match the schemas in api-response-shape-smoke; .passthrough()
// on optional-field-heavy objects to keep snapshot fixtures
// forward-compatible as new fields land.
const OrderRecordSchema = z
.object({
account: z.string(),
permlink: z.string(),
side: z.enum(['buy', 'sell']),
asset: z.string(),
fiat_currency: z.string(),
amount_min: z.number().nullable(),
amount_max: z.number().nullable(),
price_model: z.unknown(),
location_region: z.string().nullable(),
payment_methods: z.array(z.string()),
terms: z.string().nullable(),
status: z.enum(['live', 'cancelled', 'expired']).optional(),
fee_status: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
expires_at: z.string().nullable()
})
.passthrough();
const InstanceDirectoryEntrySchema = z
.object({
origin: z.string(),
operator_account: z.string(),
operator_tag: z.string().nullable(),
operator_display_name: z.string().nullable(),
name: z.string().nullable(),
tagline: z.string().nullable(),
contact_url: z.string().nullable(),
alt_networks: z.unknown(),
status: z.enum(['good', 'quiet', 'stale', 'unreachable', 'mismatch']),
registered_at: z.string(),
last_probed_at: z.string().nullable(),
indexed_block: z.number().nullable(),
chain_lag_sec: z.number().nullable(),
consecutive_failures: z.number()
})
.passthrough();
const ChatMessageRecordSchema = z
.object({
id: z.number(),
sender: z.string(),
recipient: z.string(),
ciphertext: z.string(),
header: z.unknown(),
created_at: z.string()
})
.passthrough();
// ─── Event-payload schemas per stream ───────────────────────────
// /v1/orderbook/stream events:
const OrderbookSnapshotSchema = z.object({
items: z.array(OrderRecordSchema),
indexed_block: z.number()
});
const OrderbookOrderUpsertedSchema = OrderRecordSchema;
const OrderbookOrderRemovedSchema = z.object({
account: z.string(),
permlink: z.string()
});
// /v1/instances/stream events:
const InstancesSnapshotSchema = z.object({
version: z.number(),
directory_updated_at: z.string(),
instances: z.array(InstanceDirectoryEntrySchema)
});
const InstancesAddedSchema = InstanceDirectoryEntrySchema;
const InstancesUpdatedSchema = InstanceDirectoryEntrySchema;
const InstancesRemovedSchema = z.object({
origin: z.string()
});
// /v1/chat/:a/:b/stream events:
const ChatSnapshotSchema = z.object({
items: z.array(ChatMessageRecordSchema),
indexed_block: z.number()
});
const ChatMessageAppendedSchema = ChatMessageRecordSchema;
// ─── Sample payloads (TS-type-cross-check) ─────────────────────
const sampleOrderRecord = {
account: 'alice',
permlink: 'sell-btc-2026-05-15',
side: 'sell' as const,
asset: 'BTC' as const,
fiat_currency: 'USD',
amount_min: 50,
amount_max: 500,
price_model: { type: 'spread', spread_pct: 1.5 },
location_region: 'EU',
payment_methods: ['sepa', 'wise'],
terms: null,
created_at: '2026-05-15T00:00:00Z',
updated_at: '2026-05-15T00:00:00Z',
expires_at: null
} satisfies OrderRecord;
const sampleInstance = {
origin: 'https://morphit.io',
operator_account: 'morphit',
operator_tag: null,
operator_display_name: null,
name: null,
tagline: null,
contact_url: null,
alt_networks: null,
status: 'good' as const,
registered_at: '2026-05-15T00:00:00Z',
last_probed_at: null,
indexed_block: null,
chain_lag_sec: null,
consecutive_failures: 0
} satisfies InstanceDirectoryEntry;
const sampleChatMessage = {
id: 100,
sender: 'alice',
recipient: 'bob',
ciphertext: 'opaque-base64-payload-placeholder',
header: { ephemeral_pub: 'x', nonce: 'y', client_tag: 'z' },
created_at: '2026-05-15T00:00:00Z'
} satisfies ChatMessageRecord;
// ─── Scenarios ─────────────────────────────────────────────────
interface Scenario {
readonly stream: 'orderbook' | 'instances' | 'chat';
readonly event: string;
readonly schema: z.ZodTypeAny;
readonly valid: unknown;
readonly invalidate: (sample: any) => unknown;
readonly invalidReason: string;
}
const scenarios: Scenario[] = [
// ─── orderbookStream ──
{
stream: 'orderbook',
event: 'snapshot',
schema: OrderbookSnapshotSchema,
valid: { items: [sampleOrderRecord], indexed_block: 12345 },
invalidate: (s) => ({ ...s, indexed_block: 'abc' }),
invalidReason: 'indexed_block="abc" (must be number)'
},
{
stream: 'orderbook',
event: 'order_upserted',
schema: OrderbookOrderUpsertedSchema,
valid: sampleOrderRecord,
invalidate: (s) => ({ ...s, side: 'flip' }),
invalidReason: "side='flip' (must be 'buy'|'sell')"
},
{
stream: 'orderbook',
event: 'order_removed',
schema: OrderbookOrderRemovedSchema,
valid: { account: 'alice', permlink: 'sell-btc-2026-05-15' },
invalidate: (s) => {
const { account, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "account"'
},
// ─── instancesStream ──
{
stream: 'instances',
event: 'snapshot',
schema: InstancesSnapshotSchema,
valid: {
version: 1,
directory_updated_at: '2026-05-15T00:00:00Z',
instances: [sampleInstance]
},
invalidate: (s) => ({ ...s, version: '1' }),
invalidReason: 'version="1" (must be number)'
},
{
stream: 'instances',
event: 'instance_added',
schema: InstancesAddedSchema,
valid: sampleInstance,
invalidate: (s) => ({ ...s, origin: 12 }),
invalidReason: 'origin=12 (must be string)'
},
{
stream: 'instances',
event: 'instance_updated',
schema: InstancesUpdatedSchema,
valid: sampleInstance,
invalidate: (s) => ({ ...s, registered_at: null }),
invalidReason: 'registered_at=null (must be string)'
},
{
stream: 'instances',
event: 'instance_removed',
schema: InstancesRemovedSchema,
valid: { origin: 'https://morphit.io' },
invalidate: (s) => {
const { origin, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "origin"'
},
// ─── chatStream ──
{
stream: 'chat',
event: 'snapshot',
schema: ChatSnapshotSchema,
valid: { items: [sampleChatMessage], indexed_block: 12345 },
invalidate: (s) => ({ ...s, items: 'not an array' }),
invalidReason: 'items="not an array" (must be array)'
},
{
stream: 'chat',
event: 'message_appended',
schema: ChatMessageAppendedSchema,
valid: sampleChatMessage,
invalidate: (s) => ({ ...s, id: 'one hundred' }),
invalidReason: "id='one hundred' (must be number)"
}
];
// ─── Run scenarios ─────────────────────────────────────────────
console.log(
`sse-stream-shape smoke: ${scenarios.length * 2} checks ` +
`across ${new Set(scenarios.map((s) => s.stream)).size} streams\n`
);
let failed = 0;
for (const s of scenarios) {
const validResult = s.schema.safeParse(s.valid);
if (validResult.success) {
console.log(`${s.stream}/${s.event} valid sample parses`);
} else {
const issues = validResult.error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ');
console.log(`${s.stream}/${s.event} valid sample FAILED: ${issues}`);
failed++;
}
const invalidSample = s.invalidate(s.valid as Record<string, unknown>);
const invalidResult = s.schema.safeParse(invalidSample);
if (!invalidResult.success) {
console.log(`${s.stream}/${s.event} rejects ${s.invalidReason}`);
} else {
console.log(
`${s.stream}/${s.event} should have rejected ${s.invalidReason}`
);
failed++;
}
}
console.log('');
if (failed === 0) {
console.log(`✓ all ${scenarios.length * 2} sse-stream-shape checks hold`);
process.exit(0);
}
console.error(`${failed} failed, ${scenarios.length * 2 - failed} passed`);
process.exit(1);