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)
451 lines
18 KiB
TypeScript
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");
|
|
});
|
|
});
|