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)
474 lines
18 KiB
TypeScript
474 lines
18 KiB
TypeScript
/**
|
|
* supplier-job-runner.test.ts — RED phase tests for runChatJob
|
|
*
|
|
* M1-F-async-chat RED phase — Caroline, 2026-04-28
|
|
*
|
|
* Tests the background job runner:
|
|
* Ollama → receipt → Submit → jobs.complete / jobs.fail → lock release
|
|
*
|
|
* All external side-effects are mocked. Chain, Ollama, and state are injected.
|
|
* No real Ogmios / Ollama calls.
|
|
*
|
|
* ORDERING CONTRACT (documented, not silently assumed):
|
|
* jobs.setRunning MUST be called before callOllama.
|
|
* If Catherine implements it in the opposite order, the ordering test will
|
|
* fail and must be reported rather than silently corrected.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
|
import { SupplierState } from "../../supplier/src/state.js";
|
|
import { JobStore } from "../../supplier/src/jobs.js";
|
|
import type { JobStore as JobStoreType } from "../../supplier/src/jobs.js";
|
|
import { runChatJob } from "../../supplier/src/jobRunner.js";
|
|
import type { RunChatJobParams } from "../../supplier/src/jobRunner.js";
|
|
import * as ollamaMod from "../../supplier/src/ollama.js";
|
|
import { buildSampleConfig } from "../fixtures/supplier-side/sample-config.js";
|
|
import { buildSupplierWalletKey, SUPPLIER_PKH } from "../fixtures/supplier-side/wallet-keys.js";
|
|
import {
|
|
ESCROW_TX_HASH,
|
|
TEST_MODEL,
|
|
TEST_MESSAGES,
|
|
PROMPT_HASH,
|
|
POSTED_AT,
|
|
DELIVER_BY,
|
|
PAYMENT_LOVELACE,
|
|
BUYER_BOND,
|
|
SUPPLIER_BOND,
|
|
CAPABILITY_ID,
|
|
REQUEST_SPEC_HASH,
|
|
} from "../fixtures/supplier-side/sample-escrow-state.js";
|
|
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
|
|
import { encodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
|
|
import type { OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
|
|
|
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
const ESCROW_REF_STR = `${ESCROW_TX_HASH}#0`;
|
|
const CLAIMED_TX_HASH = "1".repeat(64);
|
|
const CLAIMED_REF: OutputReference = { txHash: CLAIMED_TX_HASH, index: 0 };
|
|
const SUBMIT_TX_HASH = "2".repeat(64);
|
|
|
|
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
const HEX64_RE = /^[0-9a-f]{64}$/i;
|
|
const HEX128_RE = /^[0-9a-f]{128}$/i;
|
|
|
|
function buildAdvert(): AdvertDatum {
|
|
return {
|
|
supplier_pkh: SUPPLIER_PKH,
|
|
capability_id: CAPABILITY_ID,
|
|
model: TEST_MODEL,
|
|
max_output_tokens: 512,
|
|
max_processing_ms: 60_000,
|
|
price_lovelace: 2_000_000n,
|
|
supplier_bond_lovelace: 1_000_000n,
|
|
buyer_bond_lovelace: 1_000_000n,
|
|
endpoint_url: "http://supplier.example:8080",
|
|
detail_uri: "ipfs://QmTest",
|
|
detail_hash: "a".repeat(64),
|
|
advertised_at: POSTED_AT,
|
|
status: "Active",
|
|
};
|
|
}
|
|
|
|
function buildEscrowDatum(): EscrowDatum {
|
|
return {
|
|
buyer_pkh: "1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
|
supplier_pkh: SUPPLIER_PKH,
|
|
advert_ref: { txHash: "b".repeat(64), index: 0 },
|
|
capability_id: CAPABILITY_ID,
|
|
request_spec_hash: REQUEST_SPEC_HASH,
|
|
prompt_hash: PROMPT_HASH,
|
|
payment_lovelace: PAYMENT_LOVELACE,
|
|
buyer_bond_lovelace: BUYER_BOND,
|
|
supplier_bond_lovelace: SUPPLIER_BOND,
|
|
deliver_by: DELIVER_BY,
|
|
posted_at: POSTED_AT,
|
|
submitted_at: null,
|
|
result_receipt_hash: null,
|
|
state: "Claimed",
|
|
};
|
|
}
|
|
|
|
function buildOllamaOkResult() {
|
|
return {
|
|
content: "I am a helpful assistant.",
|
|
prompt_tokens: 12,
|
|
completion_tokens: 48,
|
|
wallclock_ms: 3200,
|
|
};
|
|
}
|
|
|
|
function makeParams(
|
|
chain: MockChainProvider,
|
|
state: SupplierState,
|
|
jobs: JobStoreType,
|
|
jobId: string,
|
|
): RunChatJobParams {
|
|
return {
|
|
deps: {
|
|
chain,
|
|
state,
|
|
config: buildSampleConfig(),
|
|
supplierKey: buildSupplierWalletKey(),
|
|
jobs,
|
|
},
|
|
jobId,
|
|
escrowRef: ESCROW_REF_STR,
|
|
claimedRef: CLAIMED_REF,
|
|
advert: buildAdvert(),
|
|
escrowDatum: buildEscrowDatum(),
|
|
requestBody: { messages: TEST_MESSAGES },
|
|
};
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function mockOllamaOk() {
|
|
return vi.spyOn(ollamaMod, "callOllama").mockResolvedValue(buildOllamaOkResult());
|
|
}
|
|
|
|
function mockOllamaFail(message = "Ollama returned HTTP 500") {
|
|
return vi.spyOn(ollamaMod, "callOllama").mockRejectedValue(
|
|
new ollamaMod.OllamaError("ollama_failure", message),
|
|
);
|
|
}
|
|
|
|
// ─── Happy path ───────────────────────────────────────────────────────────────
|
|
|
|
describe("runChatJob — happy path", () => {
|
|
let chain: MockChainProvider;
|
|
let state: SupplierState;
|
|
let jobs: JobStore;
|
|
|
|
beforeEach(() => {
|
|
chain = new MockChainProvider();
|
|
chain.advanceSlot(1_000);
|
|
state = new SupplierState();
|
|
state.tryAcquire(ESCROW_REF_STR);
|
|
jobs = new JobStore();
|
|
mockOllamaOk();
|
|
// Mock submitTx to return deterministic hashes
|
|
vi.spyOn(chain, "submitTx").mockResolvedValue(SUBMIT_TX_HASH);
|
|
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
|
|
// Seed the claimedRef UTxO so buildSubmitTx's chain.queryUtxo(escrowRef)
|
|
// can find it once Catherine flips the runner back to buildSubmitTx.
|
|
// state="Claimed", submitted_at=null, result_receipt_hash=null per spec.
|
|
chain.seed({
|
|
ref: CLAIMED_REF,
|
|
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
|
|
lovelace: 4_000_000n,
|
|
assets: {},
|
|
datumHex: encodeEscrowDatum(buildEscrowDatum()),
|
|
scriptRef: null,
|
|
});
|
|
// Mock awaitTx to resolve immediately after successful submitTx.
|
|
// Without this mock, MockChainProvider.awaitTx polls for SUBMIT_TX_HASH
|
|
// which is never added to knownTxs (submitTx is mocked), causing a 200ms
|
|
// hang. Once Catherine flips the runner to buildSubmitTx, the real
|
|
// chain.submitTx will add the hash to knownTxs and awaitTx finds it;
|
|
// this spy then becomes a no-op override — safe to leave in.
|
|
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
|
|
it("calls setRunning before callOllama (ordering contract)", async () => {
|
|
// RED — throws "not implemented"
|
|
// ORDERING CONTRACT: setRunning must precede callOllama.
|
|
// If Catherine inverts this order, the test fails and must be flagged.
|
|
const callOrder: string[] = [];
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
|
|
const setRunningSpy = vi.spyOn(jobs, "setRunning").mockImplementation((id) => {
|
|
callOrder.push(`setRunning:${id}`);
|
|
});
|
|
const ollamaSpy = vi.spyOn(ollamaMod, "callOllama").mockImplementation(async () => {
|
|
callOrder.push("callOllama");
|
|
return buildOllamaOkResult();
|
|
});
|
|
const completeSpy = vi.spyOn(jobs, "complete").mockImplementation(() => {});
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
expect(callOrder.indexOf(`setRunning:${jobId}`)).toBeLessThan(
|
|
callOrder.indexOf("callOllama"),
|
|
);
|
|
|
|
setRunningSpy.mockRestore();
|
|
ollamaSpy.mockRestore();
|
|
completeSpy.mockRestore();
|
|
});
|
|
|
|
it("calls jobs.complete with a payload containing choices, usage, receipt, receipt_signature", async () => {
|
|
// RED — throws "not implemented"
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
const completeSpy = vi.spyOn(jobs, "complete");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
expect(completeSpy).toHaveBeenCalledOnce();
|
|
const [calledJobId, payload] = completeSpy.mock.calls[0];
|
|
expect(calledJobId).toBe(jobId);
|
|
expect(Array.isArray(payload.choices)).toBe(true);
|
|
expect(payload.choices[0].message.role).toBe("assistant");
|
|
expect(typeof payload.choices[0].message.content).toBe("string");
|
|
expect(payload.choices[0].message.content.length).toBeGreaterThan(0);
|
|
expect(typeof payload.usage.prompt_tokens).toBe("number");
|
|
expect(typeof payload.usage.completion_tokens).toBe("number");
|
|
expect(typeof payload.usage.total_tokens).toBe("number");
|
|
expect(payload.receipt).toBeTruthy();
|
|
expect(typeof payload.receipt_signature).toBe("string");
|
|
});
|
|
|
|
it("receipt contains all 8 required fields with correct types and values", async () => {
|
|
// RED — throws "not implemented"
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
const completeSpy = vi.spyOn(jobs, "complete");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
const receipt = completeSpy.mock.calls[0][1].receipt as Record<string, unknown>;
|
|
// 8 fields: prompt_hash, response_hash, model, prompt_tokens,
|
|
// completion_tokens, wallclock_ms, supplier_pkh, escrow_ref
|
|
expect(typeof receipt.prompt_hash).toBe("string");
|
|
expect(HEX64_RE.test(receipt.prompt_hash as string)).toBe(true);
|
|
expect(receipt.prompt_hash).toBe(PROMPT_HASH);
|
|
|
|
expect(typeof receipt.response_hash).toBe("string");
|
|
expect(HEX64_RE.test(receipt.response_hash as string)).toBe(true);
|
|
|
|
expect(receipt.model).toBe(TEST_MODEL);
|
|
|
|
expect(typeof receipt.prompt_tokens).toBe("number");
|
|
expect(receipt.prompt_tokens).toBe(12);
|
|
|
|
expect(typeof receipt.completion_tokens).toBe("number");
|
|
expect(receipt.completion_tokens).toBe(48);
|
|
|
|
expect(typeof receipt.wallclock_ms).toBe("number");
|
|
// wallclock_ms comes from Ollama result, not total job time
|
|
expect(receipt.wallclock_ms).toBe(3200);
|
|
|
|
expect(receipt.supplier_pkh).toBe(SUPPLIER_PKH);
|
|
expect(receipt.escrow_ref).toBe(ESCROW_REF_STR);
|
|
});
|
|
|
|
it("receipt_signature is a 128-char hex string (64-byte Ed25519)", async () => {
|
|
// RED — throws "not implemented"
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
const completeSpy = vi.spyOn(jobs, "complete");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
const sig = completeSpy.mock.calls[0][1].receipt_signature;
|
|
expect(HEX128_RE.test(sig)).toBe(true);
|
|
});
|
|
|
|
it("wallclock_ms in receipt equals the wallclock_ms from Ollama (not total job time)", async () => {
|
|
// RED — throws "not implemented"
|
|
// The receipt's wallclock_ms must be inference.wallclock_ms, not wall clock
|
|
// elapsed for the whole runChatJob invocation.
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
const completeSpy = vi.spyOn(jobs, "complete");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
const receipt = completeSpy.mock.calls[0][1].receipt as Record<string, unknown>;
|
|
// Our mock returns wallclock_ms = 3200; that must be what ends up in receipt
|
|
expect(receipt.wallclock_ms).toBe(3200);
|
|
});
|
|
|
|
it("releases supplier lock on success (try/finally)", async () => {
|
|
// RED — throws "not implemented"
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
expect(state.snapshot().status).toBe("working");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
expect(state.snapshot().status).toBe("free");
|
|
});
|
|
});
|
|
|
|
// ─── Ollama failure ───────────────────────────────────────────────────────────
|
|
|
|
describe("runChatJob — Ollama failure", () => {
|
|
let chain: MockChainProvider;
|
|
let state: SupplierState;
|
|
let jobs: JobStore;
|
|
|
|
beforeEach(() => {
|
|
chain = new MockChainProvider();
|
|
chain.advanceSlot(1_000);
|
|
state = new SupplierState();
|
|
state.tryAcquire(ESCROW_REF_STR);
|
|
jobs = new JobStore();
|
|
vi.spyOn(chain, "submitTx").mockResolvedValue(SUBMIT_TX_HASH);
|
|
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
|
|
// Seed claimedRef and mock awaitTx for Catherine's buildSubmitTx flip.
|
|
// Ollama failure tests don't reach Submit, so these are defensive fixtures.
|
|
chain.seed({
|
|
ref: CLAIMED_REF,
|
|
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
|
|
lovelace: 4_000_000n,
|
|
assets: {},
|
|
datumHex: encodeEscrowDatum(buildEscrowDatum()),
|
|
scriptRef: null,
|
|
});
|
|
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
|
|
it("calls jobs.fail with httpStatus=502, reason=ollama_failure on Ollama error", async () => {
|
|
// RED — throws "not implemented"
|
|
mockOllamaFail("Ollama returned HTTP 500: Internal Server Error");
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
const failSpy = vi.spyOn(jobs, "fail");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
expect(failSpy).toHaveBeenCalledOnce();
|
|
const [calledJobId, failure] = failSpy.mock.calls[0];
|
|
expect(calledJobId).toBe(jobId);
|
|
expect(failure.httpStatus).toBe(502);
|
|
expect(failure.reason).toBe("ollama_failure");
|
|
expect(typeof failure.message).toBe("string");
|
|
});
|
|
|
|
it("releases supplier lock after Ollama failure (try/finally)", async () => {
|
|
// RED — throws "not implemented"
|
|
mockOllamaFail();
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
expect(state.snapshot().status).toBe("free");
|
|
});
|
|
|
|
it("does not throw — resolves after Ollama failure", async () => {
|
|
// RED — throws "not implemented"
|
|
mockOllamaFail();
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
await expect(runChatJob(makeParams(chain, state, jobs, jobId))).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── Submit tx failure ────────────────────────────────────────────────────────
|
|
|
|
describe("runChatJob — Submit tx failure", () => {
|
|
let chain: MockChainProvider;
|
|
let state: SupplierState;
|
|
let jobs: JobStore;
|
|
|
|
beforeEach(() => {
|
|
chain = new MockChainProvider();
|
|
chain.advanceSlot(1_000);
|
|
state = new SupplierState();
|
|
state.tryAcquire(ESCROW_REF_STR);
|
|
jobs = new JobStore();
|
|
mockOllamaOk();
|
|
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
|
|
// Seed claimedRef UTxO. In Submit-failure tests, submitTx is mocked to
|
|
// reject so awaitTx is never reached; these fixtures are defensive for
|
|
// Catherine's buildSubmitTx flip.
|
|
chain.seed({
|
|
ref: CLAIMED_REF,
|
|
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
|
|
lovelace: 4_000_000n,
|
|
assets: {},
|
|
datumHex: encodeEscrowDatum(buildEscrowDatum()),
|
|
scriptRef: null,
|
|
});
|
|
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
|
|
it("calls jobs.fail with httpStatus=502, reason=submit_failed when Submit tx throws", async () => {
|
|
// RED — throws "not implemented"
|
|
vi.spyOn(chain, "submitTx").mockRejectedValue(new Error("phase-2 script rejected"));
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
const failSpy = vi.spyOn(jobs, "fail");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
expect(failSpy).toHaveBeenCalledOnce();
|
|
const [calledJobId, failure] = failSpy.mock.calls[0];
|
|
expect(calledJobId).toBe(jobId);
|
|
expect(failure.httpStatus).toBe(502);
|
|
expect(failure.reason).toBe("submit_failed");
|
|
});
|
|
|
|
it("releases lock after Submit tx failure", async () => {
|
|
// RED — throws "not implemented"
|
|
vi.spyOn(chain, "submitTx").mockRejectedValue(new Error("phase-2 script rejected"));
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
expect(state.snapshot().status).toBe("free");
|
|
});
|
|
|
|
it("does not throw — resolves after Submit tx failure", async () => {
|
|
// RED — throws "not implemented"
|
|
vi.spyOn(chain, "submitTx").mockRejectedValue(new Error("rejected"));
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
await expect(runChatJob(makeParams(chain, state, jobs, jobId))).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── awaitTx timeout on Submit ────────────────────────────────────────────────
|
|
|
|
describe("runChatJob — awaitTx timeout on Submit", () => {
|
|
let chain: MockChainProvider;
|
|
let state: SupplierState;
|
|
let jobs: JobStore;
|
|
|
|
beforeEach(() => {
|
|
chain = new MockChainProvider();
|
|
chain.advanceSlot(1_000);
|
|
state = new SupplierState();
|
|
state.tryAcquire(ESCROW_REF_STR);
|
|
jobs = new JobStore();
|
|
mockOllamaOk();
|
|
vi.spyOn(chain, "submitTx").mockResolvedValue(SUBMIT_TX_HASH);
|
|
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
|
|
// Seed claimedRef UTxO for buildSubmitTx flip. awaitTx is mocked per-test
|
|
// to reject (submit_timeout scenario); do NOT add a resolve-mock here.
|
|
chain.seed({
|
|
ref: CLAIMED_REF,
|
|
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
|
|
lovelace: 4_000_000n,
|
|
assets: {},
|
|
datumHex: encodeEscrowDatum(buildEscrowDatum()),
|
|
scriptRef: null,
|
|
});
|
|
// NOTE: awaitTx is intentionally NOT mocked here at the describe level —
|
|
// individual tests mock it to reject with a timeout error.
|
|
// This is the only describe block that keeps the submit_timeout RED until
|
|
// Catherine flips the 60_000ms budget (see Catherine's flip checklist).
|
|
});
|
|
|
|
afterEach(() => { vi.restoreAllMocks(); });
|
|
|
|
it("calls jobs.fail with reason=submit_timeout when awaitTx rejects", async () => {
|
|
// RED — throws "not implemented"
|
|
vi.spyOn(chain, "awaitTx").mockRejectedValue(new Error("awaitTx timed out"));
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
const failSpy = vi.spyOn(jobs, "fail");
|
|
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
|
|
expect(failSpy).toHaveBeenCalledOnce();
|
|
const [calledJobId, failure] = failSpy.mock.calls[0];
|
|
expect(calledJobId).toBe(jobId);
|
|
expect(failure.httpStatus).toBe(502);
|
|
expect(failure.reason).toBe("submit_timeout");
|
|
});
|
|
|
|
it("releases lock after awaitTx timeout", async () => {
|
|
// RED — throws "not implemented"
|
|
vi.spyOn(chain, "awaitTx").mockRejectedValue(new Error("timeout"));
|
|
const jobId = jobs.create(ESCROW_REF_STR);
|
|
await runChatJob(makeParams(chain, state, jobs, jobId));
|
|
expect(state.snapshot().status).toBe("free");
|
|
});
|
|
});
|