Kutip — Testing Guide
Last updated: 2026-05-17 · 56 Foundry passing · 143 Vitest unit + 6 integration green
TL;DR
| Layer | Tests | Coverage |
|---|---|---|
| Solidity (Foundry) | 56 passing (units + 4×256 fuzz runs across AttributionLedger + Escrow) | Constructor invariants + bps math + dust + conservation + yield linearity + dual-auth |
| TypeScript (Vitest unit) | 143 passing across 6 files | 100% on x402 · 100% branches on session + claim-registry · 96% branches on orcid-oauth |
| Integration | 6 scenarios for /api/claim (real handler + nock + ethers) | Full flow happy + 5 negative paths |
| CI | GitHub Actions workflow | Both jobs gated on PR |
Convention
describe("methodName", () => {
describe("positive", () => {
it("does the happy thing", () => { ... });
});
describe("negative", () => {
it("rejects malformed input", () => { ... });
});
describe("edge cases", () => {
it("handles boundary X", () => { ... });
});
});
Negative tests cover all validation logic. Financial functions get property-based tests via fast-check plus hand-crafted boundary cases. Unit tests are isolated London-school (mock collaborators with vi.mock). Integration tests use real subsystems where cheap (ethers Wallet, in-memory cache) + nock-mocked external HTTP.
Run
Foundry (works now)
cd contracts
forge test # all 56 tests
forge test -vvv # with traces
forge test --match-test testFuzz_ # only fuzz suites
forge coverage # coverage report
Vitest (after install)
If you hit pnpm symlink issues on Windows (we did), nuke + reinstall:
cd web
rm -rf node_modules pnpm-lock.yaml
pnpm install
pnpm test # both unit + integration
pnpm test:unit # only fast unit tests
pnpm test:integration # mid-scope flows (slower)
pnpm test:coverage # with HTML coverage report
Output goes to web/coverage/index.html.
Test Files
Foundry (contracts/test/)
| File | Suite | Tests | Highlights |
|---|---|---|---|
AttributionLedger.t.sol | AttributionLedgerTest | 6 | Split correctness, dup query revert, weight mismatch, empty cites, stats, dual-auth (operator + agent) |
AttributionLedger.fuzz.t.sol | AttributionLedgerFuzzTest | 7 + 2×256 fuzz | Conservation property · two-author split fuzz · dust payment · weight boundary 9999/10001 · constructor InvalidSplit · zero-weight allowed |
UnclaimedYieldEscrow.t.sol | UnclaimedYieldEscrowTest | 8 | Existing |
UnclaimedYieldEscrow.fuzz.t.sol | UnclaimedYieldEscrowFuzzTest | 7 + 2×256 fuzz | Yield linearity fuzz · dust principal yield=0 · 1 USDC × 5% yields exactly 5e16 wei · post-claim freeze · double-claim revert · operator gating |
BountyMarket.t.sol | BountyMarketTest | 7 | Existing |
AgentReputation.t.sol | AgentReputationTest | 7 | Existing |
AgentRegistry8004.t.sol | AgentRegistry8004Test | 6 | Existing |
Vitest (web/test/unit/ + web/test/integration/)
| File | Subject | Test count |
|---|---|---|
unit/orcid-oauth.test.ts | HMAC cookie sign/verify, OAuth URL builder, isOrcidOauthEnabled, redirectUrl | 33 |
unit/x402.test.ts | Payment header decode (5 negative cases), buildPaymentRequired, nock-mocked settleWithFacilitator, isDemoMode | 20 |
unit/session.test.ts | Real ethers Wallet + verifyIntent + checkSpendStateless cap enforcement + UTC daily reset | 16 |
unit/kitepass.test.ts | buildKutipRules shape, KITEPASS_ADDRESSES, uint160 boundary | 15 |
unit/agent.financial.test.ts ★ | evenWeights, normalize, flattenCitationsForContract, buildCitations — fast-check property tests asserting weight conservation invariant (sum=10000) | 24 |
unit/claim-registry.test.ts | ORCID normalisation, claim message determinism + parseClaimMessageExpiry, orcidHash collision-resistance, cache lifecycle | 30 |
integration/api-claim.test.ts | Full /api/claim POST flow with real handler + nock ORCID + ethers signing + signed OAuth cookie | 6 |
Coverage Gates
Defined in web/vitest.config.ts — per-file thresholds reflecting actually-tested modules:
"lib/x402.ts": { lines: 100, branches: 100, functions: 100 } // financial edge
"lib/orcid-oauth.ts": { lines: 70, branches: 90, functions: 85 } // HMAC auth
"lib/session.ts": { lines: 50, branches: 100, functions: 40 } // spending caps (financial)
"lib/claim-registry.ts": { lines: 50, branches: 100, functions: 75 } // identity binding
Achieved (verified 2026-05-17):
x402.ts 100% / 100% / 100% / 100%
orcid-oauth.ts 71% / 96% / 88% / 71%
session.ts 61% / 100% / 46% / 61%
claim-registry.ts 60% / 100% / 79% / 60%
kitepass.ts 21% / 100% / 12% / 21%
Note: lines/funcs low on session/claim-registry because both files contain on-chain RPC paths only exercised by integration tests (Anvil-real), not unit tests. Branches at 100% is the strict invariant — every conditional in the financial logic is tested.
CI fails if coverage drops below these.
Property-Based Testing
Financial code uses fast-check:
fc.assert(
fc.property(
fc.array(fc.integer({ min: 1, max: 10_000_000 }), { minLength: 1, maxLength: 10 }),
(weights) => {
const out = normalize(input, papers);
const sum = Array.from(out.values()).reduce((a, b) => a + b, 0);
return sum === 10000; // INVARIANT
}
)
);
By default fast-check runs 100 random inputs per property. Override with fc.assert(prop, { numRuns: 1000 }) for high-stakes invariants.
Mocking Strategy
| Collaborator | Strategy | Why |
|---|---|---|
| OpenRouter LLM | vi.mock returning canned response | Deterministic tests |
| ORCID API | nock("https://pub.orcid.org") | HTTP boundary |
| Pieverse facilitator | nock("https://facilitator.pieverse.io") | HTTP boundary |
| Kite RPC | vi.mock("@/lib/ledger") getPublicClient | Pure code path testing |
| Bundler | nock("https://bundler-service.staging.gokite.ai") | HTTP boundary, no local stub |
| Goldsky subgraph | nock GraphQL endpoint | HTTP boundary |
| ethers Wallet | Real (deterministic) | Cheap; tests would mock too much auth |
| EIP-712 signing | Real | Crypto primitives — mocking invites false-pass |
CI
.github/workflows/test.yml:
foundryjob — installs Foundry stable + OZ + std + runsforge test -vvv+forge coveragevitestjob — pnpm install with frozen lock, typecheck, unit + coverage, integration- Coverage uploaded as artifact for review
Failure on either job blocks merge to main (when branch protection is on).
Adding New Tests
When you add a function to lib/:
- Decide: pure function or has side-effects? Pure → unit. Side-effects on subsystem → integration.
- Decide: financial (handles money, bps, share splits) → property + boundary. Logic-only → standard pos/neg/edge.
- Use the convention:
describe(method) → describe(pos|neg|edge) → it. - Negative tests must cover every validation branch. Run
pnpm test:coverageand check the HTML report for missed branches before committing.
Known Gotchas
- pnpm symlink errors on Windows: nuke
node_modules/.pnpm/andnode_modules/, thenpnpm install. If still fails, deletepnpm-lock.yamland retry. - Vitest globals off by default: we use explicit imports (
import { describe, it, expect } from "vitest") for clarity. Don't enableglobals: true— defeats purpose of London-school isolation. - Test isolation: each test file runs in its own worker. Module-level state (e.g.
claim-registry'sglobalThis.__KUTIP_CLAIMS__) must be cleared inbeforeEach— seeclaim-registry.test.tsfor pattern. - Foundry fuzz seed: change runs in
foundry.tomlto reproduce a flake (fuzz.runs = 1024). Default 256 catches most bugs in <2s.