Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

110
backend/test/setup.js Normal file
View File

@@ -0,0 +1,110 @@
/**
* Vitest global setup. Runs once per test file before any test bodies.
*
* Responsibilities:
* 1. Load .env.test (falls back to env vars already in process.env if missing).
* 2. Override DATABASE_URL / VALKEY_URL with the *test* equivalents BEFORE any
* backend service modules are imported — services capture `getDb()` at module
* load, so this rewrite must happen first.
* 3. Run the migration against the test schema (idempotent — single migrate.js).
* 4. Provide a `beforeEach` truncate hook for any test that imports the helper.
*
* Why this file is small: helpers live under test/helpers/* and are imported lazily
* by individual test files. This file is intentionally just env setup + migrate.
*/
import { existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { config as loadDotenv } from 'dotenv'
import { spawnSync } from 'node:child_process'
import { afterAll, beforeAll } from 'vitest'
const __dirname = dirname(fileURLToPath(import.meta.url))
const backendRoot = resolve(__dirname, '..')
// 1. Load .env.test if it exists
const envTestPath = resolve(backendRoot, '.env.test')
if (existsSync(envTestPath)) {
loadDotenv({ path: envTestPath })
}
// 2. Validate required env (fail fast — silent fallback to dev DB would be catastrophic)
const required = ['TEST_DATABASE_URL', 'TEST_VALKEY_URL', 'AUTH_JWT_SECRET']
for (const key of required) {
if (!process.env[key]) {
throw new Error(
`Missing required test env var: ${key}. Copy .env.test.example → .env.test or set it in your shell.`
)
}
}
if (process.env.AUTH_JWT_SECRET.length < 32) {
throw new Error('AUTH_JWT_SECRET must be at least 32 chars')
}
const TEST_SCHEMA = process.env.TEST_DB_SCHEMA || 'halobestie_test'
if (TEST_SCHEMA === 'public') {
// Hard guard: schema isolation only works if we don't reuse the dev `public` schema.
throw new Error(
`TEST_DB_SCHEMA must not be "public" (would clobber dev tables). ` +
`Set TEST_DB_SCHEMA to something like "halobestie_test".`
)
}
// 3. Build the schema-scoped URL used by getDb() in services.
// The `?options=-c search_path=...` query param tells Postgres to set search_path
// on every new connection. All CREATE TABLE / INSERT / SELECT then default to the
// test schema, leaving the dev `public` schema untouched.
const baseTestUrl = process.env.TEST_DATABASE_URL
const sep = baseTestUrl.includes('?') ? '&' : '?'
const scopedTestUrl = `${baseTestUrl}${sep}options=${encodeURIComponent(`-c search_path=${TEST_SCHEMA},public`)}`
// CRITICAL: rewrite the env vars services read at module load time. Must happen before
// any `import { ... } from '../src/services/...'` in a test or helper.
process.env.DATABASE_URL = scopedTestUrl
process.env.VALKEY_URL = process.env.TEST_VALKEY_URL
beforeAll(async () => {
// Ensure the schema exists. Use a one-shot connection that's NOT the singleton.
const { default: postgres } = await import('postgres')
const bootstrap = postgres(process.env.TEST_DATABASE_URL)
try {
await bootstrap`CREATE SCHEMA IF NOT EXISTS ${bootstrap(TEST_SCHEMA)}`
} finally {
await bootstrap.end()
}
// Run the migration via a child process so we don't conflict with the singleton
// sql client this test process will use. migrate.js calls sql.end() at the bottom,
// which would tear down the shared client if invoked in-process.
const result = spawnSync(
process.execPath,
[resolve(backendRoot, 'src/db/migrate.js')],
{
env: {
...process.env,
DATABASE_URL: scopedTestUrl,
},
cwd: backendRoot,
encoding: 'utf8',
}
)
if (result.status !== 0) {
throw new Error(
`Test migration failed (exit ${result.status}):\n` +
`stdout: ${result.stdout}\nstderr: ${result.stderr}`
)
}
}, 60_000)
afterAll(async () => {
// Best-effort cleanup of any singletons opened by the test process. Each helper that
// opens a connection registers its own teardown; this is a safety net.
try {
const { getDb } = await import('../src/db/client.js')
const sql = getDb()
await sql.end({ timeout: 5 })
} catch {
// Singleton was never created — fine.
}
})