Files
agents-marketplace/tests/unit/buyer-sdk-discover-suppliers.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

170 lines
6.8 KiB
TypeScript

/**
* buyer-sdk-discover-suppliers.test.ts — RED phase (M1-E)
*
* Category A: Marketplace.discoverSuppliers()
*
* All tests are expected to FAIL until M1-E-green because Marketplace.discoverSuppliers()
* currently throws "not implemented — M1-E-green".
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
import { IndexerError } from "../../buyer/src/sdk/types.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeMarketplace(fetchImpl: typeof globalThis.fetch): Marketplace {
return new Marketplace({
chain: new MockChainProvider(),
indexerUrl: "http://indexer.test",
walletKey: buildBuyerWalletKey(),
networkParams: { networkId: 0 },
_fetch: fetchImpl as unknown as typeof fetch,
} as never);
}
function makeSampleSupplier(overrides: Record<string, unknown> = {}) {
return {
utxo_ref: "a".repeat(64) + "#0",
supplier_pkh: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01",
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: "https://supplier.example.com",
detail_uri: "ipfs://Qm000",
detail_hash: "a".repeat(64),
advertised_at: 1_745_500_000_000,
status: "free",
advert_status: "Active",
current_escrow_ref: null,
last_seen_iso: "2026-04-24T00:00:00.000Z",
created_slot: 1000,
...overrides,
};
}
function jsonResponse(body: unknown, status = 200) {
return Promise.resolve(
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Marketplace.discoverSuppliers()", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
});
it("calls GET ${indexerUrl}/suppliers with no extra params when called with no options", async () => {
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
await mp.discoverSuppliers();
expect(fetchSpy).toHaveBeenCalledOnce();
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
expect(url).toBe("http://indexer.test/suppliers");
});
it("returns a list shaped as SupplierView[]", async () => {
const sample = makeSampleSupplier();
fetchSpy.mockReturnValue(jsonResponse([sample]));
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
const result = await mp.discoverSuppliers();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
supplier_pkh: sample.supplier_pkh,
capability_id: sample.capability_id,
price_lovelace: sample.price_lovelace,
});
});
it("adds ?capability_id=... query param when filtering by capability_id", async () => {
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
await mp.discoverSuppliers({ capability_id: "llm.text.generate.v1" });
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
expect(url).toContain("capability_id=llm.text.generate.v1");
});
it("adds ?sort=price query param when sort:'price' is requested", async () => {
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
await mp.discoverSuppliers({ sort: "price" });
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
expect(url).toContain("sort=price");
});
it("combines capability_id and sort params correctly", async () => {
fetchSpy.mockReturnValue(jsonResponse([makeSampleSupplier()]));
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
await mp.discoverSuppliers({ capability_id: "llm.text.generate.v1", sort: "price" });
const url = String((fetchSpy.mock.calls[0] as unknown[])[0]);
expect(url).toContain("capability_id=llm.text.generate.v1");
expect(url).toContain("sort=price");
});
it("returns [] when the indexer returns an empty list", async () => {
fetchSpy.mockReturnValue(jsonResponse([]));
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
const result = await mp.discoverSuppliers();
expect(result).toEqual([]);
});
it("throws IndexerError with status code when indexer returns 5xx", async () => {
fetchSpy.mockReturnValue(
Promise.resolve(new Response(JSON.stringify({ error: "internal" }), { status: 503 }))
);
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
await expect(mp.discoverSuppliers()).rejects.toSatisfy(
(e: unknown) => e instanceof IndexerError && e.status === 503
);
});
it("throws IndexerError when indexer returns 500", async () => {
fetchSpy.mockReturnValue(
Promise.resolve(new Response("server error", { status: 500 }))
);
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
await expect(mp.discoverSuppliers()).rejects.toBeInstanceOf(IndexerError);
});
it("throws IndexerError when indexer returns malformed JSON", async () => {
fetchSpy.mockReturnValue(
Promise.resolve(new Response("not json!!", { status: 200 }))
);
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
await expect(mp.discoverSuppliers()).rejects.toBeInstanceOf(IndexerError);
});
it("returns multiple suppliers when indexer returns multiple entries", async () => {
const s1 = makeSampleSupplier({ supplier_pkh: "a".repeat(56) });
const s2 = makeSampleSupplier({ supplier_pkh: "b".repeat(56) });
fetchSpy.mockReturnValue(jsonResponse([s1, s2]));
const mp = makeMarketplace(fetchSpy as unknown as typeof fetch);
const result = await mp.discoverSuppliers();
expect(result).toHaveLength(2);
});
it("does not make any network calls during Marketplace construction", () => {
const mp = new Marketplace({
chain: new MockChainProvider(),
indexerUrl: "http://indexer.test",
walletKey: buildBuyerWalletKey(),
networkParams: { networkId: 0 },
});
expect(fetchSpy).not.toHaveBeenCalled();
void mp; // suppress unused
});
});