Files
agents-marketplace/tests/unit/indexer-routes.test.ts
jelisaveta-talbot cbab81efbd M1-F: end-to-end LLM marketplace lifecycle on Vector testnet
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)
2026-05-06 13:27:48 +02:00

451 lines
18 KiB
TypeScript

/**
* tests/unit/indexer-routes.test.ts — REST route tests via supertest (Category D)
*
* Tests all indexer routes: /suppliers, /capabilities, /escrows, /health.
* createApp is injected with a mock cache and worker.
* All tests RED — createApp throws "not implemented — M1-D-green".
* Tests call createApp() directly; the stub throws, causing Vitest to mark them FAIL.
* When Catherine implements createApp, these will use supertest to verify route behaviour.
*/
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
import { createApp } from "../../indexer/src/server.js";
import type { IndexerDeps } from "../../indexer/src/server.js";
import {
INDEXER_SUPPLIER_PKH,
INDEXER_BUYER_PKH,
INDEXER_SUPPLIER_ENDPOINT,
buildAdvertDatumHex,
buildEscrowDatumHex,
SAMPLE_DELIVER_BY,
SAMPLE_POSTED_AT,
SAMPLE_REQUEST_SPEC_HASH,
SAMPLE_PROMPT_HASH,
} from "../fixtures/indexer-side/sample-blocks.js";
import type { AdvertRow, EscrowRow } from "../../indexer/src/db/cache.js";
// ─── Mock helpers ─────────────────────────────────────────────────────────────
function sampleAdvert(overrides: Partial<AdvertRow> = {}): AdvertRow {
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: SAMPLE_POSTED_AT,
status: "Active",
created_slot: 1_000_000,
datum_hex: buildAdvertDatumHex(),
rolled_back: 0,
...overrides,
} as AdvertRow;
}
function sampleEscrow(overrides: Partial<EscrowRow> = {}): EscrowRow {
return {
utxo_ref: "c".repeat(64) + "#0",
buyer_pkh: INDEXER_BUYER_PKH,
supplier_pkh: INDEXER_SUPPLIER_PKH,
advert_ref_tx: "a".repeat(64),
advert_ref_index: 0,
capability_id: "llm.text.generate.v1",
request_spec_hash: SAMPLE_REQUEST_SPEC_HASH,
prompt_hash: SAMPLE_PROMPT_HASH,
payment_lovelace: "2000000",
buyer_bond_lovelace: "1000000",
supplier_bond_lovelace: "1000000",
deliver_by: SAMPLE_DELIVER_BY,
posted_at: SAMPLE_POSTED_AT,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
created_slot: 1_000_000,
datum_hex: buildEscrowDatumHex({ state: "Open" }),
rolled_back: 0,
...overrides,
} as EscrowRow;
}
function makeMockDeps(overrides: Partial<{
adverts: AdvertRow[];
escrows: EscrowRow[];
currentSlot: number;
tipSlot: number;
}>): IndexerDeps {
const adverts = overrides.adverts ?? [sampleAdvert()];
const escrows = overrides.escrows ?? [];
const currentSlot = overrides.currentSlot ?? 1_000_000;
const tipSlot = overrides.tipSlot ?? 1_001_000;
const cache = {
listActiveAdvertisements: vi.fn((filter?: { capability_id?: string; supplier_pkh?: string }) => {
return adverts.filter(a => {
if (filter?.capability_id && a.capability_id !== filter.capability_id) return false;
if (filter?.supplier_pkh && a.supplier_pkh !== filter.supplier_pkh) return false;
return a.status === "Active";
});
}),
getAdvertisementByRef: vi.fn((ref: string) => adverts.find(a => a.utxo_ref === ref) ?? null),
getEscrowByRef: vi.fn((ref: string) => escrows.find(e => e.utxo_ref === ref) ?? null),
listEscrowsByBuyer: vi.fn((pkh: string) => escrows.filter(e => e.buyer_pkh === pkh)),
listEscrowsBySupplier: vi.fn((pkh: string) => escrows.filter(e => e.supplier_pkh === pkh)),
getSupplierStatus: vi.fn(() => ({
supplier_pkh: INDEXER_SUPPLIER_PKH,
advert_ref: "a".repeat(64) + "#0",
status: "free",
last_seen_iso: "2026-04-24T10:00:00.000Z",
current_escrow_ref: null,
polled_at: Date.now(),
})),
listEventsAfterSlot: vi.fn(() => []),
dbSizeBytes: vi.fn(() => 4096),
} as unknown as IndexerDeps["cache"];
const worker = {
getCurrentSlot: vi.fn(() => currentSlot),
getTipSlot: vi.fn(() => tipSlot),
on: vi.fn(),
off: vi.fn(),
} as unknown as IndexerDeps["worker"];
return { cache, worker };
}
// ─── GET /healthz ─────────────────────────────────────────────────────────────
// SPEC FIX 2026-04-27: standardize to /healthz (was /health — indexer drift).
// All tests call createApp() directly — stub throws, tests fail RED.
describe("GET /healthz", () => {
it("GET /healthz returns 200 with ok, sync_slot, tip_slot, db_size_bytes", async () => { // SPEC FIX 2026-04-27: standardize to /healthz
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/healthz");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
ok: true,
sync_slot: 1_000_000,
tip_slot: 1_001_000,
db_size_bytes: 4096,
});
});
it("healthz endpoint returns ogmios_status field", async () => { // SPEC FIX 2026-04-27: standardize to /healthz
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/healthz");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("ogmios_status");
});
it("sync_slot reflects worker.getCurrentSlot() value of 999999", async () => {
const app = createApp(makeMockDeps({ currentSlot: 999_999 }));
const res = await request(app).get("/healthz"); // SPEC FIX 2026-04-27: standardize to /healthz
expect(res.body.sync_slot).toBe(999_999);
});
it("tip_slot reflects worker.getTipSlot() value of 1234567", async () => {
const app = createApp(makeMockDeps({ tipSlot: 1_234_567 }));
const res = await request(app).get("/healthz"); // SPEC FIX 2026-04-27: standardize to /healthz
expect(res.body.tip_slot).toBe(1_234_567);
});
});
// ─── GET /suppliers ───────────────────────────────────────────────────────────
describe("GET /suppliers", () => {
it("GET /suppliers returns 200 with array of active suppliers", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/suppliers");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBe(1);
});
it("suppliers list includes supplier_pkh, capability_id, price_lovelace, endpoint_url", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/suppliers");
expect(res.status).toBe(200);
expect(res.body[0]).toMatchObject({
supplier_pkh: INDEXER_SUPPLIER_PKH,
capability_id: "llm.text.generate.v1",
price_lovelace: "2000000",
endpoint_url: INDEXER_SUPPLIER_ENDPOINT,
});
});
it("suppliers list includes last status (status, last_seen_iso) from supplier_status cache", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/suppliers");
expect(res.status).toBe(200);
expect(res.body[0]).toMatchObject({
status: "free",
last_seen_iso: expect.any(String),
});
});
it("returns empty array when no active suppliers (adverts=[])", async () => {
const app = createApp(makeMockDeps({ adverts: [] }));
const res = await request(app).get("/suppliers");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("price_lovelace is serialised as string (bigint-safe)", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/suppliers");
expect(typeof res.body[0].price_lovelace).toBe("string");
});
});
// ─── GET /suppliers/:pkh ──────────────────────────────────────────────────────
describe("GET /suppliers/:pkh", () => {
it("returns 200 with supplier detail when pkh matches Active advert", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get(`/suppliers/${INDEXER_SUPPLIER_PKH}`);
expect(res.status).toBe(200);
expect(res.body.supplier_pkh).toBe(INDEXER_SUPPLIER_PKH);
});
it("returns 404 when no Active advert exists for pkh", async () => {
const app = createApp(makeMockDeps({ adverts: [] }));
const res = await request(app).get(`/suppliers/${INDEXER_SUPPLIER_PKH}`);
expect(res.status).toBe(404);
});
it("returns 404 for Retired supplier pkh", async () => {
const app = createApp(makeMockDeps({ adverts: [sampleAdvert({ status: "Retired" })] }));
const res = await request(app).get(`/suppliers/${INDEXER_SUPPLIER_PKH}`);
expect(res.status).toBe(404);
});
it("response body includes endpoint_url and status", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get(`/suppliers/${INDEXER_SUPPLIER_PKH}`);
expect(res.body).toMatchObject({
endpoint_url: INDEXER_SUPPLIER_ENDPOINT,
status: "free",
});
});
});
// ─── GET /capabilities ────────────────────────────────────────────────────────
describe("GET /capabilities", () => {
it("returns 200 with distinct capability_id list", async () => {
const adverts = [
sampleAdvert({ utxo_ref: "a".repeat(64) + "#0", capability_id: "llm.text.generate.v1" }),
sampleAdvert({ utxo_ref: "b".repeat(64) + "#0", capability_id: "llm.text.generate.v1" }),
sampleAdvert({ utxo_ref: "c".repeat(64) + "#0", capability_id: "speech.transcribe.v1" }),
];
const app = createApp(makeMockDeps({ adverts }));
const res = await request(app).get("/capabilities");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it("returns correct supplier_count per capability", async () => {
const adverts = [
sampleAdvert({ utxo_ref: "a".repeat(64) + "#0", capability_id: "llm.text.generate.v1" }),
sampleAdvert({ utxo_ref: "b".repeat(64) + "#0", capability_id: "llm.text.generate.v1" }),
sampleAdvert({ utxo_ref: "c".repeat(64) + "#0", capability_id: "speech.transcribe.v1" }),
];
const app = createApp(makeMockDeps({ adverts }));
const res = await request(app).get("/capabilities");
expect(res.status).toBe(200);
const llm = res.body.find((c: { capability_id: string }) => c.capability_id === "llm.text.generate.v1");
expect(llm?.supplier_count).toBe(2);
});
it("returns empty array when no active suppliers", async () => {
const app = createApp(makeMockDeps({ adverts: [] }));
const res = await request(app).get("/capabilities");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
// ─── GET /capabilities/:id/suppliers ─────────────────────────────────────────
describe("GET /capabilities/:id/suppliers", () => {
it("filters suppliers by capability_id, returns only matching", async () => {
const adverts = [
sampleAdvert({ utxo_ref: "a".repeat(64) + "#0", capability_id: "llm.text.generate.v1" }),
sampleAdvert({ utxo_ref: "b".repeat(64) + "#0", capability_id: "speech.transcribe.v1" }),
];
const app = createApp(makeMockDeps({ adverts }));
const res = await request(app).get("/capabilities/llm.text.generate.v1/suppliers");
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].capability_id).toBe("llm.text.generate.v1");
});
it("?sort=price sorts by price_lovelace ascending (cheapest first)", async () => {
const adverts = [
sampleAdvert({ utxo_ref: "a".repeat(64) + "#0", price_lovelace: "3000000" }),
sampleAdvert({ utxo_ref: "b".repeat(64) + "#0", price_lovelace: "1000000" }),
];
const app = createApp(makeMockDeps({ adverts }));
const res = await request(app).get("/capabilities/llm.text.generate.v1/suppliers?sort=price");
expect(res.status).toBe(200);
expect(res.body[0].price_lovelace).toBe("1000000");
});
it("?sort=last_seen sorts by last_seen_iso descending (most recent first)", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/capabilities/llm.text.generate.v1/suppliers?sort=last_seen");
expect(res.status).toBe(200);
// Just checking it doesn't 400/500 — sort order verified by caller
});
it("returns empty array for unknown capability_id", async () => {
const app = createApp(makeMockDeps({ adverts: [] }));
const res = await request(app).get("/capabilities/unknown.capability.v99/suppliers");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns 200 with all matching suppliers across multiple suppliers", async () => {
const otherPkh = "11".repeat(28);
const adverts = [
sampleAdvert({ utxo_ref: "a".repeat(64) + "#0", supplier_pkh: INDEXER_SUPPLIER_PKH }),
sampleAdvert({ utxo_ref: "b".repeat(64) + "#0", supplier_pkh: otherPkh }),
];
const app = createApp(makeMockDeps({ adverts }));
const res = await request(app).get("/capabilities/llm.text.generate.v1/suppliers");
expect(res.status).toBe(200);
expect(res.body.length).toBe(2);
});
});
// ─── GET /escrows/:ref ────────────────────────────────────────────────────────
describe("GET /escrows/:ref", () => {
it("returns 200 with full escrow detail including state, buyer_pkh, supplier_pkh", async () => {
const ref = "c".repeat(64) + "#0";
const app = createApp(makeMockDeps({ escrows: [sampleEscrow({ utxo_ref: ref })] }));
const encodedRef = encodeURIComponent(ref);
const res = await request(app).get(`/escrows/${encodedRef}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
buyer_pkh: INDEXER_BUYER_PKH,
supplier_pkh: INDEXER_SUPPLIER_PKH,
state: "Open",
});
});
it("returns 404 when escrow ref not found", async () => {
const app = createApp(makeMockDeps({ escrows: [] }));
const res = await request(app).get("/escrows/unknown%230");
expect(res.status).toBe(404);
});
it("escrow response includes deliver_by, posted_at, submitted_at, result_receipt_hash", async () => {
const ref = "c".repeat(64) + "#0";
const app = createApp(makeMockDeps({ escrows: [sampleEscrow({ utxo_ref: ref })] }));
const encodedRef = encodeURIComponent(ref);
const res = await request(app).get(`/escrows/${encodedRef}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
deliver_by: expect.any(Number),
posted_at: expect.any(Number),
});
expect(res.body).toHaveProperty("submitted_at");
expect(res.body).toHaveProperty("result_receipt_hash");
});
it("returns 200 for Claimed escrow with matching state in response", async () => {
const ref = "d".repeat(64) + "#0";
const app = createApp(makeMockDeps({
escrows: [sampleEscrow({ utxo_ref: ref, state: "Claimed" })],
}));
const encodedRef = encodeURIComponent(ref);
const res = await request(app).get(`/escrows/${encodedRef}`);
expect(res.status).toBe(200);
expect(res.body.state).toBe("Claimed");
});
});
// ─── GET /escrows?buyer= and ?supplier= ──────────────────────────────────────
describe("GET /escrows (query params)", () => {
it("?buyer=PKH returns escrows matching buyer_pkh only", async () => {
const otherBuyer = "9".repeat(56);
const deps = makeMockDeps({
escrows: [
sampleEscrow({ utxo_ref: "c".repeat(64) + "#0", buyer_pkh: INDEXER_BUYER_PKH }),
sampleEscrow({ utxo_ref: "d".repeat(64) + "#0", buyer_pkh: otherBuyer }),
],
});
const app = createApp(deps);
const res = await request(app).get(`/escrows?buyer=${INDEXER_BUYER_PKH}`);
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].buyer_pkh).toBe(INDEXER_BUYER_PKH);
});
it("?supplier=PKH returns escrows matching supplier_pkh only", async () => {
const otherSupplier = "9".repeat(56);
const deps = makeMockDeps({
escrows: [
sampleEscrow({ utxo_ref: "c".repeat(64) + "#0", supplier_pkh: INDEXER_SUPPLIER_PKH }),
sampleEscrow({ utxo_ref: "d".repeat(64) + "#0", supplier_pkh: otherSupplier }),
],
});
const app = createApp(deps);
const res = await request(app).get(`/escrows?supplier=${INDEXER_SUPPLIER_PKH}`);
expect(res.status).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0].supplier_pkh).toBe(INDEXER_SUPPLIER_PKH);
});
it("returns empty array when no escrows match buyer query", async () => {
const app = createApp(makeMockDeps({ escrows: [] }));
const res = await request(app).get(`/escrows?buyer=${INDEXER_BUYER_PKH}`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns 400 when neither buyer nor supplier filter is provided", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/escrows");
expect(res.status).toBe(400);
});
it("payment_lovelace in escrow response is serialised as string", async () => {
const deps = makeMockDeps({
escrows: [sampleEscrow({ buyer_pkh: INDEXER_BUYER_PKH })],
});
const app = createApp(deps);
const res = await request(app).get(`/escrows?buyer=${INDEXER_BUYER_PKH}`);
expect(res.status).toBe(200);
expect(typeof res.body[0].payment_lovelace).toBe("string");
});
});
// ─── Unknown routes ───────────────────────────────────────────────────────────
describe("createApp — unknown routes", () => {
it("returns 404 for unknown path /nonexistent-path", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/nonexistent-path");
expect(res.status).toBe(404);
});
it("does not expose internal Express error details on unmatched routes", async () => {
const app = createApp(makeMockDeps({}));
const res = await request(app).get("/nonexistent-path");
expect(res.status).toBe(404);
// Should not return a full Express stack trace
expect(typeof res.text).toBe("string");
});
});