Smart contract tests
177 Foundry tests across 4 files, organized by domain:
cd contracts && forge test
Test structure
| File | Tests | What it covers |
|---|
MouthBetPvP.t.sol | 64 | 1v1 PvP flow: deposit, withdraw, resolve, finalize, claim, resolverClaim, nonDisputeable |
MouthBetOpenPvP.t.sol | 35 | Open PvP flow: multi-opponent deposits, partial fill, activatePartial, max opponents, withdrawAfterDeadline |
MouthBetEdgeCases.t.sol | 32 | Cross-cutting scenarios: re-resolve / dispute window, mutual cancel, asymmetric odds, fee snapshot, resolver change, full lifecycle |
MouthBetFactory.t.sol | 46 | Factory: bet creation with various odds, admin functions (fee, resolver, treasury), upgrades, initialization |
All test contracts inherit from BaseTest.sol, which sets up a fork-like environment with a mock USDC token, deploys the factory via UUPS proxy, and provides shared helpers (_createPvPBet, _createOpenBet, _opponentDeposit, _resolve, etc.).
Coverage
| File | Lines | Statements | Functions |
|---|
| MouthBet.sol | 98.09% (205/209) | 97.37% (222/228) | 100% (21/21) |
| MouthBetFactory.sol | 96.15% (50/52) | 95.45% (42/44) | 100% (10/10) |
cd contracts && forge coverage --ir-minimum
Branch coverage numbers appear low (~47% / ~12%) but this is an artifact of --ir-minimum generating imprecise source mappings. The --ir-minimum flag is required because the contracts hit “stack too deep” errors with standard coverage compilation.
Backend tests
The backend uses integration tests that exercise the full HTTP request lifecycle: routing, middleware, input validation, permission checks, state transitions, and database queries. Tests run against the real PostgreSQL database — only external services that cannot be automated (Privy OAuth, Base blockchain) are mocked.
56 tests across 4 files. Runs in ~60 seconds.
What the tests cover
Auth (auth.test.ts — 11 tests)
| Flow | What’s verified |
|---|
POST /auth/login | First login creates user in DB, returns profile |
POST /auth/login | Missing or invalid token returns 401 |
GET /auth/me | Authenticated user gets their profile |
GET /auth/me | No token → 401, valid token but user deleted → 403 |
POST /auth/withdraw | Valid withdrawal returns txHash |
POST /auth/withdraw | Invalid address, zero amount, missing wallet, missing fields → 400 |
Bets (bets.test.ts — 33 tests)
| Flow | What’s verified |
|---|
| Listing | GET /bets returns bets, supports status and category filters, pagination |
| Detail | GET /bets/:id returns bet with challenger handle; 404 for non-existent |
| Creation | POST /bets creates PvP and open_pvp bets; validates required fields, odds range (1–99), minimum amount (5 USDC); 401 without auth |
| Acceptance | Cannot accept own bet (400), non-pending bet (400), wrong opponent for PvP (403), expired deadline (400) |
| Cancellation | Only challenger can cancel (403 for others), only pending bets (400) |
| Resolution | Only resolver wallet can resolve (403), must be active bet (400), validates outcome values |
| Finalization | Only resolver can finalize (403), must be resolved bet (400) |
| Claim | Only winner can claim (403 for loser/non-participant), must be finalized (400), no double-claim (400) |
| Full lifecycle | End-to-end: create → accept → resolve → finalize → claim |
Users (users.test.ts — 8 tests)
| Flow | What’s verified |
|---|
GET /users/:handle | Returns public profile (no private fields like privyId); 404 for non-existent |
GET /users/:handle/bets | Returns bet history (as challenger and as opponent); pagination; 404 |
GET /users/:handle/stats | Returns stats array; 404 for non-existent |
Leaderboard (leaderboard.test.ts — 4 tests)
| Flow | What’s verified |
|---|
GET /leaderboard | Returns leaderboard array with activity data |
GET /leaderboard | Supports sort parameter (wins, volume, profit) and limit |
What is mocked
External services that require real credentials, OAuth flows, or cost real money are replaced with mock functions:
| Module | Mock | Why |
|---|
| Privy auth | verifyAccessToken, getUser, create | OAuth with X cannot be automated in tests |
| tx-relayer | relayCreateBet, relayDeposit, relayWithdraw, relayClaim, relayTransferUSDC | Would send real USDC transactions on Base mainnet |
| blockchain | resolveBetOnChain, finalizeBetOnChain, publicClient | Would interact with real smart contracts |
| wallet-resolver | resolveXHandleToWallet | Depends on Privy user lookup |
Mocks are defined in src/__tests__/setup.ts and reset before each test via vi.clearAllMocks().
What is real
- PostgreSQL database — all INSERT, SELECT, UPDATE, DELETE queries hit the real Neon DB
- Hono app — full middleware chain (CORS, logger, auth middleware)
- Route handlers — all validation logic, permission checks, SQL queries, state transitions
- Drizzle ORM — real query building and execution
What the tests do NOT cover
| Area | Reason |
|---|
| Privy ↔ backend integration | Privy is mocked — if Privy changes their SDK response format, tests won’t catch it |
| Smart contract interactions | Blockchain is mocked — contract bugs or ABI mismatches won’t surface |
| Cron jobs (bet expiration, auto-claim) | These run as scheduled processes, not HTTP routes |
| Disputes | Not yet implemented in tests |
| Frontend ↔ backend | Tests only cover the API layer, not the React frontend |
| activity_log population | No route writes to activity_log (populated by crons), so stats/leaderboard tests use manually inserted data |
How test data is isolated
Tests never delete existing data. Each test helper (createTestUser, createTestBet, createTestActivity) tracks the IDs of records it creates. At the end of each test file, cleanupTestData() deletes only those tracked records:
// Tracked during test execution
const createdUserIds: string[] = [];
const createdBetIds: string[] = [];
// afterAll — only deletes what tests created
await db.delete(bets).where(inArray(bets.id, createdBetIds));
await db.delete(users).where(inArray(users.id, createdUserIds));
Test files run sequentially (fileParallelism: false) to avoid race conditions on the shared database.
File structure
backend/
vitest.config.ts # Vitest config (sequential execution, path aliases)
src/
app.ts # Hono app (extracted from index.ts for testability)
index.ts # Server startup + cron (not imported by tests)
__tests__/
setup.ts # Global mocks (Privy, blockchain, tx-relayer)
helpers.ts # createTestUser, createTestBet, makeRequest, cleanup
auth.test.ts # Auth route tests
bets.test.ts # Bet lifecycle tests
users.test.ts # User profile/stats tests
leaderboard.test.ts # Leaderboard tests
The app.ts / index.ts split exists specifically for testing. app.ts exports the Hono app without calling serve() or starting cron jobs, so tests can import it cleanly via app.request().