| name | test-conventions |
| description | Exact bun:test conventions for the Key0 SDK. Covers imports, factory helpers, injectable clock, store patterns, concurrency assertions, error assertions, and mock adapter usage. |
Key0 Test Conventions
All tests use bun:test. Never use jest or vitest.
Imports
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { MockPaymentAdapter } from "../../test-utils";
import {
type AccessRequest,
Key0Error,
type PaymentProof,
type SellerConfig,
} from "../../types";
import { ChallengeEngine, type ChallengeEngineConfig } from "../challenge-engine.js";
import { TestChallengeStore, TestSeenTxStore } from "../../test-utils/stores.js";
Factory Helper Pattern
Every test file exercising ChallengeEngine defines three helpers. Never inline config objects in individual tests.
const WALLET = `0x${"ab".repeat(20)}` as `0x${string}`;
function makeConfig(overrides?: Partial<SellerConfig>): SellerConfig {
return {
agentName: "Test Agent",
agentDescription: "Test",
agentUrl: "https://agent.example.com",
providerName: "Provider",
providerUrl: "https://provider.example.com",
walletAddress: WALLET,
network: "testnet",
plans: [{ planId: "single", displayName: "Single Photo", unitAmount: "$0.10", resourceType: "photo" }],
challengeTTLSeconds: 900,
fetchResourceCredentials: async (params) => ({
token: `tok_${params.challengeId}`,
expiresAt: new Date(Date.now() + 3600 * 1000),
tokenType: "Bearer",
}),
...overrides,
};
}
function makeEngine(opts?: {
config?: Partial<SellerConfig>;
adapter?: MockPaymentAdapter;
clock?: () => number;
store?: TestChallengeStore;
seenTxStore?: TestSeenTxStore;
}) {
const adapter = opts?.adapter ?? new MockPaymentAdapter();
const store = opts?.store ?? new TestChallengeStore();
const seenTxStore = opts?.seenTxStore ?? new TestSeenTxStore();
const config = makeConfig(opts?.config);
const engineConfig: ChallengeEngineConfig = {
config,
store,
seenTxStore,
adapter,
...(opts?.clock ? { clock: opts.clock } : {}),
};
return { engine: new ChallengeEngine(engineConfig), adapter, store, seenTxStore };
}
function makeRequest(overrides?: Partial<AccessRequest>): AccessRequest {
return {
requestId: crypto.randomUUID(),
resourceId: "photo-42",
planId: "single",
clientAgentId: "agent://test-client",
...overrides,
};
}
function makeTxHash(): `0x${string}` {
const hex = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `0x${hex}` as `0x${string}`;
}
Injectable Clock for Time-Travel
Never use setTimeout or manipulate Date.now. Use the clock parameter:
let now = Date.now();
const clock = () => now;
const { engine } = makeEngine({ clock });
now += 901_000;
TestChallengeStore Pattern
When creating a store directly in a test (not via makeEngine), use TestChallengeStore from test-utils/stores.js. It has no cleanup timer and no size guard — zero configuration needed:
const store = new TestChallengeStore();
const seenTxStore = new TestSeenTxStore();
makeChallengeRecord — for store-level tests
When testing store transitions directly (not through the engine), use this helper:
function makeChallengeRecord(overrides?: Partial<ChallengeRecord>): ChallengeRecord {
return {
challengeId: crypto.randomUUID(),
requestId: crypto.randomUUID(),
clientAgentId: "agent://test",
resourceId: "photo-42",
planId: "single",
amount: "$0.10",
amountRaw: 100000n,
asset: "USDC",
chainId: 84532,
destination: `0x${"ab".repeat(20)}` as `0x${string}`,
state: "PENDING",
expiresAt: new Date(Date.now() + 900_000),
createdAt: new Date(),
...overrides,
};
}
Import ChallengeRecord from ../../types.
Concurrency Assertions
Use Promise.all + filter-Boolean. Never Promise.race or sequential calls:
const [a, b] = await Promise.all([
store.transition(id, "PENDING", "PAID", { txHash, paidAt: new Date() }),
store.transition(id, "PENDING", "EXPIRED"),
]);
expect([a, b].filter(Boolean).length).toBe(1);
const results = await Promise.all([
store.transition(id, "PENDING", "PAID"),
store.transition(id, "PENDING", "EXPIRED"),
store.transition(id, "PENDING", "CANCELLED"),
]);
expect(results.filter(Boolean).length).toBe(1);
Error Assertions
Always assert both .code and .httpStatus on Key0Error:
const err = await engine.submitProof(proof).catch((e) => e);
expect(err).toBeInstanceOf(Key0Error);
expect(err.code).toBe("CHALLENGE_NOT_FOUND");
expect(err.httpStatus).toBe(404);
MockPaymentAdapter
Control verification outcomes with setVerifyResult():
const adapter = new MockPaymentAdapter();
adapter.setVerifyResult({ success: true });
adapter.setVerifyResult({ success: false, error: "Transfer not found" });
Default is { success: true }.
Test Organization
- One
describe block per concept (state transitions, error paths, callbacks, concurrency)
- Happy path tests before error/edge cases within each
describe
- Test names describe the scenario, not the function:
- GOOD:
"returns EXPIRED error after TTL elapses"
- BAD:
"processRequest with expired challenge"