239 lines
9.1 KiB
TypeScript
239 lines
9.1 KiB
TypeScript
/**
|
|
* Chat-stream pure helpers — tsx smoke runner.
|
|
*
|
|
* Covers:
|
|
* - parseFilter (input validation + canonicalization)
|
|
* - eventMatchesFilter (subscriber filter check)
|
|
* - rowToWire (DB row → wire shape)
|
|
* - sseEvent (frame formatter)
|
|
*
|
|
* Usage (from apps/indexer):
|
|
* tsx scripts/chat-stream-smoke.ts
|
|
*/
|
|
|
|
import {
|
|
eventMatchesFilter,
|
|
parseFilter,
|
|
rowToWire,
|
|
sseEvent,
|
|
type ChatStreamRow
|
|
} from '../src/api/chatStreamHelpers.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 assertEqual(actual: unknown, expected: unknown, label: string): void {
|
|
const a = JSON.stringify(actual);
|
|
const e = JSON.stringify(expected);
|
|
if (a !== e) {
|
|
throw new Error(`${label}: expected ${e}, got ${a}`);
|
|
}
|
|
}
|
|
|
|
function assertTrue(cond: boolean, msg: string): void {
|
|
if (!cond) throw new Error(msg);
|
|
}
|
|
|
|
// ─── parseFilter ─────────────────────────────────────────────────
|
|
|
|
scenario('parseFilter: valid inputs canonicalize lo<hi', () => {
|
|
const r = parseFilter('alice', 'bob');
|
|
if ('error' in r) throw new Error('expected ok');
|
|
assertEqual(r.lo, 'alice', 'lo');
|
|
assertEqual(r.hi, 'bob', 'hi');
|
|
});
|
|
|
|
scenario('parseFilter: reverses to canonicalize', () => {
|
|
const r = parseFilter('zelda', 'alice');
|
|
if ('error' in r) throw new Error('expected ok');
|
|
assertEqual(r.lo, 'alice', 'lo');
|
|
assertEqual(r.hi, 'zelda', 'hi');
|
|
});
|
|
|
|
scenario('parseFilter: rejects self-chat', () => {
|
|
const r = parseFilter('alice', 'alice');
|
|
assertTrue('error' in r, 'should error');
|
|
});
|
|
|
|
scenario('parseFilter: accepts dotted account names (C-19 regression)', () => {
|
|
// Blurt account names CAN contain dots (multi-segment, e.g.
|
|
// `alice.brave`). Pre-C-19 fix the chat regex rejected dots,
|
|
// breaking chat for every dotted-name user. Canonical regex
|
|
// now allows dots. This test pins that.
|
|
const r = parseFilter('alice.brave', 'bob');
|
|
if ('error' in r) throw new Error('expected ok for dotted name');
|
|
assertEqual(r.lo, 'alice.brave', 'lo');
|
|
assertEqual(r.hi, 'bob', 'hi');
|
|
});
|
|
|
|
scenario('parseFilter: dotted names sort correctly', () => {
|
|
// Dot's ASCII code (46) is BETWEEN '-' (45) and '0' (48), so
|
|
// canonicalization comparing `alice.brave` vs `alice-brave`
|
|
// must order them by char codepoint — verify the behavior is
|
|
// consistent rather than relying on locale.
|
|
const r = parseFilter('alice.brave', 'alice-cool');
|
|
if ('error' in r) throw new Error('expected ok');
|
|
// '-' < '.' so 'alice-cool' < 'alice.brave'
|
|
assertEqual(r.lo, 'alice-cool', 'lo');
|
|
assertEqual(r.hi, 'alice.brave', 'hi');
|
|
});
|
|
|
|
scenario('parseFilter: rejects bad account name (uppercase)', () => {
|
|
const r = parseFilter('Alice', 'bob');
|
|
assertTrue('error' in r, 'should error on uppercase');
|
|
});
|
|
|
|
scenario('parseFilter: rejects bad account name (too short)', () => {
|
|
const r = parseFilter('a', 'bob');
|
|
assertTrue('error' in r, 'should error on 1-char name');
|
|
});
|
|
|
|
scenario('parseFilter: rejects bad account name (empty)', () => {
|
|
const r = parseFilter('', 'bob');
|
|
assertTrue('error' in r, 'should error on empty');
|
|
});
|
|
|
|
// ─── eventMatchesFilter ──────────────────────────────────────────
|
|
|
|
scenario('eventMatchesFilter: matching pair', () => {
|
|
const f = { lo: 'alice', hi: 'bob' };
|
|
const ev = { lo: 'alice', hi: 'bob' };
|
|
assertTrue(eventMatchesFilter(ev, f), 'should match');
|
|
});
|
|
|
|
scenario('eventMatchesFilter: different pair', () => {
|
|
const f = { lo: 'alice', hi: 'bob' };
|
|
const ev = { lo: 'alice', hi: 'carol' };
|
|
assertTrue(!eventMatchesFilter(ev, f), 'should not match');
|
|
});
|
|
|
|
scenario('eventMatchesFilter: same accounts swapped (canonical handles)', () => {
|
|
// Both filter and event should be canonicalized at construction
|
|
// time; the matcher is a pure equality check. This test
|
|
// verifies the precondition: pre-canonicalized inputs match.
|
|
const f = parseFilter('alice', 'bob');
|
|
if ('error' in f) throw new Error('expected ok filter');
|
|
const ev = { lo: 'alice', hi: 'bob' };
|
|
assertTrue(eventMatchesFilter(ev, f), 'should match canonicalized');
|
|
});
|
|
|
|
// ─── rowToWire ───────────────────────────────────────────────────
|
|
|
|
function makeRow(overrides: Partial<ChatStreamRow> = {}): ChatStreamRow {
|
|
return {
|
|
id: 42,
|
|
sender: 'alice',
|
|
recipient: 'bob',
|
|
ciphertext: 'opaque-base64-blob',
|
|
header: { client_tag: 'abc123', ephemeral_pub: 'xyz', nonce: '789' },
|
|
created_at: new Date('2026-04-27T12:00:00Z'),
|
|
// cp474 — both REQUIRED by ChatStreamRow and previously absent, so every
|
|
// makeRow() built a shape the SQL never returns and `rowToWire`'s copy of
|
|
// them was asserted by nothing.
|
|
source_trx_id: 'aabbccdd11223344556677889900aabbccddeeff',
|
|
order_permlink: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
scenario('rowToWire: created_at becomes ISO string', () => {
|
|
const w = rowToWire(makeRow());
|
|
assertEqual(w.created_at, '2026-04-27T12:00:00.000Z', 'iso');
|
|
});
|
|
|
|
// ─── cp474 drift guard ───────────────────────────────────────────
|
|
//
|
|
// cp470 fixed a HIGH bug: `rowToWire` simply wasn't copying
|
|
// `order_permlink`, so every SSE event (snapshot, fast-path provisional AND
|
|
// durable bus push) went out with an implicit `null` tag. The client threads
|
|
// by (peer, order) and drops live messages whose tag doesn't match the open
|
|
// thread, so messages in an ORDER thread vanished until the REST fallback poll
|
|
// re-fetched them a main-indexer lag later — the ~60s "fast chat is broken"
|
|
// symptom. That fix shipped with NO smoke guarding it: the fixture omitted the
|
|
// field entirely, so nothing could have caught its removal.
|
|
//
|
|
// A hand-written per-field assertion would only guard the fields someone
|
|
// remembered. This walks the row instead: because `makeRow` is typed
|
|
// `ChatStreamRow` and the suite is now typechecked, any field ADDED to
|
|
// ChatStreamRow must appear in the fixture, and this scenario then forces
|
|
// `rowToWire` to carry it to the wire. Dropping a field can't pass again.
|
|
scenario('rowToWire: every ChatStreamRow field reaches the wire (drift guard)', () => {
|
|
const row = makeRow({ order_permlink: 'sell-btc-eur-2026-04' });
|
|
const w = rowToWire(row) as Record<string, unknown>;
|
|
const dropped = Object.keys(row).filter((k) => !(k in w));
|
|
if (dropped.length > 0) {
|
|
throw new Error(
|
|
`rowToWire dropped field(s) the client needs: ${dropped.join(', ')} — ` +
|
|
'see cp470 (order_permlink omission = ~60s fast-chat outage)'
|
|
);
|
|
}
|
|
// Value fidelity for the two the cp470 bug actually hit. `created_at` is
|
|
// deliberately transformed to an ISO string and is covered above.
|
|
assertEqual(w.order_permlink, 'sell-btc-eur-2026-04', 'order_permlink tag copied');
|
|
assertEqual(w.source_trx_id, row.source_trx_id, 'source_trx_id copied');
|
|
});
|
|
|
|
scenario('rowToWire: a general (order-less) thread keeps a null tag', () => {
|
|
// The null case is the one that masked the cp470 bug — general threads were
|
|
// unaffected because their tag is genuinely null, so the bug only showed up
|
|
// in order threads. Pin both.
|
|
const w = rowToWire(makeRow({ order_permlink: null }));
|
|
assertEqual(w.order_permlink, null, 'null tag preserved, not undefined');
|
|
});
|
|
|
|
scenario('rowToWire: header passes through unchanged', () => {
|
|
const header = { client_tag: 'tag', ephemeral_pub: 'pub', nonce: 'n' };
|
|
const w = rowToWire(makeRow({ header }));
|
|
assertEqual(w.header, header, 'header preserved');
|
|
});
|
|
|
|
scenario('rowToWire: id passes through as number', () => {
|
|
const w = rowToWire(makeRow({ id: 12345 }));
|
|
assertEqual(w.id, 12345, 'id');
|
|
});
|
|
|
|
scenario('rowToWire: ciphertext untouched', () => {
|
|
const w = rowToWire(makeRow({ ciphertext: 'X==Y==' }));
|
|
assertEqual(w.ciphertext, 'X==Y==', 'ciphertext');
|
|
});
|
|
|
|
scenario('rowToWire: sender + recipient untouched', () => {
|
|
const w = rowToWire(makeRow({ sender: 'carol', recipient: 'dave' }));
|
|
assertEqual(w.sender, 'carol', 'sender');
|
|
assertEqual(w.recipient, 'dave', 'recipient');
|
|
});
|
|
|
|
// ─── sseEvent ────────────────────────────────────────────────────
|
|
|
|
scenario('sseEvent: produces correct frame format', () => {
|
|
const frame = sseEvent('snapshot', { items: [] });
|
|
assertEqual(frame, 'event: snapshot\ndata: {"items":[]}\n\n', 'frame');
|
|
});
|
|
|
|
scenario('sseEvent: handles complex data', () => {
|
|
const data = { id: 42, sender: 'alice', nested: { a: 1 } };
|
|
const frame = sseEvent('message_appended', data);
|
|
const expected = `event: message_appended\ndata: ${JSON.stringify(data)}\n\n`;
|
|
assertEqual(frame, expected, 'complex frame');
|
|
});
|
|
|
|
console.log(`\n${'─'.repeat(54)}`);
|
|
if (failures === 0) {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
}
|