Files
agents-marketplace/tests/unit/buyer-ui-smoke.test.tsx
jelisaveta-talbot 1f1a7d5a59 buyer: response archive (SQLite + filesystem) for off-chain audit trail
The on-chain Submit tx anchors sha256(canonicalize(receipt)) so the
receipt's signature is provably tied to a specific Cardano block — but
without persisting the receipt content + the actual response bytes
nowhere can buyer or supplier produce the artefacts a dispute would
need to argue about. This commit closes that gap on the buyer side.

What's new

- buyer/src/db/schema.ts (new):
  `responses` table keyed by escrow_ref, indexes on completed_at /
  capability_id / supplier_pkh. Stores metadata + receipt JSON +
  Ed25519 signature; large blobs (request envelope, response bytes)
  live on disk per-escrow.

- buyer/src/db/archive.ts (new):
  ResponseArchive class — persistChat / persistTts / list / get /
  readRequest / readResponse. Filesystem layout under ARCHIVE_DIR:
    <txhash>_<index>/request.json   (always JSON)
    <txhash>_<index>/response.{ext} (txt/json for chat, mp3/wav/… for TTS)
  Synchronous (better-sqlite3 + fs.writeFileSync) — volume is low and
  the simplicity is worth more than async overhead.

- buyer/src/server.ts:
  - AppDeps.archive plumbed through createApp.
  - /v1/submit-prompt + /v1/submit-tts persist BEFORE returning to
    the SPA, using canonicalize() for chat (same bytes the supplier
    hashed; JSON.stringify produced different bytes → wrong sha256).
    Persist failures are logged but never abort the response — the
    on-chain receipt is still authoritative.
  - New read-only endpoints:
      GET /v1/responses?limit=…           list metadata + parsed receipts
      GET /v1/responses/:ref               single-row detail
      GET /v1/responses/:ref/request       request artefact (application/json)
      GET /v1/responses/:ref/response      response artefact (verbatim bytes)
  - URL path uses "<txhash>_<index>" (the dir-safe form) since "#"
    can't appear unencoded in URLs (fragment delimiter); server
    normalises back to canonical "<txhash>#<index>" for SQLite lookup.
  - 503 service_unavailable on /v1/responses* when boot has no
    archive (ARCHIVE_DIR unset or DB unopenable) — the rest of
    the buyer-app still works.

- buyer/src/ui/pages/TaskHistory.tsx:
  Joins indexer escrows (chain state) + /v1/responses (archived
  artefacts) by escrow_ref. Each completed lifecycle gets an
  archive panel showing:
  - Request panel (above, chronological order): human-readable
    summary — first user message for chat, `text + voice + format +
    speed` for TTS, raw JSON fallback otherwise.
  - Response panel: assistant text inline for chat, <audio controls>
    for TTS; Download response / Download request anchors.
  - "Verify hash" button — fetches the response bytes, computes
    sha256 via crypto.subtle, compares to receipt.response_hash;
    green ✓ on match, red ✗ with truncated computed/expected on
    mismatch.
  - Collapsible "Receipt + signature" with the full canonical
    receipt JSON + the supplier's Ed25519 hex sig.

- buyer/src/config.ts: + ARCHIVE_DIR env (default "./data/archive").

- buyer/Dockerfile: builder gains python3/make/g++ + the
  better-sqlite3 native-binding fetch step (mirrors indexer/Dockerfile);
  runtime image stays toolchain-free.

- deploy/testnet/docker-compose.buyer.yml: bind-mounts
  ../../data/buyer/archive (repo-root-relative, not file-relative)
  into /data/archive. ARCHIVE_DIR env points at that path.

Tests

- tests/unit/buyer-archive.test.ts (new, 14 tests): persistChat /
  persistTts unit coverage (filesystem layout, INSERT-OR-REPLACE
  semantics, content-type → extension mapping for mp3/wav/opus/aac/
  flac, list ordering + limit, single-row get/read miss-vs-hit) +
  HTTP integration (503 when disabled, happy paths returning
  metadata / request / response artefact bytes with correct
  content-types, 404 on unknown ref).
- tests/unit/buyer-ui-smoke.test.tsx: TaskHistory fetch mock now
  also handles /v1/responses (returns empty list — same code path as
  "archive disabled" production case).

45 buyer tests passing; chat + TTS lifecycle verified end-to-end on
Vector testnet; "Verify hash" button confirms sha256(response bytes)
matches receipt.response_hash for both capabilities.
2026-05-11 08:49:07 +02:00

