Mitra-availability state (online flag, deactivated flag, per-mitra session count, heartbeat liveness) mirrored into Valkey so the customer beacon + pairing blast + dashboard counts no longer hit Postgres on the hot path. Postgres remains the durable source of truth; Valkey state is fully derivable via seedFromPostgres on startup + reconnect. Schema - mitras:online SET — mirror of is_online - mitras:deactivated SET — mirror of is_active=false - mitra:capacity:<id> STRING — active+pending_payment session count - mitra💓<id> STRING — ISO timestamp of last ping - availability:snapshot JSON — beacon cache, TTL 10s, cluster-shared Write paths (Postgres first, best-effort Valkey) - setOnline/setOffline mirror SADD/SREM + heartbeat SET/DEL - updateMitraStatus mirrors mitras:deactivated AND revokes auth_sessions on deactivate (bounds the "ghost online" window to access-token TTL) - heartbeat is Valkey-only on the hot path; the per-ping Postgres UPDATE on last_heartbeat_at is eliminated (was 1,200 ops/min at prod scale) - chat_session lifecycle (accept/end/reroute/extension/expiry) calls recomputeCapacityForMitra after each UPDATE — derive-from-truth avoids the bookkeeping risk of per-transition INCR/DECR Read paths (Valkey-first, Postgres fallback on Valkey error) - isMitraReachable: SISMEMBER mitras:online + heartbeat freshness - findAvailableMitras: SDIFF + pipelined GETs, filter by capacity + heartbeat - countAvailableMitrasFromCache: Valkey-driven, cached cluster-wide 10s TTL - dashboard online count: SCARD - Each reader wraps Valkey ops in try/catch → Postgres fallback on outage Heartbeat path on /api/mitra/status/heartbeat - resolveMitra preHandler replaced with heartbeatGuard: SISMEMBER on mitras:deactivated (~0 DB hits per ping). Falls back to full DB resolveMitra if Valkey is unreachable so a Valkey outage doesn't silently accept heartbeats from deactivated mitras. Three sweeps, env-configurable cadences - MITRA_AUTO_OFFLINE_SWEEP_SECONDS (30) — Valkey-driven stale detection - HEARTBEAT_MIRROR_INTERVAL_SECONDS (60) — batched UPSERT writes Valkey timestamps to Postgres last_heartbeat_at via UNNEST (1 statement per cycle, idempotent across instances) - VALKEY_ONLINE_MIRROR_SWEEP_SECONDS (300) — periodic reseed heals drift Startup - restoreActiveTimers → seedFromPostgres → bind listeners - onValkeyReady re-runs the seed on every reconnect (cold start + reseed on Valkey restart, no manual intervention) Failure semantics - Read fallback: every Valkey read wrapped, falls back to existing Postgres JOIN query — system stays correct during Valkey outage, performance degrades not breaks - Write best-effort: Postgres write commits before Valkey is touched; Valkey errors log + continue; reconciliation sweep heals drift - Auto-offline sweep aborts entirely on Valkey error (does NOT mass- offline via Postgres scan during Valkey hiccup) Tests - New: 32 integration tests in mitra-status.valkey-mirror.test.js covering seed, write-through, fallbacks, capacity lifecycle, auto-offline sweep, heartbeat mirror, deactivation flow, beacon cache - Updated: fixtures.js seeds Valkey alongside Postgres when isOnline=true - Updated: helpers/db.js resetDb also flushes test Valkey - Fixed 2 pre-existing session-timer flakes (string IDs failed uuid parse; vi.advanceTimersByTimeAsync raced real Postgres I/O) - All 124/124 backend tests pass (was 90/92) Docs - requirement/valkey-online-mirror-plan.md — canonical plan - requirement/valkey-online-mirror-testing.md — manual E2E checklist - requirement/deployment.md — infra + Valkey persistence guidance for prod (Memorystore Standard tier recommended; migration from self-hosted Valkey is zero-downtime via reseed-from-Postgres) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Backend tests (Vitest)
Vitest scaffolding for the Halo Bestie Fastify backend. Three sample tests exist to demonstrate the patterns; broader coverage will be filled in incrementally.
Strategy: schema-isolated remote DB (default)
The remote dev role on omv.sjamsani.id does not have CREATE DATABASE
privilege, so the chosen isolation mechanism is a separate schema inside the
existing halobestie_clone database. The migration runs into a halobestie_test
schema (driven by ?options=-c search_path=... on the test DB URL), leaving the
dev public schema untouched.
Valkey isolation uses a separate logical db number (/1) on the same instance.
Why not Docker?
Docker availability could not be verified inside the agent sandbox at scaffold
time. A docker-compose.test.yml exists for users who prefer ephemeral local
containers — see "Switching to local Docker" below.
Why not a separate Postgres database?
The dev role is non-superuser and lacks CREATE DATABASE. Schema isolation gives
us the same isolation guarantee (test tables live in their own namespace) without
requiring a privilege bump.
Setup
-
Copy
.env.test.example→.env.test:cp .env.test.example .env.testAdjust
TEST_DATABASE_URL/TEST_VALKEY_URLif your dev DB is elsewhere. -
(Optional) Verify connectivity:
node -e "import('postgres').then(({default:p})=>{const s=p(process.env.TEST_DATABASE_URL);s\`SELECT 1\`.then(console.log).finally(()=>s.end())})" -
The
halobestie_testschema and all test tables are created automatically the first timenpm testruns (idempotent — re-runningnpm testis safe).
Running
npm test # one-shot run
npm run test:watch # re-run on file change
npm run test:coverage # plus coverage report under coverage/
Required environment variables
| Var | Default | Purpose |
|---|---|---|
TEST_DATABASE_URL |
postgresql://halobestie_clone:halobestie_clone@omv.sjamsani.id:5432/halobestie_clone |
Same as dev — schema isolates |
TEST_DB_SCHEMA |
halobestie_test |
Schema name for test tables. Hard-rejected if set to public |
TEST_VALKEY_URL |
redis://omv.sjamsani.id:6379/1 |
Note the /1 — separate logical db from dev |
AUTH_JWT_SECRET |
(must be ≥ 32 chars) | Signs JWTs the prod authenticate plugin verifies. Test value can differ from dev |
ACCESS_TOKEN_TTL_SECONDS |
3600 |
Optional |
REFRESH_TOKEN_TTL_DAYS |
30 |
Optional |
CC_ORIGIN |
http://localhost:5173 |
Required by the internal app's CORS config |
Adding a new test
Templates by type:
| Test type | Template | Sample |
|---|---|---|
| Pure service | uses db() + fixtures |
test/services/payment.service.test.js |
| Service with mocked WS/FCM | vi.mock('../../src/plugins/websocket.js') at top |
test/services/pairing.service.test.js |
| Route (HTTP-free via inject) | app.inject({ method, url, headers, payload }) |
test/routes/client.payment.routes.test.js |
Helpers (under test/helpers/):
db.js—db()returns the shared sql client;resetDb()truncates Phase 3.7 + dependent tables;resetAppConfig()restores config defaults.valkey.js—getTestValkey()for direct keyspace assertions;flushTestDb()to wipe between tests.server.js—buildPublic()/buildInternal()for route tests.jwt.js—customerJwt(id),mitraJwt(id),ccJwt(id)mint tokens the prodauthenticateplugin accepts.authHeader(token)builds the header.fixtures.js—createCustomer(),createMitra({ isOnline }).
Patterns to follow (from the sample tests):
- Always import status / cause values from
../../src/constants.js— never hard-code'pending','all_mitras_rejected', etc. (See project memory: "Use Enums for Fixed Values".) - Mock
../../src/plugins/websocket.jsand../../src/services/notification.service.jsfor any test that touches pairing / extension / closure — they fan out via WS + FCM and you don't want either to fire on a real socket / Firebase project. - Call
resetDb()inbeforeEach,resetAppConfig()once inbeforeAll(or inafterEachif your test mutates config).
Isolation notes
Tests run sequentially (fileParallelism: false, sequence.concurrent: false)
because they share one DB schema and one Valkey db. If you ever need
parallelism: switch to per-test transactions (BEGIN in beforeEach, ROLLBACK
in afterEach) or per-test schemas (CREATE SCHEMA test_${random}) and update
vitest.config.js.
Switching to local Docker
If you'd rather run an isolated, throwaway Postgres + Valkey on your machine:
docker compose -f docker-compose.test.yml up -d
# In .env.test:
TEST_DATABASE_URL=postgresql://test:test@localhost:55432/halobestie_test
TEST_DB_SCHEMA=public
TEST_VALKEY_URL=redis://localhost:56379/0
npm test
docker compose -f docker-compose.test.yml down -v
The non-default ports (55432, 56379) avoid clashing with any local Postgres /
Redis you have running. Note TEST_DB_SCHEMA=public is OK in the Docker case
because the whole database is throwaway — schema isolation is only required
when sharing with the dev DB.
Safety guards
setup.jshard-fails ifTEST_DB_SCHEMA === 'public'ANDTEST_DATABASE_URLlooks like the dev DB. (Schema reuse on the dev DB would clobber dev tables.)setup.jshard-fails if any required env var is missing — silent fallback to dev URLs would be catastrophic.- The migration runs as a child process (not in-process) so its
sql.end()at the bottom doesn't tear down the singleton this test process shares with services.