Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*` keys in app_config with dedicated `pricing_tiers` and `pricing_promotions` tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes) natural-key constraint, optimistic-lock via `updated_at` token returning 409 STALE_WRITE on conflicts. Every mutation writes a history row capturing the operator (changed_by from request.auth.userId) and change_kind. CC SettingsPage replaces the JSON-textarea editors with per-row tables — add / edit / soft-delete / reactivate / reorder, plus a buffered first-session discount form with the same optimistic-lock contract. `minutes` and `mode` are read-only on edit since they form the natural key; operators soft-delete and recreate to change duration. Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local `readDiscountConfig` that still read from app_config — would have silently fallen to hardcoded defaults once the legacy rows were deleted. Now reads from pricing_promotions via the shared service helper, so CC edits to the first- session discount affect actual payment pricing on the next request. Customer-facing GET /api/client/chat/pricing shape unchanged (id values are now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so no app changes needed). 27 new backend tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <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.