358 lines
15 KiB
TypeScript

// @vitest-environment happy-dom
/**
* buyer-ui-smoke.test.tsx — RED phase (M1-E)
*
* Category G: UI smoke tests with React Testing Library + happy-dom (~15 tests)
*
* All tests FAIL until M1-E-green because the UI components currently
* render "NOT IMPLEMENTED" stubs and the Marketplace methods throw.
*
* Design contract encoded for Catherine:
* - <App /> must render a <nav> with links to /, /tasks, /wallet
* - <Dashboard /> must call marketplace.discoverSuppliers() on mount
* - <PromptForm /> must call marketplace.submitPrompt() with correct args on submit
* - <TaskHistory /> must call getTaskHistory() and render TaskRow for each entry
* - <Wallet /> must render the configured walletKey.address
* - <SupplierCard /> "Use" button must be disabled when supplier.status === "offline"
* - Submitting an empty prompt must be prevented client-side (no SDK call)
* - A loading state must render while submitPrompt() is pending
* - An error toast/message must render when submitPrompt() rejects
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import App from "../../buyer/src/ui/App.js";
import Dashboard from "../../buyer/src/ui/pages/Dashboard.js";
import TaskHistory from "../../buyer/src/ui/pages/TaskHistory.js";
import Wallet from "../../buyer/src/ui/pages/Wallet.js";
import SupplierCard from "../../buyer/src/ui/components/SupplierCard.js";
import PromptForm from "../../buyer/src/ui/components/PromptForm.js";
import { MarketplaceProvider } from "../../buyer/src/ui/state/MarketplaceContext.js";
import type { SupplierView } from "../../buyer/src/sdk/types.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
import {
ALL_SAMPLE_TASK_RECORDS,
TASK_COMPLETED,
} from "../fixtures/buyer-side/sample-task-records.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
import { MemoryTaskHistoryStore } from "../../buyer/src/sdk/history.js";
// ─── Mock Marketplace factory ──────────────────────────────────────────────
const buyer = buildBuyerWalletKey();
function makeMockMarketplace() {
const store = new MemoryTaskHistoryStore();
const mp = new Marketplace({
chain: new MockChainProvider(),
indexerUrl: "http://indexer.test",
walletKey: buyer,
networkParams: { networkId: 0 },
historyStore: store,
});
return mp;
}
function makeSampleSupplierView(overrides: Partial<SupplierView> = {}): SupplierView {
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 wrap(element: React.ReactElement, mp: Marketplace) {
return (
<MemoryRouter>
<MarketplaceProvider marketplace={mp}>
{element}
</MarketplaceProvider>
</MemoryRouter>
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("<App /> smoke", () => {
it("renders a nav element with links for Dashboard, Tasks, and Wallet", () => {
const mp = makeMockMarketplace();
render(wrap(<App />, mp));
const nav = document.querySelector("nav");
expect(nav).not.toBeNull();
// Each link should be present
const links = screen.getAllByRole("link");
const hrefs = links.map((l) => (l as HTMLAnchorElement).pathname ?? (l as HTMLAnchorElement).getAttribute("href") ?? "");
expect(hrefs.some(h => h === "/" || h === "")).toBe(true);
expect(hrefs.some(h => h.includes("tasks"))).toBe(true);
expect(hrefs.some(h => h.includes("wallet"))).toBe(true);
});
});
describe("<Dashboard /> smoke", () => {
it("calls marketplace.discoverSuppliers() on mount", async () => {
const mp = makeMockMarketplace();
const spy = vi.spyOn(mp, "discoverSuppliers").mockResolvedValue([]);
render(wrap(<Dashboard />, mp));
await waitFor(() => expect(spy).toHaveBeenCalledOnce());
});
it("renders a SupplierCard for each supplier returned by discoverSuppliers()", async () => {
const mp = makeMockMarketplace();
vi.spyOn(mp, "discoverSuppliers").mockResolvedValue([
makeSampleSupplierView({ supplier_pkh: "a".repeat(56) }),
makeSampleSupplierView({ supplier_pkh: "b".repeat(56) }),
]);
render(wrap(<Dashboard />, mp));
// Wait for async discover to complete and cards to render. Dashboard
// appends a synthetic Piper-TTS demo card to whatever the indexer
// returned, so the count is N+1 until the on-chain TTS supplier ships.
await waitFor(() => {
const cards = document.querySelectorAll("[data-testid='supplier-card']");
expect(cards.length).toBe(3);
});
// Sanity: one of the cards is the demo Piper supplier.
const piperBadges = Array.from(
document.querySelectorAll("[data-testid='supplier-card']"),
).filter((c) => c.textContent?.includes("audio.synthesize.piper.v1"));
expect(piperBadges.length).toBe(1);
});
});
describe("<PromptForm /> smoke", () => {
// PromptForm now POSTs to /v1/submit-prompt server-side instead of calling
// the SDK directly — the browser SPA's chain provider is a stub so all
// chain-touching work moved server-side. These tests stub globalThis.fetch
// to verify the form's request shape and UX states.
function stubFetchOk(receiptObj: unknown, signature: string) {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url);
if (!url.endsWith("/v1/submit-prompt")) {
throw new Error(`unexpected fetch: ${url}`);
}
const body = init?.body ? JSON.parse(init.body as string) : {};
(stubFetchOk as unknown as { lastBody?: unknown }).lastBody = body;
return new Response(JSON.stringify({
choices: [{ index: 0, message: { role: "assistant", content: "4" }, finish_reason: "stop" }],
receipt: receiptObj,
receipt_signature: signature,
escrow_ref: `${"a".repeat(64)}#0`,
}), { status: 200, headers: { "Content-Type": "application/json" } });
});
}
it("POSTs /v1/submit-prompt with correct messages when form is submitted", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
const fetchSpy = stubFetchOk(TASK_COMPLETED.receipt!, TASK_COMPLETED.receipt_signature!);
vi.stubGlobal("fetch", fetchSpy);
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(
<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />,
mp
));
const textarea = screen.getByRole("textbox");
await user.type(textarea, "What is 2+2?");
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
const lastBody = (stubFetchOk as unknown as { lastBody?: { messages: Array<{ content: string }> } }).lastBody;
expect(lastBody?.messages.some((m) => m.content.includes("What is 2+2?"))).toBe(true);
vi.unstubAllGlobals();
});
it("does NOT POST when prompt textarea is empty (client-side validation)", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
const fetchSpy = stubFetchOk(TASK_COMPLETED.receipt!, TASK_COMPLETED.receipt_signature!);
vi.stubGlobal("fetch", fetchSpy);
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(
<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />,
mp
));
// Do NOT type anything; just click submit
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await new Promise(r => setTimeout(r, 50));
expect(fetchSpy).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("renders a loading indicator while /v1/submit-prompt is pending", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
let resolveFn!: (value: Response) => void;
const pending = new Promise<Response>(r => { resolveFn = r; });
vi.stubGlobal("fetch", vi.fn(() => pending));
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />, mp));
const textarea = screen.getByRole("textbox");
await user.type(textarea, "What is 2+2?");
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await waitFor(() => {
const loading = screen.queryByTestId("loading-indicator") ??
screen.queryByRole("progressbar") ??
screen.queryByText(/loading|pending|…|\.\.\.$/i);
expect(loading).not.toBeNull();
});
resolveFn(new Response("{}", { status: 502 }));
vi.unstubAllGlobals();
});
it("renders an error message when /v1/submit-prompt rejects", async () => {
const user = userEvent.setup();
const mp = makeMockMarketplace();
vi.stubGlobal("fetch", vi.fn(async () =>
new Response(
JSON.stringify({ error: "submit_prompt_failed", message: "supplier offline" }),
{ status: 502, headers: { "Content-Type": "application/json" } }
)
));
const advertRef = { txHash: "b".repeat(64), index: 0 };
render(wrap(<PromptForm advertRef={advertRef} payment_lovelace={2_000_000n} />, mp));
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello?");
const submitBtn = screen.getByRole("button", { name: /submit/i });
await user.click(submitBtn);
await waitFor(() => {
const errEl = screen.queryByRole("alert") ?? screen.queryByText(/error|failed|offline/i);
expect(errEl).not.toBeNull();
});
vi.unstubAllGlobals();
});
});
describe("<TaskHistory /> smoke", () => {
// TaskHistory now fetches /v1/indexer/escrows?buyer=<pkh> via the buyer-app
// server proxy and groups rows by posted_at. Each lifecycle (Open → Claimed
// → Submitted → terminal) shares one posted_at value, so we mock the
// indexer with a couple of fully-populated rows representing two distinct
// lifecycles in different states and assert one task-row per lifecycle.
it("fetches /v1/indexer/escrows and renders one row per distinct lifecycle (posted_at)", async () => {
const mp = makeMockMarketplace();
const buyerPkh = mp.getWalletKey().pubKeyHash;
const baseRow = (overrides: Record<string, unknown>) => ({
utxo_ref: "a".repeat(64) + "#0",
buyer_pkh: buyerPkh,
supplier_pkh: "b".repeat(56),
advert_ref: "c".repeat(64) + "#0",
capability_id: "llm.text.generate.v1",
prompt_hash: "d".repeat(64),
payment_lovelace: "2000000",
buyer_bond_lovelace: "1000000",
supplier_bond_lovelace: "1000000",
posted_at: 1700000000000,
submitted_at: null,
result_receipt_hash: null,
state: "Submitted",
created_slot: 1000,
...overrides,
});
// Lifecycle A: posted_at=1700000000000 — Open + Submitted rows (two
// chain rows in same lifecycle).
// Lifecycle B: posted_at=1700000099999 — Open + Accepted (different lifecycle).
const rows = [
baseRow({ utxo_ref: "1".repeat(64) + "#0", state: "Open", created_slot: 1000 }),
baseRow({ utxo_ref: "2".repeat(64) + "#0", state: "Submitted", created_slot: 1010 }),
baseRow({ utxo_ref: "3".repeat(64) + "#0", state: "Open", created_slot: 2000, posted_at: 1700000099999 }),
baseRow({ utxo_ref: "4".repeat(64) + "#0", state: "Accepted", created_slot: 2020, posted_at: 1700000099999 }),
];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url);
// TaskHistory now also fetches /v1/responses (the buyer-app's archive
// index) and joins by escrow_ref. Empty list is a valid response that
// means "archive disabled / no records yet" and exercises the same
// "no archive payload to render inline" code path as production.
if (url.includes("/v1/responses")) {
return new Response(JSON.stringify({ responses: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (!url.includes(`/v1/indexer/escrows?buyer=${buyerPkh}`)) {
throw new Error(`unexpected fetch: ${url}`);
}
return new Response(JSON.stringify(rows), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}));
render(wrap(<TaskHistory />, mp));
await waitFor(() => {
const taskRows = document.querySelectorAll("[data-testid='task-row']");
expect(taskRows.length).toBe(2); // 2 distinct lifecycles
});
vi.unstubAllGlobals();
});
it("renders an empty-state when the indexer returns no rows", async () => {
const mp = makeMockMarketplace();
vi.stubGlobal("fetch", vi.fn(async () =>
new Response(JSON.stringify([]), { status: 200, headers: { "Content-Type": "application/json" } })
));
render(wrap(<TaskHistory />, mp));
await waitFor(() => {
expect(screen.getByTestId("task-history-empty")).toBeTruthy();
});
vi.unstubAllGlobals();
});
});
describe("<Wallet /> smoke", () => {
it("renders the configured walletKey.address", () => {
const mp = makeMockMarketplace();
render(wrap(<Wallet />, mp));
// The buyer's bech32 address must appear somewhere in the rendered output
expect(screen.getByText(new RegExp(buyer.address, "i"))).toBeTruthy();
});
});
describe("<SupplierCard /> smoke", () => {
it("renders the supplier model and price", () => {
const supplier = makeSampleSupplierView();
render(<SupplierCard supplier={supplier} />);
// Should display model name
expect(screen.getByText(/qwen2\.5:0\.5b/i)).toBeTruthy();
});
it("'Use' button is disabled when supplier.status is 'offline'", () => {
const offlineSupplier = makeSampleSupplierView({ status: "offline" });
render(<SupplierCard supplier={offlineSupplier} />);
const useBtn = screen.queryByRole("button", { name: /use/i });
if (useBtn) {
expect(useBtn).toBeDisabled();
} else {
// If there's a "Use" button but it's hidden/replaced, verify it cannot be clicked
const btn = document.querySelector("button");
expect(btn === null || btn.disabled).toBe(true);
}
});
it("'Use' button is enabled when supplier.status is 'free'", () => {
const freeSupplier = makeSampleSupplierView({ status: "free" });
render(<SupplierCard supplier={freeSupplier} />);
const useBtn = screen.getByRole("button", { name: /use/i });
expect(useBtn).not.toBeDisabled();
});
});