Skip to content

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 CaseTitleTest IDFileAssertion type
UC-1.3Operator Sign InTC-AUTH-01tc-auth.test.tsHappy path
UC-2.2Edit Campaign — terms frozen once fundedTC-CAMP-01tc-camp.test.tsIntegrity guard (409)
UC-2.2Edit Campaign — status transition allowedTC-CAMP-02tc-camp.test.tsState-transition
UC-2.2Edit Campaign — draft edit allowedTC-CAMP-03tc-camp.test.tsHappy path
UC-4.4Send Offer to Streamer (Operator-initiated) — createTC-OFFER-01tc-offer.test.tsHappy path
UC-4.2Accept Offer → Open Negotiation (atomicity)TC-OFFER-02tc-offer.test.tsRace-condition regression
UC-4.3Decline OfferTC-OFFER-03tc-offer.test.tsHappy path
UC-3.4Revoke-decline — blocked by active offerTC-OFFER-04tc-offer.test.tsConstraint guard regression
UC-3.4Revoke-decline — succeeds when no conflictTC-OFFER-05tc-offer.test.tsHappy path
UC-3.4Withdraw own pending offerTC-OFFER-06tc-offer.test.tsHappy path
UC-3.4Withdraw blocked for non-initiatorTC-OFFER-07tc-offer.test.tsAuth guard (403)
UC-4B.1Propose or Approve a Term (auto-agree)TC-NEG-01tc-neg.test.tsHappy path
UC-4B.3Fund Deal — budget cap exceededTC-NEG-02tc-neg.test.tsGuard (409)
UC-4B.3Fund Deal — within budget capTC-NEG-03tc-neg.test.tsHappy path
UC-4B.3Fund Deal — wallet address snapshottedTC-NEG-04tc-neg.test.tsData-integrity regression
UC-4B.2Cancel Negotiation → offer withdrawnTC-NEG-05tc-neg.test.tsState-transition
UC-4B.2Cancel blocked once agreedTC-NEG-06tc-neg.test.tsGuard (409)
UC-5.1Submit Stream Session Proof — bad slot indexTC-DEL-01tc-del.test.tsValidation guard (400)
UC-5.1Submit Stream Session Proof — valid slotTC-DEL-02tc-del.test.tsHappy path
UC-5.1Submit Stream Session Proof — slot already confirmedTC-DEL-03tc-del.test.tsGuard (409)
UC-6.2Confirm Submission → deal auto-completesTC-DEL-04tc-del.test.tsState-transition regression
UC-6.2Confirm Submission — partial, deal stays activeTC-DEL-05tc-del.test.tsNegative state-transition
UC-6.2Confirm Submission — KYC not approvedTC-DEL-06tc-del.test.tsGuard (403)
UC-6.3Reject Submission → status rejectedTC-DEL-07tc-del.test.tsState-transition
UC-8.1Streamer Registration / Sign InTC-AUTH-02tc-auth.test.tsHappy path
Anti-enumeration (unknown email)TC-AUTH-03tc-auth.test.tsSecurity property
UC-3.1Streamer isolation — directory blocked for streamer sessionTC-DISC-01tc-discovery.test.tsSecurity guard (403)
UC-3.1Streamer isolation — own profile allowed, others' blockedTC-DISC-02tc-discovery.test.tsSecurity guard (200/403)
UC-9.1Browse Streamer Directory — Tier-1 anonymisedTC-DISC-03tc-discovery.test.tsTier-gate (strip)
UC-9.1Browse Streamer Directory — Tier-2 fullTC-DISC-04tc-discovery.test.tsTier-gate (full)
UC-9.2View Streamer Public Profile — tier guardTC-DISC-05tc-discovery.test.tsTier-gate (200/403)
UC-9.3Pricing Display Rules — exact rates never surfacedTC-DISC-03/04/05tc-discovery.test.tsData-exposure regression

Not yet tested

