Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.
Workspace layout (pnpm + TypeScript):
contracts/marketplace/ Aiken validators (advert + escrow) + plutus.json
packages/shared/ Tx-builders (lucid-evolution), CBOR codecs,
chain providers (mock / read-only / live Ogmios)
supplier/ OpenAI-compatible HTTP node (Ollama by default;
vLLM / llama.cpp / OpenRouter / OpenClaw all
swap-in via OLLAMA_URL); 202+poll chat handler
buyer/ Express + React SPA; server-side runs the full
chain lifecycle via /v1/submit-prompt and
/v1/accept; SPA never holds the buyer key
indexer/ Chain-follower (extends apex-dashboard pattern),
SSE event stream + REST queries
indexer-ui/ Operator dashboard (events + suppliers + escrow
lookup)
tests/ 1098 vitest unit + integration tests (10 skipped
under M1-F-D-defer for UPLC test plumbing)
deploy/testnet/ Per-service docker-compose for the Hetzner stack
docs/EXECUTIVE_SUMMARY.* Marp deck (PDF + PPTX) — 11 slides, mermaid
diagrams pre-rendered for portable export
docs/CLOUDFLARE_WAF_BYPASS.md CF Skip-rule runbook for /v1/* paths
docs/ARCHITECTURE.md canonical spec
Live demo (testnet, 2026-05-06):
https://mp-buyers.vector.testnet.apexfusion.org (buyer SPA)
https://mp-suppliers.vector.testnet.apexfusion.org (supplier API)
https://mp-indexer.vector.testnet.apexfusion.org (indexer + UI)
563 lines
22 KiB
TypeScript
563 lines
22 KiB
TypeScript
/**
|
|
* tests/unit/indexer-sse.test.ts — long-lived multiplexed SSE stream tests (M1-D-fix)
|
|
*
|
|
* Tests GET /events?stream=1 using a real ephemeral HTTP server + eventsource client.
|
|
* DO NOT use supertest for these tests — supertest aggressively buffers responses
|
|
* and breaks SSE (the stream never delivers events to the test listener).
|
|
*
|
|
* DELETED (from old degenerate one-shot contract):
|
|
* - "GET /events?stream=1 returns Content-Type: text/event-stream" (supertest version)
|
|
* - "GET /events?stream=1 returns Cache-Control: no-cache" (supertest version)
|
|
* - "SSE event wire format is 'event: <type>\\ndata: <json>\\n\\n'" (format-only stub)
|
|
* - "SSE payload includes required fields: type, ref, slot" (shape-only stub)
|
|
* - "chain-event emitted by worker is written to connected SSE client" (listener-count only)
|
|
* - "createApp wires at least one listener on worker 'chain-event' event" (listener-count only)
|
|
* - "cache.listEventsAfterSlot is called with the correct slot value (500)" (supertest version)
|
|
* - "GET /events?stream=1&since_slot=0 triggers replay from slot 0" (supertest version)
|
|
* - "createApp wires worker 'chain-event' listener — no memory leak after mount"
|
|
* - "after disconnect, emitting chain-event does not throw"
|
|
* Reason: all of the above encoded a degenerate one-shot SSE contract (res.end() immediately).
|
|
* The new tests require a real long-lived stream.
|
|
*
|
|
* New contract (per M1-D-fix spec):
|
|
* - Connection stays open until client disconnects
|
|
* - Initial `: connected` comment heartbeat on connect
|
|
* - Live chain-events pushed to all open clients as `event: <type>\ndata: <json>\n\n`
|
|
* - sync-progress pushed as `event: sync-progress\ndata: <json>\n\n`
|
|
* - Two concurrent clients both receive an event
|
|
* - Disconnect one client; remaining client still receives events; no error thrown
|
|
* - Heartbeat comment `: keepalive\n\n` sent every heartbeatMs (configurable, default 25s)
|
|
* - ?since_slot=N replay sends buffered events with slot > N before live events
|
|
* - Response headers: Content-Type text/event-stream, Cache-Control no-cache, X-Accel-Buffering no
|
|
*/
|
|
|
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
import { EventEmitter } from "events";
|
|
import http from "http";
|
|
import EventSource from "eventsource";
|
|
|
|
import { createApp } from "../../indexer/src/server.js";
|
|
import type { IndexerDeps } from "../../indexer/src/server.js";
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Parsed SSE line: either a comment, a field:value pair, or a blank dispatch boundary. */
|
|
interface ParsedSSELine {
|
|
type: "comment" | "field" | "blank";
|
|
raw: string;
|
|
field?: string;
|
|
value?: string;
|
|
}
|
|
|
|
/**
|
|
* Minimal SSE stream parser over a raw text chunk.
|
|
* Returns each line parsed.
|
|
*/
|
|
function parseSSELines(chunk: string): ParsedSSELine[] {
|
|
return chunk.split("\n").map((raw) => {
|
|
if (raw.startsWith(":")) return { type: "comment", raw };
|
|
if (raw === "") return { type: "blank", raw };
|
|
const colon = raw.indexOf(":");
|
|
if (colon === -1) return { type: "field", raw, field: raw, value: "" };
|
|
return { type: "field", raw, field: raw.slice(0, colon), value: raw.slice(colon + 1).trimStart() };
|
|
});
|
|
}
|
|
|
|
/** Build a mock IndexerDeps with a configurable heartbeatMs. */
|
|
function makeDeps(opts: {
|
|
heartbeatMs?: number;
|
|
eventsAfterSlot?: Array<{ id: number; type: string; slot: number; tx_hash: string; utxo_ref: string; datum_hex: string; metadata_json: string; rolled_back: number }>;
|
|
} = {}): { deps: IndexerDeps; workerEmitter: EventEmitter } {
|
|
const workerEmitter = new EventEmitter();
|
|
const eventsAfterSlot = opts.eventsAfterSlot ?? [];
|
|
|
|
const cache = {
|
|
listActiveAdvertisements: vi.fn(() => []),
|
|
getAdvertisementByRef: vi.fn(() => null),
|
|
getEscrowByRef: vi.fn(() => null),
|
|
listEscrowsByBuyer: vi.fn(() => []),
|
|
listEscrowsBySupplier: vi.fn(() => []),
|
|
getSupplierStatus: vi.fn(() => null),
|
|
listEventsAfterSlot: vi.fn(() => eventsAfterSlot),
|
|
dbSizeBytes: vi.fn(() => 4096),
|
|
} as unknown as IndexerDeps["cache"];
|
|
|
|
const worker = {
|
|
getCurrentSlot: vi.fn(() => 1_000_000),
|
|
getTipSlot: vi.fn(() => 1_001_000),
|
|
on: workerEmitter.on.bind(workerEmitter),
|
|
emit: workerEmitter.emit.bind(workerEmitter),
|
|
off: workerEmitter.off.bind(workerEmitter),
|
|
} as unknown as IndexerDeps["worker"];
|
|
|
|
return { deps: { cache, worker }, workerEmitter };
|
|
}
|
|
|
|
/**
|
|
* Start a real ephemeral HTTP server on port 0.
|
|
* Returns { server, port, close }.
|
|
* The server must be closed after each test.
|
|
*/
|
|
async function startServer(deps: IndexerDeps, heartbeatMs?: number): Promise<{
|
|
server: http.Server;
|
|
port: number;
|
|
close: () => Promise<void>;
|
|
}> {
|
|
const app = createApp(deps, { heartbeatMs: heartbeatMs ?? 25_000 });
|
|
const server = http.createServer(app);
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const addr = server.address() as { port: number };
|
|
const close = () =>
|
|
new Promise<void>((resolve, reject) =>
|
|
server.close((err) => (err ? reject(err) : resolve())),
|
|
);
|
|
return { server, port: addr.port, close };
|
|
}
|
|
|
|
/**
|
|
* Open an EventSource to the given URL and collect messages until `count`
|
|
* messages have been received or `timeoutMs` elapses.
|
|
* Returns { messages, es } — caller must close the EventSource.
|
|
*
|
|
* onerror is treated as a soft end (server closed the connection before we
|
|
* got `count` messages). Tests assert the message count; if the server closes
|
|
* prematurely the assertion will fail with a meaningful message rather than
|
|
* throwing an unhandled rejection.
|
|
*/
|
|
function collectMessages(
|
|
url: string,
|
|
count: number,
|
|
timeoutMs = 2000,
|
|
): Promise<{ messages: MessageEvent[]; es: EventSource }> {
|
|
return new Promise((resolve) => {
|
|
const messages: MessageEvent[] = [];
|
|
const es = new EventSource(url) as EventSource & { close(): void };
|
|
|
|
let finished = false;
|
|
const finish = () => {
|
|
if (!finished) {
|
|
finished = true;
|
|
resolve({ messages, es });
|
|
}
|
|
};
|
|
const timer = setTimeout(finish, timeoutMs);
|
|
|
|
const onmessage = (ev: MessageEvent) => {
|
|
messages.push(ev);
|
|
if (messages.length >= count) {
|
|
clearTimeout(timer);
|
|
finish();
|
|
}
|
|
};
|
|
|
|
es.onmessage = onmessage;
|
|
// Also listen for named events (our events have `event:` fields)
|
|
[
|
|
"PostAdvert", "UpdateAdvert", "RetireAdvert",
|
|
"PostEscrow", "ClaimEscrow", "SubmitEscrow",
|
|
"AcceptEscrow", "ReclaimEscrow", "ReleaseEscrow",
|
|
"sync-progress",
|
|
].forEach((type) => {
|
|
(es as unknown as EventTarget).addEventListener(type, onmessage as EventListener);
|
|
});
|
|
|
|
// Treat onerror as a premature close — resolve with whatever was collected.
|
|
// The calling test will fail on the message count assertion if the server
|
|
// closed before delivering all expected events (RED phase behaviour).
|
|
es.onerror = () => {
|
|
clearTimeout(timer);
|
|
finish();
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collect raw bytes from an HTTP response until timeoutMs elapses.
|
|
* Used for header verification and raw stream parsing.
|
|
*/
|
|
function collectRaw(url: string, timeoutMs: number): Promise<{ statusCode?: number; headers: Record<string, string | string[] | undefined>; body: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.get(url, (res) => {
|
|
let body = "";
|
|
const timer = setTimeout(() => {
|
|
req.destroy();
|
|
resolve({ statusCode: res.statusCode, headers: res.headers as Record<string, string | string[] | undefined>, body });
|
|
}, timeoutMs);
|
|
res.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
res.on("end", () => { clearTimeout(timer); resolve({ statusCode: res.statusCode, headers: res.headers as Record<string, string | string[] | undefined>, body }); });
|
|
res.on("error", (err) => { clearTimeout(timer); reject(err); });
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
// ─── Test cleanup ────────────────────────────────────────────────────────────
|
|
|
|
const serversToClose: Array<() => Promise<void>> = [];
|
|
const esourcesToClose: EventSource[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const es of esourcesToClose.splice(0)) {
|
|
try { (es as unknown as { close(): void }).close(); } catch { /* ignore */ }
|
|
}
|
|
for (const close of serversToClose.splice(0)) {
|
|
await close().catch(() => { /* ignore */ });
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ─── Response headers ────────────────────────────────────────────────────────
|
|
|
|
describe("GET /events?stream=1 — response headers", () => {
|
|
it("Content-Type is text/event-stream", async () => {
|
|
const { deps } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const { headers } = await collectRaw(`http://127.0.0.1:${port}/events?stream=1`, 300);
|
|
expect(headers["content-type"]).toMatch(/text\/event-stream/);
|
|
});
|
|
|
|
it("Cache-Control is no-cache", async () => {
|
|
const { deps } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const { headers } = await collectRaw(`http://127.0.0.1:${port}/events?stream=1`, 300);
|
|
expect(headers["cache-control"]).toBe("no-cache");
|
|
});
|
|
|
|
it("X-Accel-Buffering is no", async () => {
|
|
const { deps } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const { headers } = await collectRaw(`http://127.0.0.1:${port}/events?stream=1`, 300);
|
|
expect(headers["x-accel-buffering"]).toBe("no");
|
|
});
|
|
});
|
|
|
|
// ─── Initial heartbeat comment ───────────────────────────────────────────────
|
|
|
|
describe("GET /events?stream=1 — initial heartbeat comment", () => {
|
|
it("server sends ': connected' comment immediately on connect", async () => {
|
|
const { deps } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const { body } = await collectRaw(`http://127.0.0.1:${port}/events?stream=1`, 300);
|
|
expect(body).toContain(": connected");
|
|
});
|
|
});
|
|
|
|
// ─── Single client receives live chain-events ─────────────────────────────────
|
|
|
|
describe("GET /events?stream=1 — single client receives live chain-events", () => {
|
|
it("worker chain-event is forwarded to connected client as named SSE event", async () => {
|
|
const { deps, workerEmitter } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const url = `http://127.0.0.1:${port}/events?stream=1`;
|
|
const collectPromise = collectMessages(url, 1, 1500);
|
|
|
|
// Give the EventSource time to connect before emitting
|
|
await new Promise<void>((r) => setTimeout(r, 100));
|
|
|
|
workerEmitter.emit("chain-event", {
|
|
type: "PostAdvert",
|
|
ref: "a".repeat(64) + "#0",
|
|
slot: 1_000_000,
|
|
});
|
|
|
|
const { messages, es } = await collectPromise;
|
|
esourcesToClose.push(es);
|
|
|
|
expect(messages.length).toBeGreaterThanOrEqual(1);
|
|
const parsed = JSON.parse(messages[0].data);
|
|
expect(parsed.type).toBe("PostAdvert");
|
|
expect(parsed.slot).toBe(1_000_000);
|
|
});
|
|
|
|
it("multiple chain-events are all received by the client in order", async () => {
|
|
const { deps, workerEmitter } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const url = `http://127.0.0.1:${port}/events?stream=1`;
|
|
const collectPromise = collectMessages(url, 3, 1500);
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 100));
|
|
|
|
const events = [
|
|
{ type: "PostEscrow", ref: "a".repeat(64) + "#0", slot: 1_001_000 },
|
|
{ type: "ClaimEscrow", ref: "b".repeat(64) + "#0", slot: 1_001_100 },
|
|
{ type: "AcceptEscrow", ref: "c".repeat(64) + "#0", slot: 1_001_200 },
|
|
];
|
|
for (const ev of events) {
|
|
workerEmitter.emit("chain-event", ev);
|
|
}
|
|
|
|
const { messages, es } = await collectPromise;
|
|
esourcesToClose.push(es);
|
|
|
|
expect(messages.length).toBe(3);
|
|
expect(JSON.parse(messages[0].data).type).toBe("PostEscrow");
|
|
expect(JSON.parse(messages[1].data).type).toBe("ClaimEscrow");
|
|
expect(JSON.parse(messages[2].data).type).toBe("AcceptEscrow");
|
|
});
|
|
});
|
|
|
|
// ─── Fan-out: two concurrent clients ─────────────────────────────────────────
|
|
|
|
describe("GET /events?stream=1 — fan-out to multiple clients", () => {
|
|
it("two concurrent clients both receive a single chain-event", async () => {
|
|
const { deps, workerEmitter } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const url = `http://127.0.0.1:${port}/events?stream=1`;
|
|
const p1 = collectMessages(url, 1, 1500);
|
|
const p2 = collectMessages(url, 1, 1500);
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 150));
|
|
|
|
workerEmitter.emit("chain-event", {
|
|
type: "SubmitEscrow",
|
|
ref: "d".repeat(64) + "#0",
|
|
slot: 1_002_000,
|
|
});
|
|
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
esourcesToClose.push(r1.es, r2.es);
|
|
|
|
expect(r1.messages.length).toBeGreaterThanOrEqual(1);
|
|
expect(r2.messages.length).toBeGreaterThanOrEqual(1);
|
|
expect(JSON.parse(r1.messages[0].data).type).toBe("SubmitEscrow");
|
|
expect(JSON.parse(r2.messages[0].data).type).toBe("SubmitEscrow");
|
|
});
|
|
});
|
|
|
|
// ─── Client disconnect: no error on subsequent emit ───────────────────────────
|
|
|
|
describe("GET /events?stream=1 — client disconnect cleanup", () => {
|
|
it("after one client disconnects, remaining client still receives events without error", async () => {
|
|
const { deps, workerEmitter } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const url = `http://127.0.0.1:${port}/events?stream=1`;
|
|
|
|
// Open two clients
|
|
const p1 = collectMessages(url, 1, 1500);
|
|
const p2 = collectMessages(url, 2, 1500);
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 150));
|
|
|
|
// First event — both clients receive it
|
|
workerEmitter.emit("chain-event", {
|
|
type: "PostAdvert",
|
|
ref: "e".repeat(64) + "#0",
|
|
slot: 1_003_000,
|
|
});
|
|
|
|
const { messages: m1, es: es1 } = await p1;
|
|
expect(m1.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Disconnect client 1
|
|
(es1 as unknown as { close(): void }).close();
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 100));
|
|
|
|
// Second event — only client 2 should receive it (no throw for client 1)
|
|
workerEmitter.emit("chain-event", {
|
|
type: "ClaimEscrow",
|
|
ref: "f".repeat(64) + "#0",
|
|
slot: 1_003_100,
|
|
});
|
|
|
|
const { messages: m2, es: es2 } = await p2;
|
|
esourcesToClose.push(es2);
|
|
|
|
// client 2 must have received both events
|
|
expect(m2.length).toBe(2);
|
|
expect(JSON.parse(m2[1].data).type).toBe("ClaimEscrow");
|
|
});
|
|
|
|
it("emitting chain-event with zero clients connected does not throw", async () => {
|
|
const { deps, workerEmitter } = makeDeps();
|
|
const { close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
// No clients connected
|
|
expect(() => {
|
|
workerEmitter.emit("chain-event", {
|
|
type: "PostAdvert",
|
|
ref: "g".repeat(64) + "#0",
|
|
slot: 1_004_000,
|
|
});
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ─── Heartbeat keepalive ──────────────────────────────────────────────────────
|
|
|
|
describe("GET /events?stream=1 — heartbeat keepalive", () => {
|
|
it("server sends at least 2 ': keepalive' comment lines within 120ms with 50ms heartbeatMs", async () => {
|
|
const { deps } = makeDeps();
|
|
// Use a 50ms heartbeat so we can test within a short window
|
|
const { port, close } = await startServer(deps, 50);
|
|
serversToClose.push(close);
|
|
|
|
const { body } = await collectRaw(`http://127.0.0.1:${port}/events?stream=1`, 130);
|
|
const keepaliveCount = (body.match(/: keepalive/g) ?? []).length;
|
|
expect(keepaliveCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
// ─── ?since_slot=N replay ────────────────────────────────────────────────────
|
|
|
|
describe("GET /events?since_slot=N — replay before live events", () => {
|
|
it("events with slot > since_slot are replayed before any live events arrive", async () => {
|
|
const storedEvents = [
|
|
{ id: 1, type: "PostAdvert", slot: 3, tx_hash: "a".repeat(64), utxo_ref: "a".repeat(64) + "#0", datum_hex: "aa", metadata_json: "{}", rolled_back: 0 },
|
|
{ id: 2, type: "PostEscrow", slot: 5, tx_hash: "b".repeat(64), utxo_ref: "b".repeat(64) + "#0", datum_hex: "bb", metadata_json: "{}", rolled_back: 0 },
|
|
{ id: 3, type: "ClaimEscrow", slot: 7, tx_hash: "c".repeat(64), utxo_ref: "c".repeat(64) + "#0", datum_hex: "cc", metadata_json: "{}", rolled_back: 0 },
|
|
];
|
|
const { deps } = makeDeps({ eventsAfterSlot: storedEvents });
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
// since_slot=2 → should replay all 3 events (slots 3, 5, 7 are all > 2)
|
|
const collectPromise = collectMessages(
|
|
`http://127.0.0.1:${port}/events?stream=1&since_slot=2`,
|
|
3,
|
|
1500,
|
|
);
|
|
|
|
const { messages, es } = await collectPromise;
|
|
esourcesToClose.push(es);
|
|
|
|
expect(messages.length).toBe(3);
|
|
expect(JSON.parse(messages[0].data).type).toBe("PostAdvert");
|
|
expect(JSON.parse(messages[1].data).type).toBe("PostEscrow");
|
|
expect(JSON.parse(messages[2].data).type).toBe("ClaimEscrow");
|
|
});
|
|
|
|
it("cache.listEventsAfterSlot is called with the correct slot value", async () => {
|
|
const { deps } = makeDeps();
|
|
const spy = vi.spyOn(deps.cache, "listEventsAfterSlot").mockReturnValue([]);
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const { body } = await collectRaw(`http://127.0.0.1:${port}/events?stream=1&since_slot=500`, 300);
|
|
void body; // we only care that the spy was called
|
|
|
|
expect(spy).toHaveBeenCalledWith(500);
|
|
});
|
|
|
|
it("since_slot=0 triggers replay from slot 0", async () => {
|
|
const { deps } = makeDeps();
|
|
const spy = vi.spyOn(deps.cache, "listEventsAfterSlot").mockReturnValue([]);
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
await collectRaw(`http://127.0.0.1:${port}/events?stream=1&since_slot=0`, 300);
|
|
expect(spy).toHaveBeenCalledWith(0);
|
|
});
|
|
|
|
it("replayed events arrive BEFORE a subsequent live chain-event", async () => {
|
|
const stored = [
|
|
{ id: 1, type: "PostAdvert", slot: 10, tx_hash: "a".repeat(64), utxo_ref: "a".repeat(64) + "#0", datum_hex: "aa", metadata_json: "{}", rolled_back: 0 },
|
|
];
|
|
const { deps, workerEmitter } = makeDeps({ eventsAfterSlot: stored });
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const collectPromise = collectMessages(
|
|
`http://127.0.0.1:${port}/events?stream=1&since_slot=5`,
|
|
2,
|
|
1500,
|
|
);
|
|
|
|
// Wait for connection + replay to complete, then emit live
|
|
await new Promise<void>((r) => setTimeout(r, 200));
|
|
workerEmitter.emit("chain-event", {
|
|
type: "ClaimEscrow",
|
|
ref: "b".repeat(64) + "#0",
|
|
slot: 100,
|
|
});
|
|
|
|
const { messages, es } = await collectPromise;
|
|
esourcesToClose.push(es);
|
|
|
|
expect(messages.length).toBe(2);
|
|
// Replayed event must come first
|
|
expect(JSON.parse(messages[0].data).type).toBe("PostAdvert");
|
|
expect(JSON.parse(messages[1].data).type).toBe("ClaimEscrow");
|
|
});
|
|
});
|
|
|
|
// ─── sync-progress event ─────────────────────────────────────────────────────
|
|
|
|
describe("GET /events?stream=1 — sync-progress forwarding", () => {
|
|
it("worker sync-progress event is forwarded as event: sync-progress", async () => {
|
|
const { deps, workerEmitter } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
const url = `http://127.0.0.1:${port}/events?stream=1`;
|
|
const collectPromise = collectMessages(url, 1, 1500);
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 100));
|
|
|
|
workerEmitter.emit("sync-progress", {
|
|
slot: 1_000_500,
|
|
tipSlot: 1_001_000,
|
|
percentage: 99.5,
|
|
});
|
|
|
|
const { messages, es } = await collectPromise;
|
|
esourcesToClose.push(es);
|
|
|
|
expect(messages.length).toBeGreaterThanOrEqual(1);
|
|
const parsed = JSON.parse(messages[0].data);
|
|
expect(parsed.slot).toBe(1_000_500);
|
|
});
|
|
});
|
|
|
|
// ─── SSE wire format contract ─────────────────────────────────────────────────
|
|
|
|
describe("GET /events?stream=1 — SSE wire format", () => {
|
|
it("each event arrives as 'event: <type>\\ndata: <json>\\n\\n' in the raw stream", async () => {
|
|
const { deps, workerEmitter } = makeDeps();
|
|
const { port, close } = await startServer(deps);
|
|
serversToClose.push(close);
|
|
|
|
let rawBody = "";
|
|
const rawDone = new Promise<void>((resolve) => {
|
|
const req = http.get(`http://127.0.0.1:${port}/events?stream=1`, (res) => {
|
|
res.on("data", (chunk: Buffer) => {
|
|
rawBody += chunk.toString();
|
|
if (rawBody.includes("data: ")) resolve();
|
|
});
|
|
});
|
|
req.on("error", () => resolve());
|
|
setTimeout(() => { req.destroy(); resolve(); }, 1000);
|
|
});
|
|
|
|
await new Promise<void>((r) => setTimeout(r, 100));
|
|
workerEmitter.emit("chain-event", {
|
|
type: "PostAdvert",
|
|
ref: "a".repeat(64) + "#0",
|
|
slot: 999,
|
|
});
|
|
|
|
await rawDone;
|
|
|
|
// Must contain the SSE event lines
|
|
expect(rawBody).toMatch(/event: PostAdvert/);
|
|
expect(rawBody).toMatch(/data: \{.*"type".*"PostAdvert".*\}/);
|
|
});
|
|
});
|