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)
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
/**
|
|
* tests/unit/indexer-status-poller.test.ts — StatusPoller tests (Category C)
|
|
*
|
|
* Mocks fetch via vi.stubGlobal. Tests use tickOnce() to control poll timing.
|
|
* All tests RED — StatusPoller constructor throws "not implemented — M1-D-green".
|
|
* Tests construct StatusPoller directly; the stub throws, causing Vitest to mark them FAIL.
|
|
* When Catherine implements StatusPoller, these tests verify actual polling behaviour.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
|
|
import { StatusPoller } from "../../indexer/src/poller/statusPoller.js";
|
|
import { SqliteCache } from "../../indexer/src/db/cache.js";
|
|
import {
|
|
STATUS_FREE,
|
|
STATUS_WORKING,
|
|
STATUS_WRONG_SHAPE,
|
|
} from "../fixtures/indexer-side/sample-suppliers.js";
|
|
import {
|
|
buildAdvertDatumHex,
|
|
INDEXER_SUPPLIER_PKH,
|
|
INDEXER_SUPPLIER_ENDPOINT,
|
|
} from "../fixtures/indexer-side/sample-blocks.js";
|
|
import type { AdvertRow } from "../../indexer/src/db/cache.js";
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function sampleActiveAdvert(overrides: Partial<AdvertRow> = {}): Omit<AdvertRow, "id"> {
|
|
return {
|
|
utxo_ref: "a".repeat(64) + "#0",
|
|
supplier_pkh: INDEXER_SUPPLIER_PKH,
|
|
capability_id: "llm.text.generate.v1",
|
|
model: "qwen2.5:0.5b",
|
|
max_output_tokens: 512,
|
|
max_processing_ms: 60_000,
|
|
price_lovelace: "2000000",
|
|
supplier_bond_lovelace: "1000000",
|
|
buyer_bond_lovelace: "1000000",
|
|
endpoint_url: INDEXER_SUPPLIER_ENDPOINT,
|
|
detail_uri: "ipfs://QmTest",
|
|
detail_hash: "b".repeat(64),
|
|
advertised_at: 1_750_000_000_000,
|
|
status: "Active",
|
|
created_slot: 1_000_000,
|
|
datum_hex: buildAdvertDatumHex(),
|
|
rolled_back: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeFetchMock(body: unknown, ok = true, status = 200) {
|
|
return vi.fn().mockResolvedValue({
|
|
ok,
|
|
status,
|
|
json: async () => body,
|
|
});
|
|
}
|
|
|
|
// ─── Basic construction — RED ─────────────────────────────────────────────────
|
|
// These tests construct StatusPoller directly without wrapping.
|
|
// The stub throws, failing the test (RED).
|
|
|
|
describe("StatusPoller — construction", () => {
|
|
it("StatusPoller can be constructed with a mock cache and pollIntervalMs=0", () => {
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => []),
|
|
upsertSupplierStatus: vi.fn(),
|
|
getSupplierStatus: vi.fn(() => null),
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
expect(poller).toBeDefined();
|
|
});
|
|
|
|
it("StatusPoller exposes a tickOnce() method for testing", () => {
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => []),
|
|
upsertSupplierStatus: vi.fn(),
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
expect(typeof poller.tickOnce).toBe("function");
|
|
});
|
|
});
|
|
|
|
// ─── Basic polling — RED ──────────────────────────────────────────────────────
|
|
|
|
describe("StatusPoller — basic polling", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("tickOnce polls each Active supplier endpoint_url/status via GET", async () => {
|
|
const fetchMock = makeFetchMock(STATUS_FREE);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: vi.fn(),
|
|
getSupplierStatus: vi.fn(() => null),
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
expect.stringContaining(INDEXER_SUPPLIER_ENDPOINT + "/status"),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("tickOnce writes supplier_status row with status=free after successful free poll", async () => {
|
|
vi.stubGlobal("fetch", makeFetchMock(STATUS_FREE));
|
|
const upsertSpy = vi.fn();
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: upsertSpy,
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(upsertSpy).toHaveBeenCalledOnce();
|
|
const call = upsertSpy.mock.calls[0][0];
|
|
expect(call.status).toBe("free");
|
|
expect(typeof call.last_seen_iso).toBe("string");
|
|
});
|
|
|
|
it("tickOnce writes working status with current_escrow_ref when supplier is working", async () => {
|
|
vi.stubGlobal("fetch", makeFetchMock(STATUS_WORKING));
|
|
const upsertSpy = vi.fn();
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: upsertSpy,
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(upsertSpy).toHaveBeenCalledOnce();
|
|
const call = upsertSpy.mock.calls[0][0];
|
|
expect(call.status).toBe("working");
|
|
expect(typeof call.current_escrow_ref).toBe("string");
|
|
});
|
|
|
|
it("fetch is called with HTTP GET method (or default GET — no POST)", async () => {
|
|
const fetchMock = makeFetchMock(STATUS_FREE);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: vi.fn(),
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
const callArgs = fetchMock.mock.calls[0];
|
|
// Either no method (default GET) or explicit GET
|
|
if (callArgs[1]?.method !== undefined) {
|
|
expect(callArgs[1].method.toUpperCase()).toBe("GET");
|
|
}
|
|
// If no options passed, it defaults to GET — that's fine
|
|
});
|
|
});
|
|
|
|
// ─── Error handling — RED ─────────────────────────────────────────────────────
|
|
|
|
describe("StatusPoller — error handling", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("supplier 503 response → upsertSupplierStatus called with status=offline", async () => {
|
|
vi.stubGlobal("fetch", makeFetchMock({}, false, 503));
|
|
const upsertSpy = vi.fn();
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: upsertSpy,
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(upsertSpy).toHaveBeenCalledOnce();
|
|
expect(upsertSpy.mock.calls[0][0].status).toBe("offline");
|
|
});
|
|
|
|
it("network error (fetch throws ECONNREFUSED) → status row marked offline", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
|
const upsertSpy = vi.fn();
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: upsertSpy,
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(upsertSpy).toHaveBeenCalledOnce();
|
|
expect(upsertSpy.mock.calls[0][0].status).toBe("offline");
|
|
});
|
|
|
|
it("malformed JSON body (json() throws) → status row marked offline", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => { throw new SyntaxError("Unexpected token"); },
|
|
}));
|
|
const upsertSpy = vi.fn();
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: upsertSpy,
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(upsertSpy).toHaveBeenCalledOnce();
|
|
expect(upsertSpy.mock.calls[0][0].status).toBe("offline");
|
|
});
|
|
|
|
it("wrong-shape body (missing status field) → status row marked offline", async () => {
|
|
vi.stubGlobal("fetch", makeFetchMock(STATUS_WRONG_SHAPE));
|
|
const upsertSpy = vi.fn();
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: upsertSpy,
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(upsertSpy).toHaveBeenCalledOnce();
|
|
expect(upsertSpy.mock.calls[0][0].status).toBe("offline");
|
|
});
|
|
});
|
|
|
|
// ─── Retired suppliers not polled — RED ───────────────────────────────────────
|
|
|
|
describe("StatusPoller — Retired suppliers not polled", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("Retired advertisement is NOT included in poll cycle (fetch not called)", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => []), // returns [] — Retired not returned by listActiveAdvertisements
|
|
upsertSupplierStatus: vi.fn(),
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Mix of Active and Retired: only Active suppliers polled (fetch called exactly once)", async () => {
|
|
const fetchMock = makeFetchMock(STATUS_FREE);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
// listActiveAdvertisements returns only 1 Active advert (Retired excluded by cache query)
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [sampleActiveAdvert()]),
|
|
upsertSupplierStatus: vi.fn(),
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// ─── Multiple suppliers — RED ─────────────────────────────────────────────────
|
|
|
|
describe("StatusPoller — multiple suppliers", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("polls each Active supplier at their own endpoint_url/status", async () => {
|
|
const pkh2 = "1".repeat(56);
|
|
const ep2 = "https://supplier2.example.com";
|
|
const fetchMock = makeFetchMock(STATUS_FREE);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const mockCache = {
|
|
listActiveAdvertisements: vi.fn(() => [
|
|
sampleActiveAdvert({ utxo_ref: "a".repeat(64) + "#0", supplier_pkh: INDEXER_SUPPLIER_PKH, endpoint_url: INDEXER_SUPPLIER_ENDPOINT }),
|
|
sampleActiveAdvert({ utxo_ref: "b".repeat(64) + "#0", supplier_pkh: pkh2, endpoint_url: ep2 }),
|
|
]),
|
|
upsertSupplierStatus: vi.fn(),
|
|
} as unknown as SqliteCache;
|
|
const poller = new StatusPoller({ cache: mockCache, pollIntervalMs: 0 });
|
|
await poller.tickOnce();
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const calledUrls = fetchMock.mock.calls.map((c: [string, ...unknown[]]) => c[0]);
|
|
expect(calledUrls.some((u: string) => u.includes(INDEXER_SUPPLIER_ENDPOINT))).toBe(true);
|
|
expect(calledUrls.some((u: string) => u.includes(ep2))).toBe(true);
|
|
});
|
|
});
|