Use CaseTitleReason / Priority
UC-1.1Register via InvitationNeeds admin-created invitations row; low automation priority
UC-1.2Connect Operator WalletOn-chain / MetaMask only; not API-testable
UC-2.1Create CampaignCRUD — low risk; consider TC-CAMP-04
UC-2.3Delete CampaignCRUD — low risk
UC-2.4Fund Campaign (Escrow Deposit)On-chain; not API-testable
UC-3.2View Campaign DetailRead-only; basic smoke
UC-3.3Submit 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.1View Campaign OffersRead-only list endpoint
UC-4.4Send Offer to Streamer (operator-initiated invite)TC-OFFER-01 tests operator-initiated CREATE path; invite-specific rate limit / expiry not tested
UC-6.1Review Submission (list view)Read-only
UC-7.1View Earnings DashboardRead-only aggregation
UC-7.2View Application / Payout HistoryRead-only list
UC-8.2KYC Document UploadS3 upload; requires mock or real bucket
UC-8.3Connect / Manage Walletsstreamer_addresses CRUD
UC-8.4Update Profile & Deal PreferencesPUT /api/streamers/:username
UC-8.5View Trust ScoreRead-only; computed externally
UC-10.1View Campaign Fund BreakdownRead-only aggregation
UC-11.1Impersonate Streamer / OperatorAdmin-only; single-use token
UC-11.2Manage InvitationsAdmin-only

Coverage summary

