Kutip — Testing Guide

Last updated: 2026-05-17 · 56 Foundry passing · 143 Vitest unit + 6 integration green


TL;DR

LayerTestsCoverage
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 files100% on x402 · 100% branches on session + claim-registry · 96% branches on orcid-oauth
Integration6 scenarios for /api/claim (real handler + nock + ethers)Full flow happy + 5 negative paths
CIGitHub Actions workflowBoth 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/)

FileSuiteTestsHighlights
AttributionLedger.t.solAttributionLedgerTest6Split correctness, dup query revert, weight mismatch, empty cites, stats, dual-auth (operator + agent)
AttributionLedger.fuzz.t.solAttributionLedgerFuzzTest7 + 2×256 fuzzConservation property · two-author split fuzz · dust payment · weight boundary 9999/10001 · constructor InvalidSplit · zero-weight allowed
UnclaimedYieldEscrow.t.solUnclaimedYieldEscrowTest8Existing
UnclaimedYieldEscrow.fuzz.t.solUnclaimedYieldEscrowFuzzTest7 + 2×256 fuzzYield linearity fuzz · dust principal yield=0 · 1 USDC × 5% yields exactly 5e16 wei · post-claim freeze · double-claim revert · operator gating
BountyMarket.t.solBountyMarketTest7Existing
AgentReputation.t.solAgentReputationTest7Existing
AgentRegistry8004.t.solAgentRegistry8004Test6Existing

Vitest (web/test/unit/ + web/test/integration/)

FileSubjectTest count
unit/orcid-oauth.test.tsHMAC cookie sign/verify, OAuth URL builder, isOrcidOauthEnabled, redirectUrl33
unit/x402.test.tsPayment header decode (5 negative cases), buildPaymentRequired, nock-mocked settleWithFacilitator, isDemoMode20
unit/session.test.tsReal ethers Wallet + verifyIntent + checkSpendStateless cap enforcement + UTC daily reset16
unit/kitepass.test.tsbuildKutipRules shape, KITEPASS_ADDRESSES, uint160 boundary15
unit/agent.financial.test.tsevenWeights, normalize, flattenCitationsForContract, buildCitationsfast-check property tests asserting weight conservation invariant (sum=10000)24
unit/claim-registry.test.tsORCID normalisation, claim message determinism + parseClaimMessageExpiry, orcidHash collision-resistance, cache lifecycle30
integration/api-claim.test.tsFull /api/claim POST flow with real handler + nock ORCID + ethers signing + signed OAuth cookie6

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

CollaboratorStrategyWhy
OpenRouter LLMvi.mock returning canned responseDeterministic tests
ORCID APInock("https://pub.orcid.org")HTTP boundary
Pieverse facilitatornock("https://facilitator.pieverse.io")HTTP boundary
Kite RPCvi.mock("@/lib/ledger") getPublicClientPure code path testing
Bundlernock("https://bundler-service.staging.gokite.ai")HTTP boundary, no local stub
Goldsky subgraphnock GraphQL endpointHTTP boundary
ethers WalletReal (deterministic)Cheap; tests would mock too much auth
EIP-712 signingRealCrypto primitives — mocking invites false-pass

CI

.github/workflows/test.yml:

  • foundry job — installs Foundry stable + OZ + std + runs forge test -vvv + forge coverage
  • vitest job — 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/:

  1. Decide: pure function or has side-effects? Pure → unit. Side-effects on subsystem → integration.
  2. Decide: financial (handles money, bps, share splits) → property + boundary. Logic-only → standard pos/neg/edge.
  3. Use the convention: describe(method) → describe(pos|neg|edge) → it.
  4. Negative tests must cover every validation branch. Run pnpm test:coverage and check the HTML report for missed branches before committing.

Known Gotchas

  • pnpm symlink errors on Windows: nuke node_modules/.pnpm/ and node_modules/, then pnpm install. If still fails, delete pnpm-lock.yaml and retry.
  • Vitest globals off by default: we use explicit imports (import { describe, it, expect } from "vitest") for clarity. Don't enable globals: true — defeats purpose of London-school isolation.
  • Test isolation: each test file runs in its own worker. Module-level state (e.g. claim-registry's globalThis.__KUTIP_CLAIMS__) must be cleared in beforeEach — see claim-registry.test.ts for pattern.
  • Foundry fuzz seed: change runs in foundry.toml to reproduce a flake (fuzz.runs = 1024). Default 256 catches most bugs in <2s.