Files
agents-marketplace/tests/unit/indexer-ui-suppliers-table.test.tsx
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

159 lines
6.4 KiB
TypeScript

// @vitest-environment happy-dom
/**
* tests/unit/indexer-ui-suppliers-table.test.tsx — GREEN (M1-F-5)
*
* Category C: SuppliersTable component (~8 tests)
*
* Mock strategy: vi.mock at module level for both client.js and useSSE.js;
* per-test control via mockReturnValue / mockResolvedValue.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import {
ALL_SAMPLE_SUPPLIERS,
SAMPLE_FREE_SUPPLIER,
SAMPLE_WORKING_SUPPLIER,
SAMPLE_OFFLINE_SUPPLIER,
} from "../fixtures/indexer-ui-side/sample-supplier-rows.js";
// Module-level mocks — hoisted above imports.
vi.mock("../../indexer-ui/src/api/client.js", () => ({
fetchSuppliers: vi.fn(),
fetchHealthz: vi.fn(),
fetchCapabilities: vi.fn(),
fetchEscrow: vi.fn(),
}));
vi.mock("../../indexer-ui/src/hooks/useSSE.js", () => ({
useSSE: vi.fn(() => ({ events: [], lastSeenSlot: null, connected: true })),
MAX_EVENTS: 500,
}));
import SuppliersTable from "../../indexer-ui/src/components/SuppliersTable.js";
const { fetchSuppliers: fetchSuppliersMock } = await import("../../indexer-ui/src/api/client.js");
const { useSSE: useSSEMock } = await import("../../indexer-ui/src/hooks/useSSE.js");
afterEach(() => {
cleanup();
vi.clearAllMocks();
// Reset to safe defaults.
(useSSEMock as ReturnType<typeof vi.fn>).mockReturnValue({
events: [],
lastSeenSlot: null,
connected: true,
});
});
// ─── Columns and initial fetch ───────────────────────────────────────────────
describe("SuppliersTable — columns", () => {
it("renders column headers: capability_id, model, price, status, last_seen", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue(ALL_SAMPLE_SUPPLIERS);
render(<SuppliersTable />);
await waitFor(() => {
expect(screen.getByText(/capability/i)).toBeTruthy();
expect(screen.getByText(/model/i)).toBeTruthy();
expect(screen.getByText(/price/i)).toBeTruthy();
expect(screen.getByText(/status/i)).toBeTruthy();
expect(screen.getByText(/last.?seen/i)).toBeTruthy();
});
});
it("fetches /suppliers on mount and renders one row per supplier", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue(ALL_SAMPLE_SUPPLIERS);
render(<SuppliersTable />);
await waitFor(() => {
const rows = document.querySelectorAll("[data-testid='supplier-row']");
expect(rows.length).toBe(ALL_SAMPLE_SUPPLIERS.length);
});
});
});
// ─── Empty state ─────────────────────────────────────────────────────────────
describe("SuppliersTable — empty state", () => {
it("renders 'no suppliers yet' when supplier list is empty", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue([]);
render(<SuppliersTable />);
await waitFor(() => {
expect(screen.getByText(/no suppliers yet/i)).toBeTruthy();
});
});
});
// ─── Price formatting ────────────────────────────────────────────────────────
describe("SuppliersTable — price formatting", () => {
it("formats price_lovelace=2000000 as '2 AP3X'", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue([SAMPLE_FREE_SUPPLIER]);
render(<SuppliersTable />);
await waitFor(() => {
expect(screen.getByText(/2\s*AP3X/i)).toBeTruthy();
});
});
it("formats price_lovelace=1000000 as '1 AP3X'", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue([SAMPLE_WORKING_SUPPLIER]);
render(<SuppliersTable />);
await waitFor(() => {
expect(screen.getByText(/1\s*AP3X/i)).toBeTruthy();
});
});
});
// ─── Status pill colours ─────────────────────────────────────────────────────
describe("SuppliersTable — status pill colours", () => {
it("free status pill has green-related class", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue([SAMPLE_FREE_SUPPLIER]);
render(<SuppliersTable />);
await waitFor(() => {
const pills = document.querySelectorAll("[data-testid='status-pill']");
expect(pills.length).toBeGreaterThan(0);
const pill = pills[0] as HTMLElement;
expect(pill.className).toMatch(/green/i);
});
});
it("working status pill has blue-related class", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue([SAMPLE_WORKING_SUPPLIER]);
render(<SuppliersTable />);
await waitFor(() => {
const pill = document.querySelector("[data-testid='status-pill']") as HTMLElement;
expect(pill).not.toBeNull();
expect(pill.className).toMatch(/blue/i);
});
});
it("offline status pill has gray-related class", async () => {
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue([SAMPLE_OFFLINE_SUPPLIER]);
render(<SuppliersTable />);
await waitFor(() => {
const pill = document.querySelector("[data-testid='status-pill']") as HTMLElement;
expect(pill).not.toBeNull();
expect(pill.className).toMatch(/gray/i);
});
});
});
// ─── Refresh on chain-event ──────────────────────────────────────────────────
describe("SuppliersTable — SSE refresh", () => {
it("re-fetches /suppliers when a chain-event is emitted via useSSE", async () => {
// Provide a mock useSSE that emits a PostAdvert event immediately.
(useSSEMock as ReturnType<typeof vi.fn>).mockReturnValue({
events: [{ type: "PostAdvert", ref: "f".repeat(64) + "#0", slot: 3000 }],
lastSeenSlot: 3000,
connected: true,
});
(fetchSuppliersMock as ReturnType<typeof vi.fn>).mockResolvedValue([SAMPLE_FREE_SUPPLIER]);
render(<SuppliersTable />);
// Expect at least 2 calls: initial mount (via usePolling) + on chain-event
await waitFor(() => {
expect((fetchSuppliersMock as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
});