Test Cases
Automated test suites for the Verifluence REST API and HTLC smart contract. Each case maps to a use case and has a clear setup → action → expected triple suitable for implementation as a Vitest integration test.
Scope: HTTP layer + DB side-effects. Smart-contract fields are opaque strings (the API never makes RPC calls). Pusher is silenced via empty PUSHER_APP_ID.
Use-Case ↔ Test-Case Correspondence
Two-way matrix of every defined use case against its test coverage.
Legend: ✅ covered · ⚠️ partial · — not tested
Core flow (Tier 1 — fully automated)
| Use Case | Title | Test ID | File | Assertion type |
|---|---|---|---|---|
| UC-1.3 | Operator Sign In | TC-AUTH-01 | tc-auth.test.ts | Happy path |
| UC-2.2 | Edit Campaign — terms frozen once funded | TC-CAMP-01 | tc-camp.test.ts | Integrity guard (409) |
| UC-2.2 | Edit Campaign — status transition allowed | TC-CAMP-02 | tc-camp.test.ts | State-transition |
| UC-2.2 | Edit Campaign — draft edit allowed | TC-CAMP-03 | tc-camp.test.ts | Happy path |
| UC-4.4 | Send Offer to Streamer (Operator-initiated) — create | TC-OFFER-01 | tc-offer.test.ts | Happy path |
| UC-4.2 | Accept Offer → Open Negotiation (atomicity) | TC-OFFER-02 | tc-offer.test.ts | Race-condition regression |
| UC-4.3 | Decline Offer | TC-OFFER-03 | tc-offer.test.ts | Happy path |
| UC-3.4 | Revoke-decline — blocked by active offer | TC-OFFER-04 | tc-offer.test.ts | Constraint guard regression |
| UC-3.4 | Revoke-decline — succeeds when no conflict | TC-OFFER-05 | tc-offer.test.ts | Happy path |
| UC-3.4 | Withdraw own pending offer | TC-OFFER-06 | tc-offer.test.ts | Happy path |
| UC-3.4 | Withdraw blocked for non-initiator | TC-OFFER-07 | tc-offer.test.ts | Auth guard (403) |
| UC-4B.1 | Propose or Approve a Term (auto-agree) | TC-NEG-01 | tc-neg.test.ts | Happy path |
| UC-4B.3 | Fund Deal — budget cap exceeded | TC-NEG-02 | tc-neg.test.ts | Guard (409) |
| UC-4B.3 | Fund Deal — within budget cap | TC-NEG-03 | tc-neg.test.ts | Happy path |
| UC-4B.3 | Fund Deal — wallet address snapshotted | TC-NEG-04 | tc-neg.test.ts | Data-integrity regression |
| UC-4B.2 | Cancel Negotiation → offer withdrawn | TC-NEG-05 | tc-neg.test.ts | State-transition |
| UC-4B.2 | Cancel blocked once agreed | TC-NEG-06 | tc-neg.test.ts | Guard (409) |
| UC-5.1 | Submit Stream Session Proof — bad slot index | TC-DEL-01 | tc-del.test.ts | Validation guard (400) |
| UC-5.1 | Submit Stream Session Proof — valid slot | TC-DEL-02 | tc-del.test.ts | Happy path |
| UC-5.1 | Submit Stream Session Proof — slot already confirmed | TC-DEL-03 | tc-del.test.ts | Guard (409) |
| UC-6.2 | Confirm Submission → deal auto-completes | TC-DEL-04 | tc-del.test.ts | State-transition regression |
| UC-6.2 | Confirm Submission — partial, deal stays active | TC-DEL-05 | tc-del.test.ts | Negative state-transition |
| UC-6.2 | Confirm Submission — KYC not approved | TC-DEL-06 | tc-del.test.ts | Guard (403) |
| UC-6.3 | Reject Submission → status rejected | TC-DEL-07 | tc-del.test.ts | State-transition |
| UC-8.1 | Streamer Registration / Sign In | TC-AUTH-02 | tc-auth.test.ts | Happy path |
| — | Anti-enumeration (unknown email) | TC-AUTH-03 | tc-auth.test.ts | Security property |
| UC-3.1 | Streamer isolation — directory blocked for streamer session | TC-DISC-01 | tc-discovery.test.ts | Security guard (403) |
| UC-3.1 | Streamer isolation — own profile allowed, others' blocked | TC-DISC-02 | tc-discovery.test.ts | Security guard (200/403) |
| UC-9.1 | Browse Streamer Directory — Tier-1 anonymised | TC-DISC-03 | tc-discovery.test.ts | Tier-gate (strip) |
| UC-9.1 | Browse Streamer Directory — Tier-2 full | TC-DISC-04 | tc-discovery.test.ts | Tier-gate (full) |
| UC-9.2 | View Streamer Public Profile — tier guard | TC-DISC-05 | tc-discovery.test.ts | Tier-gate (200/403) |
| UC-9.3 | Pricing Display Rules — exact rates never surfaced | TC-DISC-03/04/05 | tc-discovery.test.ts | Data-exposure regression |
Not yet tested
| Use Case | Title | Reason / Priority |
|---|---|---|
| UC-1.1 | Register via Invitation | Needs admin-created invitations row; low automation priority |
| UC-1.2 | Connect Operator Wallet | On-chain / MetaMask only; not API-testable |
| UC-2.1 | Create Campaign | CRUD — low risk; consider TC-CAMP-04 |
| UC-2.3 | Delete Campaign | CRUD — low risk |
| UC-2.4 | Fund Campaign (Escrow Deposit) | On-chain; not API-testable |
| UC-3.2 | View Campaign Detail | Read-only; basic smoke |
| UC-3.3 | Submit an Offer (Streamer-initiated) | Untested — streamer "Apply" posts the message-only POST /api/campaigns/:id/applications shim (no terms / no floor validation); needs its own test once F-C is resolved |
| UC-4.1 | View Campaign Offers | Read-only list endpoint |
| UC-4.4 | Send Offer to Streamer (operator-initiated invite) | TC-OFFER-01 tests operator-initiated CREATE path; invite-specific rate limit / expiry not tested |
| UC-6.1 | Review Submission (list view) | Read-only |
| UC-7.1 | View Earnings Dashboard | Read-only aggregation |
| UC-7.2 | View Application / Payout History | Read-only list |
| UC-8.2 | KYC Document Upload | S3 upload; requires mock or real bucket |
| UC-8.3 | Connect / Manage Wallets | streamer_addresses CRUD |
| UC-8.4 | Update Profile & Deal Preferences | PUT /api/streamers/:username |
| UC-8.5 | View Trust Score | Read-only; computed externally |
| UC-10.1 | View Campaign Fund Breakdown | Read-only aggregation |
| UC-11.1 | Impersonate Streamer / Operator | Admin-only; single-use token |
| UC-11.2 | Manage Invitations | Admin-only |
Coverage summary
| Scope | Count |
|---|---|
| Total defined use cases | 37 |
| Use cases with ≥1 automated test | 17 |
| Coverage % (distinct use cases) | 46 % |
| Individual API test cases | 31 (TC-AUTH ×3, TC-CAMP ×3, TC-OFFER ×7, TC-NEG ×6, TC-DEL ×7, TC-DISC ×5) |
Covered use cases: UC-1.3, UC-2.2, UC-3.1, UC-3.4, UC-4.2, UC-4.3, UC-4.4, UC-4B.1, UC-4B.2, UC-4B.3, UC-5.1, UC-6.2, UC-6.3, UC-8.1, UC-9.1, UC-9.2, UC-9.3.
The remaining 21 are on-chain/MetaMask paths (UC-1.2, UC-2.4), read-only list/aggregation endpoints (UC-3.1 browse, UC-3.2, UC-4.1, UC-6.1, UC-7.x, UC-8.5, UC-10.1), admin-only flows (UC-11.x), or product-gated (UC-3.3 — see F-C). UC-3.3 is not covered: the prior matrix mislabelled the operator-initiated TC-OFFER-01 as UC-3.3.
Known doc ↔ code mismatch
TC-OFFER-01 was previously labelled "Streamer submits offer" (UC-3.3, initiated_by = "streamer"). The actual test creates an operator-initiated offer (initiated_by = "operator"), which maps to UC-4.4 — the matrix is now corrected to reflect that.
UC-3.3 (streamer-initiated apply) remains uncovered: the streamer "Apply" flow does not go through POST /api/campaigns/:id/offers at all — it posts a message-only application to the legacy POST /api/campaigns/:id/applications shim (no deal terms, no floor validation). A dedicated UC-3.3 test should be added once that path's behaviour is finalised (see finding F-C / open-findings.md).
TC-AUTH · Authentication
TC-AUTH-1 · Operator PIN sign-in
| Setup | Operator row exists with contact_email = test@op.com |
| Action | POST /api/auth/send-pin { email } → POST /api/auth/verify-pin { email, pin } |
| Expected | 200, { authenticated: true, role: "operator", operator: { id, name, … } } |
| Also assert | PIN row deleted from DB after successful verification |
TC-AUTH-2 · Streamer PIN sign-in
| Setup | Streamer row exists with email = test@streamer.com |
| Action | POST /api/auth/send-pin → POST /api/auth/verify-pin |
| Expected | 200, { authenticated: true, role: "streamer", streamer: { id, username, … } } |
TC-AUTH-3 · Unknown email still returns sent (anti-enumeration)
| Setup | No account with ghost@nowhere.com |
| Action | POST /api/auth/send-pin { email: "ghost@nowhere.com" } |
| Expected | 200, { sent: true, role: null } — does not reveal absence |
TC-CAMP · Campaign edit guards
The prepared-only edit guard (UC-2.2 / F-B) freezes campaign terms once a campaign leaves prepared. Funding writes (escrow_id/status/funding_address) run while the campaign is still prepared, so they're unaffected; afterwards only status transitions, on-chain bookkeeping and brand assets stay editable. seed.campaign inserts status in_progress, so the default fixture is post-funding.
TC-CAMP-1 · Funded campaign term edit blocked
| Setup | Campaign in in_progress |
| Action | PUT /api/campaigns/:id { name: "Renamed After Funding" } |
| Expected | 409 — "Campaign terms cannot be edited once funded…"; name unchanged |
TC-CAMP-2 · Status transition allowed on a funded campaign
| Setup | Campaign in in_progress |
| Action | PUT /api/campaigns/:id { status: "completed" } |
| Expected | 200; campaigns.status = "completed" |
TC-CAMP-3 · Draft (prepared) campaign edit allowed
| Setup | Campaign moved back to prepared |
| Action | PUT /api/campaigns/:id { name: "Draft Rename OK" } |
| Expected | 200; name updated |
TC-OFFER · Offers
TC-OFFER-1 · Operator sends offer to a streamer (operator-initiated)
Doc fix: this test was previously labelled UC-3.3 (streamer-initiated). The actual test sends
initiated_by = "operator"and maps to UC-4.4. The UC-3.3 path (initiated_by = "streamer") is structurally identical at the HTTP layer — same endpoint, same 201 response — and is covered by the same test wheninitiated_byis changed to"streamer". A dedicated UC-3.3 assertion is tracked in the not-yet-tested table above.
| Setup | Campaign exists (in_progress), active streamer |
| Action | POST /api/campaigns/:id/offers { operator_id, streamer_id, initiated_by: "operator", starting_terms: {…} } |
| Expected | 201, offer row with status = "pending", initiated_by = "operator" |
TC-OFFER-2 · Accept offer → negotiation opens atomically (duplicate accept guard)
| Setup | Pending operator-initiated offer (streamer is the receiver) |
| Action | PATCH /api/offers/:id { actor_id: streamer_id, action: "accept" } sent twice concurrently |
| Expected | First call → 200, offers.status = "accepted". Second call → 409 or idempotent 200. Offer is never left in an inconsistent state. |
| Regression | Atomicity — duplicate accept must not create two negotiations rows |
TC-OFFER-3 · Streamer declines offer
| Setup | Pending offer (any initiated_by) |
| Action | PATCH /api/offers/:id { actor_id: streamer_id, action: "decline" } |
| Expected | 200, offers.status = "declined" (or 403 if receiver-only auth guard fires first) |
TC-OFFER-4 · Revoke decline — blocked when another active offer exists
| Setup | Streamer has a declined offer for campaign A and a separate pending offer for the same campaign A |
| Action | PATCH /api/offers/:declined_id { actor_id: streamer_id, action: "revoke_decline" } |
| Expected | 409 — constraint guard prevents duplicate active offer; declined offer not updated |
| Regression | PostgresError: duplicate key value violates unique constraint "uq_offer_active" — fixed in offers.ts |
TC-OFFER-5 · Revoke decline — succeeds when no conflict
| Setup | Streamer has only a single declined offer for campaign A (no other active offer) |
| Action | PATCH /api/offers/:id { actor_id: streamer_id, action: "revoke_decline" } |
| Expected | 200, offers.status = "pending" |
TC-OFFER-6 · Streamer withdraws their own pending offer
| Setup | Streamer-initiated pending offer (initiated_by = "streamer") |
| Action | PATCH /api/offers/:id { actor_id: streamer_id, action: "withdraw" } |
| Expected | 200, offers.status = "withdrawn" |
TC-OFFER-7 · Withdraw blocked for the non-initiating party
| Setup | Operator-initiated pending offer (streamer is the receiver) |
| Action | PATCH /api/offers/:id { actor_id: streamer_id, action: "withdraw" } |
| Expected | 403 — "Only the initiating party may withdraw"; offer unchanged |
TC-NEG · Negotiations & Deals
TC-NEG-1 · Both parties approve all terms → negotiation agrees
| Setup | Open negotiation (in_progress) |
| Action | Approve all 5 terms as operator, then approve all 5 as streamer |
| Expected | negotiations.status = "agreed", agreed_at populated, turn = NULL |
TC-NEG-2 · Fund deal — budget cap enforcement
| Setup | Campaign with budget_cap_usdc = 100; existing active deal with allocation_amount = 80; agreed negotiation for a new offer |
| Action | POST /api/negotiations/:id/deal { operator_id, allocation_amount: 30, … } |
| Expected | 409 — 80 + 30 > 100; no deal row created |
| Regression | F-8 — budget cap not enforced |
TC-NEG-3 · Fund deal — accepted when within budget cap
| Setup | Campaign with budget_cap_usdc = 100; existing deal with allocation_amount = 60; agreed negotiation |
| Action | POST /api/negotiations/:id/deal { operator_id, allocation_amount: 30, … } |
| Expected | 201; deal row created with status = "active" |
TC-NEG-4 · Fund deal — wallet address snapshotted
| Setup | Streamer has wallet_address = "0xABC" in streamer_addresses; agreed negotiation |
| Action | POST /api/negotiations/:id/deal { operator_id, … } |
| Expected | 201; deals.wallet_address = "0xABC" captured at creation time |
| Regression | F-11 — wallet not snapshotted at funding |
TC-NEG-5 · Cancel negotiation → cancelled + parent offer withdrawn
| Setup | Accepted offer + in_progress negotiation |
| Action | POST /api/negotiations/:id/cancel { actor: "operator" } |
| Expected | 200, negotiations.status = "cancelled"; parent offers.status = "withdrawn" (vacates uq_offer_active) |
TC-NEG-6 · Cancel blocked once the negotiation is agreed
| Setup | Accepted offer + agreed negotiation |
| Action | POST /api/negotiations/:id/cancel { actor: "operator" } |
| Expected | 409 — "Negotiation is already agreed"; no state change |
TC-DEL · Delivery & Payouts
TC-DEL-1 · Slot index validated against allocation
| Setup | Accepted offer with allocation of 3 slots (indices 0, 1, 2) |
| Action | POST /api/campaign-applications/:id/submissions { slot_index: 99, stream_url: "…" } |
| Expected | 400, error message lists valid indices 0, 1, 2 |
| Regression | F-5 — slot_index not validated |
TC-DEL-2 · Valid slot submission succeeds
| Setup | Accepted offer with 3-slot allocation |
| Action | POST /api/campaign-applications/:id/submissions { slot_index: 1, stream_url: "https://kick.com/…" } |
| Expected | 201, submission row with status = "pending_review", attempt = 1 |
TC-DEL-3 · Submission blocked when slot already confirmed
| Setup | Slot 0 already has a confirmed submission |
| Action | POST /api/campaign-applications/:id/submissions { slot_index: 0, stream_url: "…" } |
| Expected | 409 — "This slot has already been confirmed" |
TC-DEL-4 · Confirm last slot → deal auto-completes
| Setup | Active deal with 2-slot allocation; slot 0 already confirmed; slot 1 submission is pending_review |
| Action | PUT /api/session-submissions/:id { status: "confirmed" } (slot 1) |
| Expected | 200; deals.status = "completed"; deals.updated_at refreshed |
| Regression | F-3 — deal never auto-transitioned to completed |
TC-DEL-5 · Confirm partial slot → deal stays active
| Setup | Active deal with 3-slot allocation; slots 0 and 1 already confirmed; slot 2 is pending_review |
| Action | Confirm slot 2? No — confirm only slot 0 in a fresh 3-slot deal (leaving 2 unconfirmed) |
| Expected | 200; deals.status remains "active" |
TC-DEL-6 · KYC check blocks confirmation of unverified streamer
| Setup | Submission for a streamer whose KYC is not fully approved — kyc_age_status and/or kyc_country_status ≠ approved (the helper sets both tracks; legacy single kyc_status was dropped in 0119) |
| Action | PUT /api/session-submissions/:id { status: "confirmed" } |
| Expected | 403 — "Streamer KYC is not approved" |
| Regression | F-1 (original finding) |
TC-DEL-7 · Reject submission → status rejected
| Setup | Active deal; slot 0 submission pending_review (no KYC required for the reject path) |
| Action | PUT /api/session-submissions/:id { status: "rejected", notes: "…" } |
| Expected | 200; session_submissions.status = "rejected"; deals.status stays active (rejection ≠ completion) |
TC-DISC · Streamer Discovery (isolation + tier gating)
Covers the server-side guards on the operator-facing directory and public-profile endpoints. These guards read the session cookie (verifySession), so the suite mints a signed session with the API's HMAC — the test env sets SESSION_SECRET. The tier gate keys off campaigns.status IN ('funded','in_progress'); an operator with a seeded (in_progress) campaign is Tier 2, one without is Tier 1.
TC-DISC-1 · Streamer session blocked from the directory
| Setup | Streamer row exists; signed streamer session cookie |
| Action | GET /api/streamers?public=1 with the streamer cookie |
| Expected | 403; an unauthenticated request to the same endpoint still returns 200 |
TC-DISC-2 · Streamer may read own profile, not another's
| Setup | Two streamers A and B; signed sessions for each |
| Action | GET /api/streamers/:A as streamer B, then as streamer A |
| Expected | As B → 403; as A (self) → 200 with streamer.id === A |
TC-DISC-3 · Tier-1 operator gets an anonymised directory
| Setup | Operator with no funded campaign; streamer A has bio/country/rates/price_bracket |
| Action | GET /api/streamers?public=1 with the Tier-1 operator cookie |
| Expected | A's row has username/operator_bio/country withheld; price_bracket + kyc_age_status kept; deal_cpa_rate/deal_revshare_rate stripped |
TC-DISC-4 · Tier-2 operator gets the full directory
| Setup | Operator with an in_progress campaign; same streamer A |
| Action | GET /api/streamers?public=1 with the Tier-2 operator cookie |
| Expected | A's row has username/operator_bio/country present; exact rates still stripped (UC-9.3) |
TC-DISC-5 · Profile page tier guard
| Setup | Tier-1 and Tier-2 operators; streamer A |
| Action | GET /api/streamers/:A as each operator |
| Expected | Tier-1 → 403 ("Fund a campaign…"); Tier-2 → 200 with username, no exact rates |
TC-CI · CI Pipeline
The test suite runs in GitHub Actions with a Postgres 16 service container. No blockchain node, no Resend key, no external services required.
| Component | How it is handled in CI |
|---|---|
| PostgreSQL | services: postgres:16 — ephemeral container, started per job |
| Migrations | npm run migrate against DATABASE_URL=postgres://…/vf_test before tests |
| Pusher | PUSHER_APP_ID="" → pusherTrigger no-ops silently |
| Resend (email) | RESEND_API_KEY="" → email functions log and return { success: false } |
| On-chain fields | Opaque strings — escrow_id, allocation_id, tx_hash accepted as-is |
| Smart contract | Not called by the API — frontend-only; nothing to mock |
Workflow structure:
PR / push to main
└─ ci.yml
├─ typecheck-api (npx tsc --noEmit)
├─ typecheck-frontend (npx tsc --noEmit)
└─ test-api (postgres service → migrate → npm test)
push to main, api/** changed
└─ deploy-api.yml
├─ test (same postgres setup — gates the build)
└─ build (Docker → GHCR) ← only runs if test passesTC-SC · Smart Contract (Forge)
Forge in-process EVM tests for the HTLC escrow contract (escrow/test/HTLC.t.sol). Run locally with forge test --root escrow or via the ci-contracts.yml workflow.
TC-SC-1 · Fund Locking — Deposits
| Covers | OP-1 (deposit ETH + ERC-20), SC-2 (USDC token transfer) |
| Tests | test_deposit, test_depositToken, test_deposit_revertsZeroValue, test_depositToken_revertsZeroValue, test_depositToken_revertsZeroTokenAddress, test_depositToken_revertsNoApproval |
| Assert | Deposit recorded with correct amount, currency, depositor; zero-value and unapproved transfers rejected |
TC-SC-2 · Budget Allocation — Merkle / Hashlock / Timelock
| Covers | OP-2 (Merkle multi-slot), OP-3 (per-slot hashlock), OP-4 (timelock) |
| Tests | test_allocate_singleSlot, test_allocate_multiSlotEqual, test_allocate_variableAmounts, test_allocate_multipleReceivers, test_allocate_revertsNotDepositor, test_allocate_revertsInsufficientFunds, test_allocate_revertsTimelockInPast, test_allocate_revertsDuplicateReceiver, + 4 more constraint guards |
| Assert | Allocation created with correct Merkle root and slot amounts; access control, over-allocation, past-deadline, and duplicate-receiver all rejected |
TC-SC-3 · Payout Claims — Merkle Proof / Preimage Reveal
| Covers | OP-5 (Merkle proof verification), OP-6 (preimage reveal → transfer) |
| Tests | test_withdraw_singleSlot_token, test_withdraw_singleSlot_ETH, test_withdraw_emitsEvent, test_withdraw_revertsInvalidProof, test_withdraw_revertsWrongAmount, test_withdraw_multiSlotEqual_completes, test_withdraw_variableAmounts, + 8 more |
| Assert | Correct payout on valid proof + preimage; invalid proof, wrong amount, double-claim, and completed-allocation all rejected |
TC-SC-4 · Batch Claims
| Covers | OP-6 (batch preimage reveal) |
| Tests | test_withdrawBatch_allSlots_equal, test_withdrawBatch_variableAmounts, test_withdrawBatch_partial, test_withdrawBatch_mixWithSingle, test_withdrawBatch_revertsEmpty, test_withdrawBatch_revertsDuplicateInBatch, test_withdrawBatch_ETH |
| Assert | All slots claimed atomically; partial batches work; empty and duplicate-slot batches rejected |
TC-SC-5 · Allocation Refunds (after timelock expiry)
| Covers | OP-4 (timelock enforcement), OP-7 (unclaimed slot recovery), OP-8 (partial refund) |
| Tests | test_refundAllocation_full, test_refundAllocation_partialEqual, test_refundAllocation_partialVariable, test_refundAllocation_revertsTimelockNotExpired, test_refundAllocation_revertsNotDepositor, test_refundAllocation_revertsNotActive, test_refundAllocation_allowsReallocation, test_refundAllocation_emitsEvent |
| Assert | Unclaimed funds returned after deadline; claimed slots preserved; early refund and non-depositor blocked |
TC-SC-6 · Deposit Refunds (unallocated balance)
| Covers | OP-1 (deposit balance lifecycle) |
| Tests | test_refund, test_refund_full, test_refundETH, test_refund_revertsNothingToRefund, test_refund_revertsNotDepositor |
| Assert | Unallocated balance returned to depositor; double-refund and non-depositor blocked |
TC-SC-E2E · Full Lifecycle Integration
| Covers | OP-1 → OP-2 → OP-6 → OP-7 → OP-8 (complete fund lifecycle) |
| Tests | test_fullLifecycle (single end-to-end test) |
| Assert | Deposit → allocate 2 streamers → partial payouts → batch payout → deadline passes → allocation refund → deposit refund; balances correct at every stage |
TC-SC-FUZZ · Randomised (Fuzz) Tests
| Covers | OP-2, OP-3, OP-6 (arbitrary inputs) |
| Tests | testFuzz_singleSlot, testFuzz_multiSlotEqual, testFuzz_variableAmounts |
| Assert | Correct behaviour across hundreds of random amounts, secrets, and slot counts (1–32) |