213 lines
7.9 KiB
TypeScript
213 lines
7.9 KiB
TypeScript
/**
|
|
* Federation instances-stream — tsx smoke runner.
|
|
*
|
|
* Covers the pure helpers that decide what counts as an
|
|
* "interesting change" worth emitting an SSE event for.
|
|
* Misclassifying changes here either spams subscribers
|
|
* (irrelevant changes emitted) or hides them (real changes
|
|
* silently dropped).
|
|
*
|
|
* Usage (from apps/indexer):
|
|
* tsx scripts/instances-stream-smoke.ts
|
|
*/
|
|
|
|
import { rowToEntry, rowSignature, type DirectoryRow } from '../src/api/instancesStreamHelpers.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 makeRow(overrides: Partial<DirectoryRow> = {}): DirectoryRow {
|
|
return {
|
|
origin: 'https://test.example',
|
|
operator_account: 'alice',
|
|
operator_tag: 'alice',
|
|
operator_display_name: 'Alice in Berlin',
|
|
cached_name: 'test-instance',
|
|
cached_tagline: 'A test',
|
|
cached_contact_url: null,
|
|
cached_alt_networks: { tor: null, lokinet: null, i2p: null, nostr: null },
|
|
last_probe_status: 'good',
|
|
registered_at_time: new Date('2026-04-01T00:00:00Z'),
|
|
last_probed_at: new Date('2026-04-26T12:00:00Z'),
|
|
cached_indexed_block: 100_000,
|
|
cached_chain_lag_sec: 3,
|
|
consecutive_failures: 0,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
console.log('\n── Instances stream helpers ────────────────────────────');
|
|
|
|
// ─── rowToEntry shape ────────────────────────────────────────────
|
|
|
|
scenario('rowToEntry: produces the InstanceDirectoryEntry shape', () => {
|
|
const r = makeRow();
|
|
const e = rowToEntry(r);
|
|
assertEqual(e.origin, 'https://test.example', 'origin');
|
|
assertEqual(e.operator_account, 'alice', 'operator_account');
|
|
assertEqual(e.operator_tag, 'alice', 'operator_tag');
|
|
assertEqual(e.name, 'test-instance', 'name');
|
|
assertEqual(e.status, 'good', 'status');
|
|
assertEqual(e.indexed_block, 100_000, 'indexed_block');
|
|
assertEqual(e.chain_lag_sec, 3, 'chain_lag_sec');
|
|
assertEqual(e.registered_at, '2026-04-01T00:00:00.000Z', 'registered_at iso');
|
|
assertEqual(e.last_probed_at, '2026-04-26T12:00:00.000Z', 'last_probed_at iso');
|
|
});
|
|
|
|
scenario('rowToEntry: null cached fields → null in entry', () => {
|
|
const r = makeRow({
|
|
cached_name: null,
|
|
cached_tagline: null,
|
|
cached_contact_url: null,
|
|
cached_alt_networks: null,
|
|
cached_indexed_block: null,
|
|
cached_chain_lag_sec: null,
|
|
last_probed_at: null,
|
|
last_probe_status: null
|
|
});
|
|
const e = rowToEntry(r);
|
|
assertEqual(e.name, null, 'name');
|
|
assertEqual(e.tagline, null, 'tagline');
|
|
assertEqual(e.contact_url, null, 'contact_url');
|
|
assertEqual(e.alt_networks, null, 'alt_networks');
|
|
assertEqual(e.indexed_block, null, 'indexed_block');
|
|
assertEqual(e.chain_lag_sec, null, 'chain_lag_sec');
|
|
assertEqual(e.last_probed_at, null, 'last_probed_at');
|
|
assertEqual(e.status, 'never', 'status defaults to never');
|
|
});
|
|
|
|
scenario('rowToEntry: BIGINT-as-string indexed_block converts to number', () => {
|
|
// pg returns BIGINT as a string by default. rowToEntry must
|
|
// coerce so subscribers don't have to think about wire types.
|
|
const r = makeRow({ cached_indexed_block: '999999999999' });
|
|
const e = rowToEntry(r);
|
|
assertEqual(e.indexed_block, 999_999_999_999, 'indexed_block as number');
|
|
});
|
|
|
|
// ─── rowSignature semantics ──────────────────────────────────────
|
|
|
|
scenario('rowSignature: identical rows → identical signatures', () => {
|
|
const a = rowToEntry(makeRow());
|
|
const b = rowToEntry(makeRow());
|
|
assertEqual(rowSignature(a), rowSignature(b), 'sig equality');
|
|
});
|
|
|
|
scenario('rowSignature: different status → different signatures', () => {
|
|
const a = rowToEntry(makeRow({ last_probe_status: 'good' }));
|
|
const b = rowToEntry(makeRow({ last_probe_status: 'stale' }));
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures for different status');
|
|
}
|
|
});
|
|
|
|
scenario('rowSignature: different cached_name → different signatures', () => {
|
|
const a = rowToEntry(makeRow({ cached_name: 'old-name' }));
|
|
const b = rowToEntry(makeRow({ cached_name: 'new-name' }));
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures for different name');
|
|
}
|
|
});
|
|
|
|
scenario('rowSignature: different chain_lag_sec → different signatures', () => {
|
|
const a = rowToEntry(makeRow({ cached_chain_lag_sec: 3 }));
|
|
const b = rowToEntry(makeRow({ cached_chain_lag_sec: 60 }));
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures for different lag');
|
|
}
|
|
});
|
|
|
|
scenario(
|
|
'rowSignature: different last_probed_at → DIFFERENT signature (UI shows the timestamp)',
|
|
() => {
|
|
const a = rowToEntry(makeRow({ last_probed_at: new Date('2026-04-26T12:00:00Z') }));
|
|
const b = rowToEntry(makeRow({ last_probed_at: new Date('2026-04-26T12:10:00Z') }));
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures when last_probed_at changes');
|
|
}
|
|
}
|
|
);
|
|
|
|
scenario('rowSignature: different operator_display_name → DIFFERENT signature', () => {
|
|
const a = rowToEntry(makeRow({ operator_display_name: 'Alice in Berlin' }));
|
|
const b = rowToEntry(makeRow({ operator_display_name: 'Alice (renamed)' }));
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures when operator_display_name changes');
|
|
}
|
|
});
|
|
|
|
scenario('rowSignature: different operator_tag → DIFFERENT signature', () => {
|
|
const a = rowToEntry(makeRow({ operator_tag: 'alice' }));
|
|
const b = rowToEntry(makeRow({ operator_tag: 'alice-v2' }));
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures when operator_tag changes');
|
|
}
|
|
});
|
|
|
|
scenario('rowSignature: different consecutive_failures → SAME signature (internal metric)', () => {
|
|
const a = rowToEntry(makeRow({ consecutive_failures: 0 }));
|
|
const b = rowToEntry(makeRow({ consecutive_failures: 3 }));
|
|
assertEqual(rowSignature(a), rowSignature(b), 'sig should ignore failure counter');
|
|
});
|
|
|
|
scenario('rowSignature: different alt_networks → different signatures', () => {
|
|
const a = rowToEntry(
|
|
makeRow({
|
|
cached_alt_networks: { tor: null, lokinet: null, i2p: null, nostr: null }
|
|
})
|
|
);
|
|
const b = rowToEntry(
|
|
makeRow({
|
|
cached_alt_networks: { tor: 'abc.onion', lokinet: null, i2p: null, nostr: null }
|
|
})
|
|
);
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures when tor address added');
|
|
}
|
|
});
|
|
|
|
scenario('rowSignature: cached_indexed_block as string vs number → SAME signature', () => {
|
|
// rowToEntry coerces both to number; rowSignature gets a
|
|
// number either way.
|
|
const a = rowToEntry(makeRow({ cached_indexed_block: 100_000 }));
|
|
const b = rowToEntry(makeRow({ cached_indexed_block: '100000' }));
|
|
assertEqual(rowSignature(a), rowSignature(b), 'sig stable across wire-type');
|
|
});
|
|
|
|
scenario('P7-12: pipe-containing fields do not collide across boundaries', () => {
|
|
// Pre-fix the pipe-joined signature would collide because
|
|
// boundaries were ambiguous when content contained pipes.
|
|
const a = rowToEntry(makeRow({ cached_name: 'A | B', cached_tagline: 'C' }));
|
|
const b = rowToEntry(makeRow({ cached_name: 'A', cached_tagline: ' B|C' }));
|
|
if (rowSignature(a) === rowSignature(b)) {
|
|
throw new Error('expected different signatures');
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|