ScopeCount
Total defined use cases37
Use cases with ≥1 automated test17
Coverage % (distinct use cases)46 %
Individual API test cases31 (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

SetupOperator row exists with contact_email = test@op.com
ActionPOST /api/auth/send-pin { email }POST /api/auth/verify-pin { email, pin }
Expected200, { authenticated: true, role: "operator", operator: { id, name, … } }
Also assertPIN row deleted from DB after successful verification

TC-AUTH-2 · Streamer PIN sign-in

SetupStreamer row exists with email = test@streamer.com
ActionPOST /api/auth/send-pinPOST /api/auth/verify-pin
Expected200, { authenticated: true, role: "streamer", streamer: { id, username, … } }

TC-AUTH-3 · Unknown email still returns sent (anti-enumeration)

SetupNo account with ghost@nowhere.com
ActionPOST /api/auth/send-pin { email: "ghost@nowhere.com" }
Expected200, { 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

SetupCampaign in in_progress
ActionPUT /api/campaigns/:id { name: "Renamed After Funding" }
Expected409 — "Campaign terms cannot be edited once funded…"; name unchanged

TC-CAMP-2 · Status transition allowed on a funded campaign

SetupCampaign in in_progress
ActionPUT /api/campaigns/:id { status: "completed" }
Expected200; campaigns.status = "completed"

TC-CAMP-3 · Draft (prepared) campaign edit allowed

SetupCampaign moved back to prepared
ActionPUT /api/campaigns/:id { name: "Draft Rename OK" }
Expected200; 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 when initiated_by is changed to "streamer". A dedicated UC-3.3 assertion is tracked in the not-yet-tested table above.

SetupCampaign exists (in_progress), active streamer
ActionPOST /api/campaigns/:id/offers { operator_id, streamer_id, initiated_by: "operator", starting_terms: {…} }
Expected201, offer row with status = "pending", initiated_by = "operator"

TC-OFFER-2 · Accept offer → negotiation opens atomically (duplicate accept guard)

SetupPending operator-initiated offer (streamer is the receiver)
ActionPATCH /api/offers/:id { actor_id: streamer_id, action: "accept" } sent twice concurrently
ExpectedFirst call → 200, offers.status = "accepted". Second call → 409 or idempotent 200. Offer is never left in an inconsistent state.
RegressionAtomicity — duplicate accept must not create two negotiations rows

TC-OFFER-3 · Streamer declines offer

SetupPending offer (any initiated_by)
ActionPATCH /api/offers/:id { actor_id: streamer_id, action: "decline" }
Expected200, offers.status = "declined" (or 403 if receiver-only auth guard fires first)

TC-OFFER-4 · Revoke decline — blocked when another active offer exists

SetupStreamer has a declined offer for campaign A and a separate pending offer for the same campaign A
ActionPATCH /api/offers/:declined_id { actor_id: streamer_id, action: "revoke_decline" }
Expected409 — constraint guard prevents duplicate active offer; declined offer not updated
RegressionPostgresError: duplicate key value violates unique constraint "uq_offer_active" — fixed in offers.ts

TC-OFFER-5 · Revoke decline — succeeds when no conflict

SetupStreamer has only a single declined offer for campaign A (no other active offer)
ActionPATCH /api/offers/:id { actor_id: streamer_id, action: "revoke_decline" }
Expected200, offers.status = "pending"

TC-OFFER-6 · Streamer withdraws their own pending offer

SetupStreamer-initiated pending offer (initiated_by = "streamer")
ActionPATCH /api/offers/:id { actor_id: streamer_id, action: "withdraw" }
Expected200, offers.status = "withdrawn"

TC-OFFER-7 · Withdraw blocked for the non-initiating party

SetupOperator-initiated pending offer (streamer is the receiver)
ActionPATCH /api/offers/:id { actor_id: streamer_id, action: "withdraw" }
Expected403 — "Only the initiating party may withdraw"; offer unchanged

TC-NEG · Negotiations & Deals

TC-NEG-1 · Both parties approve all terms → negotiation agrees

SetupOpen negotiation (in_progress)
ActionApprove all 5 terms as operator, then approve all 5 as streamer
Expectednegotiations.status = "agreed", agreed_at populated, turn = NULL

TC-NEG-2 · Fund deal — budget cap enforcement

SetupCampaign with budget_cap_usdc = 100; existing active deal with allocation_amount = 80; agreed negotiation for a new offer
ActionPOST /api/negotiations/:id/deal { operator_id, allocation_amount: 30, … }
Expected40980 + 30 > 100; no deal row created
RegressionF-8 — budget cap not enforced

TC-NEG-3 · Fund deal — accepted when within budget cap

SetupCampaign with budget_cap_usdc = 100; existing deal with allocation_amount = 60; agreed negotiation
ActionPOST /api/negotiations/:id/deal { operator_id, allocation_amount: 30, … }
Expected201; deal row created with status = "active"

TC-NEG-4 · Fund deal — wallet address snapshotted

SetupStreamer has wallet_address = "0xABC" in streamer_addresses; agreed negotiation
ActionPOST /api/negotiations/:id/deal { operator_id, … }
Expected201; deals.wallet_address = "0xABC" captured at creation time
RegressionF-11 — wallet not snapshotted at funding

TC-NEG-5 · Cancel negotiation → cancelled + parent offer withdrawn

SetupAccepted offer + in_progress negotiation
ActionPOST /api/negotiations/:id/cancel { actor: "operator" }
Expected200, negotiations.status = "cancelled"; parent offers.status = "withdrawn" (vacates uq_offer_active)

TC-NEG-6 · Cancel blocked once the negotiation is agreed

SetupAccepted offer + agreed negotiation
ActionPOST /api/negotiations/:id/cancel { actor: "operator" }
Expected409 — "Negotiation is already agreed"; no state change

TC-DEL · Delivery & Payouts

TC-DEL-1 · Slot index validated against allocation

SetupAccepted offer with allocation of 3 slots (indices 0, 1, 2)
ActionPOST /api/campaign-applications/:id/submissions { slot_index: 99, stream_url: "…" }
Expected400, error message lists valid indices 0, 1, 2
RegressionF-5 — slot_index not validated

TC-DEL-2 · Valid slot submission succeeds

SetupAccepted offer with 3-slot allocation
ActionPOST /api/campaign-applications/:id/submissions { slot_index: 1, stream_url: "https://kick.com/…" }
Expected201, submission row with status = "pending_review", attempt = 1

TC-DEL-3 · Submission blocked when slot already confirmed

SetupSlot 0 already has a confirmed submission
ActionPOST /api/campaign-applications/:id/submissions { slot_index: 0, stream_url: "…" }
Expected409 — "This slot has already been confirmed"

TC-DEL-4 · Confirm last slot → deal auto-completes

SetupActive deal with 2-slot allocation; slot 0 already confirmed; slot 1 submission is pending_review
ActionPUT /api/session-submissions/:id { status: "confirmed" } (slot 1)
Expected200; deals.status = "completed"; deals.updated_at refreshed
RegressionF-3 — deal never auto-transitioned to completed

TC-DEL-5 · Confirm partial slot → deal stays active

SetupActive deal with 3-slot allocation; slots 0 and 1 already confirmed; slot 2 is pending_review
ActionConfirm slot 2? No — confirm only slot 0 in a fresh 3-slot deal (leaving 2 unconfirmed)
Expected200; deals.status remains "active"

TC-DEL-6 · KYC check blocks confirmation of unverified streamer

SetupSubmission for a streamer whose KYC is not fully approved — kyc_age_status and/or kyc_country_statusapproved (the helper sets both tracks; legacy single kyc_status was dropped in 0119)
ActionPUT /api/session-submissions/:id { status: "confirmed" }
Expected403 — "Streamer KYC is not approved"
RegressionF-1 (original finding)

TC-DEL-7 · Reject submission → status rejected

SetupActive deal; slot 0 submission pending_review (no KYC required for the reject path)
ActionPUT /api/session-submissions/:id { status: "rejected", notes: "…" }
Expected200; 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

SetupStreamer row exists; signed streamer session cookie
ActionGET /api/streamers?public=1 with the streamer cookie
Expected403; an unauthenticated request to the same endpoint still returns 200

TC-DISC-2 · Streamer may read own profile, not another's

SetupTwo streamers A and B; signed sessions for each
ActionGET /api/streamers/:A as streamer B, then as streamer A
ExpectedAs B → 403; as A (self) → 200 with streamer.id === A

TC-DISC-3 · Tier-1 operator gets an anonymised directory

SetupOperator with no funded campaign; streamer A has bio/country/rates/price_bracket
ActionGET /api/streamers?public=1 with the Tier-1 operator cookie
ExpectedA'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

SetupOperator with an in_progress campaign; same streamer A
ActionGET /api/streamers?public=1 with the Tier-2 operator cookie
ExpectedA's row has username/operator_bio/country present; exact rates still stripped (UC-9.3)

TC-DISC-5 · Profile page tier guard

SetupTier-1 and Tier-2 operators; streamer A
ActionGET /api/streamers/:A as each operator
ExpectedTier-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.

ComponentHow it is handled in CI
PostgreSQLservices: postgres:16 — ephemeral container, started per job
Migrationsnpm run migrate against DATABASE_URL=postgres://…/vf_test before tests
PusherPUSHER_APP_ID=""pusherTrigger no-ops silently
Resend (email)RESEND_API_KEY="" → email functions log and return { success: false }
On-chain fieldsOpaque strings — escrow_id, allocation_id, tx_hash accepted as-is
Smart contractNot 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 passes

TC-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

CoversOP-1 (deposit ETH + ERC-20), SC-2 (USDC token transfer)
Teststest_deposit, test_depositToken, test_deposit_revertsZeroValue, test_depositToken_revertsZeroValue, test_depositToken_revertsZeroTokenAddress, test_depositToken_revertsNoApproval
AssertDeposit recorded with correct amount, currency, depositor; zero-value and unapproved transfers rejected

TC-SC-2 · Budget Allocation — Merkle / Hashlock / Timelock

CoversOP-2 (Merkle multi-slot), OP-3 (per-slot hashlock), OP-4 (timelock)
Teststest_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
AssertAllocation 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

CoversOP-5 (Merkle proof verification), OP-6 (preimage reveal → transfer)
Teststest_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
AssertCorrect payout on valid proof + preimage; invalid proof, wrong amount, double-claim, and completed-allocation all rejected

TC-SC-4 · Batch Claims

CoversOP-6 (batch preimage reveal)
Teststest_withdrawBatch_allSlots_equal, test_withdrawBatch_variableAmounts, test_withdrawBatch_partial, test_withdrawBatch_mixWithSingle, test_withdrawBatch_revertsEmpty, test_withdrawBatch_revertsDuplicateInBatch, test_withdrawBatch_ETH
AssertAll slots claimed atomically; partial batches work; empty and duplicate-slot batches rejected

TC-SC-5 · Allocation Refunds (after timelock expiry)

CoversOP-4 (timelock enforcement), OP-7 (unclaimed slot recovery), OP-8 (partial refund)
Teststest_refundAllocation_full, test_refundAllocation_partialEqual, test_refundAllocation_partialVariable, test_refundAllocation_revertsTimelockNotExpired, test_refundAllocation_revertsNotDepositor, test_refundAllocation_revertsNotActive, test_refundAllocation_allowsReallocation, test_refundAllocation_emitsEvent
AssertUnclaimed funds returned after deadline; claimed slots preserved; early refund and non-depositor blocked

TC-SC-6 · Deposit Refunds (unallocated balance)

CoversOP-1 (deposit balance lifecycle)
Teststest_refund, test_refund_full, test_refundETH, test_refund_revertsNothingToRefund, test_refund_revertsNotDepositor
AssertUnallocated balance returned to depositor; double-refund and non-depositor blocked

TC-SC-E2E · Full Lifecycle Integration

CoversOP-1 → OP-2 → OP-6 → OP-7 → OP-8 (complete fund lifecycle)
Teststest_fullLifecycle (single end-to-end test)
AssertDeposit → allocate 2 streamers → partial payouts → batch payout → deadline passes → allocation refund → deposit refund; balances correct at every stage

TC-SC-FUZZ · Randomised (Fuzz) Tests

CoversOP-2, OP-3, OP-6 (arbitrary inputs)
TeststestFuzz_singleSlot, testFuzz_multiSlotEqual, testFuzz_variableAmounts
AssertCorrect behaviour across hundreds of random amounts, secrets, and slot counts (1–32)

Verifluence Documentation