buyer: indexer-driven /tasks page, path-B TTS demo, server-side error logging
Three buyer-app changes that all landed during the same browser-debug
session and were verified end-to-end on Vector testnet:
UX-3-history — /tasks page is now indexer-driven (M1-F-5 followup)
- TaskHistory.tsx fetches /v1/indexer/escrows?buyer=<pkh> from the
buyer-app proxy, groups rows by (posted_at, supplier_pkh) into
lifecycle cards, and renders newest-first with state pill,
capability/model/price, and the tx-hash chain
- main.tsx drops the LocalStorageTaskHistoryStore (chain is the source
of truth; SDK uses MemoryTaskHistoryStore for in-flight only)
Path-B PiperTTS demo — capability-routed UI without escrow yet
- buyer/src/server.ts: POST /v1/synth-speech proxies to the openedai-
speech-min host; validates {text, voice, format, speed} and returns
audio bytes with the upstream Content-Type
- buyer/src/config.ts: TTS_PIPER_BASE_URL env (optional; absence keeps
/v1/synth-speech disabled with a clean 503)
- buyer/src/ui/components/PiperTTSForm.tsx: capability-specific form
(text + voice + format + speed slider) with inline <audio controls>
and a Download .mp3 anchor; blob URLs revoked on each submit + unmount
- buyer/src/ui/pages/Dashboard.tsx: capability registry — switch on
selected.capability_id to PromptForm vs PiperTTSForm; appends a
synthetic "PiperTTS (apex-simulation)" demo card so the capability
routing is exercisable before the on-chain TTS supplier ships
- deploy/testnet/docker-compose.buyer.yml: TTS_PIPER_BASE_URL points
at https://tts-piper.vector.testnet.apexfusion.org
Server-side error logging on /v1/submit-prompt
- buyer/src/server.ts: catch block now logs reason + message + stack
(the demo session lost ~30 minutes to a "<!DOCTYPE..." JSON-parse
error caused by Cloudflare replacing origin's clean 502+JSON with
its own HTML page; the actual upstream cause was a "wallet must
hold a pure-ADA UTxO >=5 ADA" lucid-evolution error that was
invisible because the Express handler had no logger)
Tests: 10 new for /v1/synth-speech (validation, defaults, success
shape, upstream 5xx, fetch-throw); existing buyer-ui-smoke supplier
count expectation updated for the synthetic Piper card (N+1)
This commit is contained in:
169
tests/unit/buyer-server-synth-speech.test.ts
Normal file
169
tests/unit/buyer-server-synth-speech.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* buyer-server-synth-speech.test.ts — coverage for the PiperTTS proxy
|
||||
* endpoint (POST /v1/synth-speech) added under the path-B multi-capability
|
||||
* story.
|
||||
*
|
||||
* The endpoint forwards to an injected `fetchImpl` so we never hit the
|
||||
* real openedai-speech-min host in tests. We assert:
|
||||
* - 503 when ttsPiperBaseUrl is not configured
|
||||
* - validation 400s for missing/oversized text, bad voice, bad format,
|
||||
* and out-of-range speed
|
||||
* - 200 with audio bytes + correct Content-Type when upstream succeeds
|
||||
* - 502 when upstream returns non-2xx OR throws
|
||||
* - request body forwarded to upstream as OpenAI-shape JSON (model,
|
||||
* input, voice, response_format, speed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createApp } from "../../buyer/src/server.js";
|
||||
|
||||
const baseUrl = "http://tts-piper.fake/";
|
||||
|
||||
/** Build a fetchImpl that records calls + returns a configurable response. */
|
||||
function makeStubFetch(opts: {
|
||||
status?: number;
|
||||
body?: ArrayBuffer | string;
|
||||
contentType?: string;
|
||||
throwError?: Error;
|
||||
}) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init: init ?? {} });
|
||||
if (opts.throwError) throw opts.throwError;
|
||||
const status = opts.status ?? 200;
|
||||
const body = opts.body ?? new ArrayBuffer(0);
|
||||
const contentType = opts.contentType ?? "audio/mpeg";
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe("POST /v1/synth-speech", () => {
|
||||
it("returns 503 when ttsPiperBaseUrl is not configured", async () => {
|
||||
const app = createApp({});
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech").send({
|
||||
text: "hi", voice: "nova", format: "mp3", speed: 1.0,
|
||||
});
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe("service_unavailable");
|
||||
});
|
||||
|
||||
it("rejects empty text with 400 text_required", async () => {
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech").send({ text: " " });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("text_required");
|
||||
});
|
||||
|
||||
it("rejects oversized text with 400 text_too_long", async () => {
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech").send({ text: "x".repeat(4001) });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("text_too_long");
|
||||
});
|
||||
|
||||
it("rejects unknown voice with 400 voice_invalid", async () => {
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech")
|
||||
.send({ text: "hi", voice: "not-a-voice" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("voice_invalid");
|
||||
});
|
||||
|
||||
it("rejects unknown format with 400 format_invalid", async () => {
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech")
|
||||
.send({ text: "hi", voice: "nova", format: "ogg" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("format_invalid");
|
||||
});
|
||||
|
||||
it("rejects speed below 0.5 / above 1.5 with 400 speed_out_of_range", async () => {
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl: makeStubFetch({}).fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
// NaN is not in this list because JSON.stringify({speed:NaN}) → "null",
|
||||
// which the endpoint correctly treats as "field omitted" (defaults to 1.0).
|
||||
// Out-of-range numerics + non-numeric strings are the real attack surface.
|
||||
for (const bad of [0.4, 1.6, "fast"]) {
|
||||
const res = await request(app).post("/v1/synth-speech")
|
||||
.send({ text: "hi", voice: "nova", format: "mp3", speed: bad });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("speed_out_of_range");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 200 audio bytes with upstream Content-Type on success", async () => {
|
||||
// 8 dummy bytes — supertest will deliver them as a Buffer, length verifies
|
||||
// that the proxy forwarded the body unchanged.
|
||||
const audio = new Uint8Array([0xff, 0xfb, 0x90, 0x44, 0, 0, 0, 0]);
|
||||
const { fetchImpl, calls } = makeStubFetch({
|
||||
body: audio.buffer,
|
||||
contentType: "audio/mpeg",
|
||||
});
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech")
|
||||
.send({ text: "hello", voice: "nova", format: "mp3", speed: 1.0 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toMatch(/audio\/mpeg/);
|
||||
expect(res.body).toBeInstanceOf(Buffer);
|
||||
expect(res.body.length).toBe(8);
|
||||
|
||||
// Verify the OpenAI-shape body was forwarded exactly.
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe("http://tts-piper.fake/v1/audio/speech"); // trailing-slash stripped
|
||||
const sent = JSON.parse(String(calls[0].init.body));
|
||||
expect(sent).toEqual({
|
||||
model: "tts-1",
|
||||
input: "hello",
|
||||
voice: "nova",
|
||||
response_format: "mp3",
|
||||
speed: 1.0,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults voice=nova, format=mp3, speed=1.0 when fields are omitted", async () => {
|
||||
const { fetchImpl, calls } = makeStubFetch({ body: new ArrayBuffer(1) });
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech").send({ text: "hi" });
|
||||
expect(res.status).toBe(200);
|
||||
const sent = JSON.parse(String(calls[0].init.body));
|
||||
expect(sent.voice).toBe("nova");
|
||||
expect(sent.response_format).toBe("mp3");
|
||||
expect(sent.speed).toBe(1.0);
|
||||
});
|
||||
|
||||
it("returns 502 tts_upstream_error on non-2xx upstream", async () => {
|
||||
const { fetchImpl } = makeStubFetch({
|
||||
status: 500,
|
||||
body: "internal piper error",
|
||||
contentType: "text/plain",
|
||||
});
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech").send({ text: "hi" });
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.error).toBe("tts_upstream_error");
|
||||
expect(res.body.message).toContain("500");
|
||||
});
|
||||
|
||||
it("returns 502 tts_unreachable on fetch throw", async () => {
|
||||
const { fetchImpl } = makeStubFetch({ throwError: new Error("ENOTFOUND") });
|
||||
const app = createApp({ ttsPiperBaseUrl: baseUrl, fetchImpl });
|
||||
const { default: request } = await import("supertest");
|
||||
const res = await request(app).post("/v1/synth-speech").send({ text: "hi" });
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.error).toBe("tts_unreachable");
|
||||
expect(res.body.message).toContain("ENOTFOUND");
|
||||
});
|
||||
});
|
||||
@@ -122,12 +122,18 @@ describe("<Dashboard /> smoke", () => {
|
||||
makeSampleSupplierView({ supplier_pkh: "b".repeat(56) }),
|
||||
]);
|
||||
render(wrap(<Dashboard />, mp));
|
||||
// Wait for async discover to complete and cards to render
|
||||
// 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(() => {
|
||||
// Each card should render something with the supplier_pkh visible or a test-id
|
||||
const cards = document.querySelectorAll("[data-testid='supplier-card']");
|
||||
expect(cards.length).toBe(2);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,14 +243,68 @@ describe("<PromptForm /> smoke", () => {
|
||||
});
|
||||
|
||||
describe("<TaskHistory /> smoke", () => {
|
||||
it("calls getTaskHistory() and renders a row for each task", async () => {
|
||||
// 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();
|
||||
vi.spyOn(mp, "getTaskHistory").mockReturnValue(ALL_SAMPLE_TASK_RECORDS);
|
||||
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);
|
||||
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 rows = document.querySelectorAll("[data-testid='task-row']");
|
||||
expect(rows.length).toBe(ALL_SAMPLE_TASK_RECORDS.length);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user