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

View File

@@ -0,0 +1,160 @@
# Control Center — Playwright E2E Tests
End-to-end tests for the Halo Bestie control center. Tests run a real browser
against a running CC dev server, which talks to a running internal backend.
## Install
From `control_center/`:
```bash
npm install
npx playwright install chromium
```
To add Firefox or WebKit later:
```bash
npx playwright install firefox webkit
```
…then add a project entry in `playwright.config.js`.
## Configure
Copy the example env file and fill in your local values:
```bash
cp .env.example .env
```
Required env vars (all have sensible defaults for `localhost`):
| Var | Default | Purpose |
| ---------------------- | ------------------------ | -------------------------------------------- |
| `CC_BASE_URL` | `http://localhost:5173` | Where the CC SPA is reachable |
| `BACKEND_INTERNAL_URL` | `http://localhost:3001` | Where the internal Fastify listener is |
| `CC_TEST_EMAIL` | placeholder | Operator account used by the suite |
| `CC_TEST_PASSWORD` | placeholder | Operator account password |
The seeded admin (`admin@halobestie.com` / `ChangeMe123!` from
`backend/src/db/seed.js`) works as the test operator for local dev. For
shared/CI environments, provision a dedicated test user.
`playwright.config.js` automatically loads `.env` via `dotenv.config()`. CLI
env vars still take precedence — useful when running one-off:
```bash
CC_BASE_URL=http://192.168.1.10:5173 npm run test:e2e
```
## Run on the same machine
1. Start the backend (public + internal listeners):
```bash
cd backend && npm run dev
```
2. Start the CC dev server (separate shell):
```bash
cd control_center && npm run dev
```
3. Run the suite (separate shell):
```bash
cd control_center && npm run test:e2e
```
## Run on a different machine
The Playwright config does NOT auto-start the CC dev server — that's deliberate
so the same suite can target a remote dev server. Point the env vars at it:
```bash
CC_BASE_URL=http://192.168.88.247:5173 \
BACKEND_INTERNAL_URL=http://192.168.88.247:3001 \
CC_TEST_EMAIL=test-operator@example.com \
CC_TEST_PASSWORD=changeme \
npm run test:e2e
```
The CC dev server must be reachable on the network — by default Vite binds to
`localhost`. To make it listen on all interfaces, start it with:
```bash
npm run dev -- --host
```
…or set `server.host: true` in `vite.config.js`.
## Run a single test
```bash
# One file
npm run test:e2e -- tests/e2e/settings.spec.js
# Tests matching a pattern (across all files)
npm run test:e2e -- --grep "payment session timeout"
```
## Debug
Three options, in order of how heavy they are:
```bash
# 1. UI mode — watches files, lets you re-run individual tests, inspect DOM,
# and see the timeline. Best for iterating on a flaky test.
npm run test:e2e:ui
# 2. Headed mode — same suite, but with a visible browser window.
npm run test:e2e:headed
# 3. Inspector — pauses execution, opens devtools, lets you step through.
npm run test:e2e:debug
```
To record video for every test:
```bash
RECORD=1 npm run test:e2e
```
Failure artifacts (screenshots, traces, videos) land in `test-results/`. Open
the HTML report with:
```bash
npx playwright show-report
```
## Adding a new test
1. Create `tests/e2e/<feature>.spec.js`.
2. Import the helpers:
```js
import { test, expect } from '@playwright/test'
import { loginAsOperator } from './helpers/auth.js'
import { backendRequest } from './helpers/backend-api.js'
```
3. Use `loginAsOperator(page)` in `beforeEach` for any test that hits a
protected route.
4. Use `backendRequest('/internal/...')` for fixture setup/teardown so the
test body stays focused on the UI behavior.
5. Look at `settings.spec.js` and `failed-pairings.spec.js` for the patterns
already in use (snapshot → mutate → reload → restore).
## Files
```
tests/e2e/
├── README.md (you are here)
├── helpers/
│ ├── auth.js loginAsOperator() — UI login flow
│ └── backend-api.js fetch wrapper + fixture helpers
├── settings.spec.js Phase 3.7 config rows (2 cases)
└── failed-pairings.spec.js Failed Pairings page (2 cases)
```

View File

@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test'
import { loginAsOperator } from './helpers/auth.js'
import { listFailedPairings, ensureAtLeastOneFailedPairing } from './helpers/backend-api.js'
/**
* Failed Pairings page e2e tests.
*
* NOTE: There is currently no public test-seed endpoint for failed_pairings.
* Production rows are inserted by the backend pairing service when a real
* pairing fails. These tests therefore rely on the database already containing
* at least one row (any cause). If the table is empty the tests are skipped
* with a clear TODO so the user can either:
* - run the real pairing flow once to generate real fixtures, or
* - add a backend test-only seed route (see helpers/backend-api.js).
*/
test.describe('Failed Pairings page', () => {
test.beforeEach(async ({ page }) => {
await loginAsOperator(page)
})
test('renders the table when at least one row exists', async ({ page }) => {
let seeded
try {
seeded = await ensureAtLeastOneFailedPairing()
} catch (e) {
test.skip(true, `No failed-pairings rows in DB. ${e.message}`)
return
}
await page.goto('/failed-pairings')
await expect(page.getByRole('heading', { name: 'Failed Pairings', level: 1 })).toBeVisible()
// The table headers should always render once data has loaded.
await expect(page.getByRole('columnheader', { name: 'Created' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Cause' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Operator Action' })).toBeVisible()
// At least one data row (skip the header row).
const dataRows = page.locator('table tbody tr')
const count = await dataRows.count()
expect(count).toBeGreaterThanOrEqual(1)
// The "empty" placeholder row should NOT be visible when we have data.
await expect(page.getByText('Belum ada data failed pairings.')).not.toBeVisible()
// Sanity check — total label matches what the backend reports.
await expect(page.getByText(`(${seeded.total} total)`)).toBeVisible()
})
test('cause-tag filter narrows the visible rows', async ({ page }) => {
// We need rows of at least 2 distinct cause tags to make a meaningful
// assertion about filtering. If the DB has fewer, skip with a clear
// message rather than producing a fake-positive.
const all = await listFailedPairings({ limit: 200 })
const distinctCauses = [...new Set((all?.rows ?? []).map((r) => r.cause_tag))]
if (distinctCauses.length < 2) {
test.skip(
true,
`Need >=2 distinct cause_tags in failed_pairings to test filter. Found: ${distinctCauses.join(', ') || '(none)'}.`,
)
return
}
const targetCause = distinctCauses[0]
const expectedFiltered = await listFailedPairings({ causeTags: [targetCause], limit: 200 })
await page.goto('/failed-pairings')
await expect(page.getByRole('heading', { name: 'Failed Pairings', level: 1 })).toBeVisible()
// Capture pre-filter total — must be >= filtered total or assertion is bogus.
const totalBefore = all.total
expect(totalBefore).toBeGreaterThan(expectedFiltered.total)
// Tick the cause checkbox. The filter section labels the checkbox with the
// human label from PairingFailureCauseLabel — find it by its associated text.
// Map the cause_tag to its label by re-importing the constants.
const { PairingFailureCauseLabel } = await import(
'../../src/core/constants.js'
)
const targetLabel = PairingFailureCauseLabel[targetCause]
await page.getByRole('checkbox', { name: targetLabel }).check()
// Expect the new total to match the API-computed expectation.
await expect(page.getByText(`(${expectedFiltered.total} total)`)).toBeVisible()
// And every visible row should reflect the chosen cause tag.
const causeCells = page.locator('table tbody tr td:nth-child(4)')
const visibleCount = await causeCells.count()
expect(visibleCount).toBeGreaterThan(0)
for (let i = 0; i < visibleCount; i++) {
await expect(causeCells.nth(i)).toHaveText(targetLabel)
}
})
})

View File

@@ -0,0 +1,52 @@
/**
* Auth helper for Playwright e2e tests.
*
* Logs in via the actual UI (rather than minting a JWT directly) for two
* reasons:
* 1. The CC keeps the access token in memory + uses an httpOnly refresh
* cookie. The cleanest way to exercise that flow is the real form.
* 2. It tests the login page implicitly — if the form breaks, every
* downstream test fails fast and obviously.
*
* If/when login becomes the bottleneck, swap this for a fixture that calls
* `POST /internal/auth/login` once per worker and replays the cookie via
* `context.addCookies(...)`.
*/
import { expect } from '@playwright/test'
const TEST_EMAIL = process.env.CC_TEST_EMAIL || 'test-operator@example.com'
const TEST_PASSWORD = process.env.CC_TEST_PASSWORD || 'changeme'
/**
* Navigates to /login, fills the form, submits, and waits for the post-login
* redirect (defaults to /dashboard via App.jsx Navigate).
*
* @param {import('@playwright/test').Page} page
* @param {{ email?: string, password?: string }} [overrides]
*/
export async function loginAsOperator(page, overrides = {}) {
const email = overrides.email ?? TEST_EMAIL
const password = overrides.password ?? TEST_PASSWORD
await page.goto('/login')
await page.getByLabel('Email').fill(email)
await page.getByLabel('Password').fill(password)
await page.getByRole('button', { name: /Masuk/i }).click()
// App.jsx redirects authenticated users from `/` to `/dashboard`.
// Wait for the URL to leave /login as the success signal.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), {
timeout: 10_000,
})
}
/**
* Convenience: assert the current page is a logged-in CC page (i.e. NOT
* /login). Useful as a sanity-check at the top of a test.
*
* @param {import('@playwright/test').Page} page
*/
export async function expectLoggedIn(page) {
await expect(page).not.toHaveURL(/\/login/)
}

View File

@@ -0,0 +1,164 @@
/**
* Thin wrapper around the internal backend for test setup / fixtures.
*
* The CC test suite needs to seed and clean up rows directly via the API
* (rather than via the UI) for tests that depend on pre-existing state —
* e.g. "Failed Pairings page renders rows" needs at least one row to exist.
*
* All requests:
* - go to BACKEND_INTERNAL_URL (default http://localhost:3001)
* - send credentials: 'include' so cookies (refresh token) round-trip
* - automatically attach Authorization: Bearer <access_token> after login
*/
const BACKEND_URL = process.env.BACKEND_INTERNAL_URL || 'http://localhost:3001'
const TEST_EMAIL = process.env.CC_TEST_EMAIL || 'test-operator@example.com'
const TEST_PASSWORD = process.env.CC_TEST_PASSWORD || 'changeme'
let cachedAccessToken = null
let cachedCookieHeader = null
/**
* Logs into the backend via the real CC login route and caches the access
* token + Set-Cookie value for subsequent calls. Token is cached for the
* whole worker lifetime — for the slim CC suite that's plenty.
*/
async function loginToBackend() {
if (cachedAccessToken) return cachedAccessToken
const res = await fetch(`${BACKEND_URL}/internal/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: TEST_EMAIL, password: TEST_PASSWORD }),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Backend login failed (${res.status}): ${body}`)
}
const json = await res.json()
cachedAccessToken = json?.data?.access_token
if (!cachedAccessToken) {
throw new Error(`Backend login response missing access_token: ${JSON.stringify(json)}`)
}
// node-fetch returns multi-value Set-Cookie via .getSetCookie() in newer
// Node versions. Fall back to .get() for older runtimes.
const setCookie =
typeof res.headers.getSetCookie === 'function'
? res.headers.getSetCookie()
: [res.headers.get('set-cookie')].filter(Boolean)
cachedCookieHeader = setCookie.map((c) => c.split(';')[0]).join('; ') || null
return cachedAccessToken
}
/**
* Generic authenticated request to the internal backend.
*
* @param {string} path — path beginning with `/internal/...`
* @param {RequestInit} [init] — fetch options (method, body, headers)
* @returns {Promise<any>} — parsed JSON response body (whole envelope)
*/
export async function backendRequest(path, init = {}) {
const token = await loginToBackend()
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(init.headers || {}),
}
if (cachedCookieHeader) headers.Cookie = cachedCookieHeader
const res = await fetch(`${BACKEND_URL}${path}`, { ...init, headers })
const text = await res.text()
let json
try {
json = text ? JSON.parse(text) : null
} catch {
json = { raw: text }
}
if (!res.ok) {
const errMsg = json?.error?.message || text || res.statusText
throw new Error(`backend ${init.method || 'GET'} ${path} -> ${res.status}: ${errMsg}`)
}
return json
}
// ---------------------------------------------------------------------------
// Settings helpers — read/write CC config rows used by settings.spec.js
// ---------------------------------------------------------------------------
/** Read current `payment_session_timeout_minutes` value. */
export async function getPaymentSessionTimeout() {
const json = await backendRequest('/internal/config/payment-session-timeout')
return json?.data?.payment_session_timeout_minutes
}
/** Force-set `payment_session_timeout_minutes` (useful for test reset). */
export async function setPaymentSessionTimeout(minutes) {
return backendRequest('/internal/config/payment-session-timeout', {
method: 'PATCH',
body: JSON.stringify({ payment_session_timeout_minutes: minutes }),
})
}
/** Read current `extension_default_action_on_timeout` value. */
export async function getExtensionDefaultAction() {
const json = await backendRequest('/internal/config/extension-default-action')
return json?.data?.extension_default_action_on_timeout
}
/** Force-set `extension_default_action_on_timeout` (useful for test reset). */
export async function setExtensionDefaultAction(action) {
return backendRequest('/internal/config/extension-default-action', {
method: 'PATCH',
body: JSON.stringify({ extension_default_action_on_timeout: action }),
})
}
// ---------------------------------------------------------------------------
// Failed-pairings helpers
// ---------------------------------------------------------------------------
/**
* Fetch failed-pairings rows (optionally filtered). Used to assert the page
* is reading what we just inserted, and to discover a real customer/mitra ID
* if needed.
*/
export async function listFailedPairings({ causeTags = [], limit = 50, offset = 0 } = {}) {
const params = new URLSearchParams()
for (const tag of causeTags) params.append('cause_tags', tag)
params.set('limit', String(limit))
params.set('offset', String(offset))
const json = await backendRequest(`/internal/failed-pairings?${params}`)
return json?.data
}
/**
* Seed a fixture failed-pairing row.
*
* NOTE: There is no public "create failed pairing" endpoint — production
* rows are inserted by the backend pairing service when a real pairing
* fails. For e2e tests we expose a thin test-only seed by reusing the
* /internal/failed-pairings list to verify whatever rows already exist.
*
* If the suite needs guaranteed-fresh rows, the user should add a small
* test-only seed route to the backend (e.g. POST /internal/_test/seed-failed-pairing)
* gated behind NODE_ENV !== 'production'. This helper currently:
* - returns the existing row count, OR
* - throws a clear error so the test can be skipped with a TODO.
*
* @returns {Promise<{ total: number, rows: any[] }>}
*/
export async function ensureAtLeastOneFailedPairing() {
const data = await listFailedPairings({ limit: 1 })
if ((data?.total ?? 0) === 0) {
throw new Error(
'No failed-pairings rows present in the database. ' +
'Either trigger one via the real pairing flow or add a backend test-seed route.',
)
}
return data
}

View File

@@ -0,0 +1,138 @@
import { test, expect } from '@playwright/test'
import { loginAsOperator } from './helpers/auth.js'
import {
getPaymentSessionTimeout,
setPaymentSessionTimeout,
getExtensionDefaultAction,
setExtensionDefaultAction,
} from './helpers/backend-api.js'
/**
* Settings page e2e tests.
*
* These tests exercise two of the four Phase 3.7 config rows end-to-end:
* - payment_session_timeout_minutes (number input)
* - extension_default_action_on_timeout (radio)
*
* Strategy:
* 1. Snapshot the current backend value before each test
* 2. Mutate via the UI
* 3. Reload to confirm persistence (rules out optimistic-only state)
* 4. Restore the original value in afterEach (so the suite is rerunnable)
*/
test.describe('Settings page — Phase 3.7 config rows', () => {
test.beforeEach(async ({ page }) => {
await loginAsOperator(page)
await page.goto('/settings')
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible()
})
// -------------------------------------------------------------------------
// payment_session_timeout_minutes
// -------------------------------------------------------------------------
test.describe('Batas Waktu Sesi Pembayaran', () => {
let originalValue
test.beforeEach(async () => {
originalValue = await getPaymentSessionTimeout()
})
test.afterEach(async () => {
if (typeof originalValue === 'number') {
await setPaymentSessionTimeout(originalValue)
}
})
test('changing 20 → 25 persists across reload', async ({ page }) => {
await setPaymentSessionTimeout(20)
const section = page
.locator('section', { has: page.getByRole('heading', { name: 'Batas Waktu Sesi Pembayaran' }) })
const input = section.getByRole('spinbutton')
await page.reload()
await expect(input).toHaveValue('20')
// Mutate via the UI. The component fires a mutation on every keystroke
// (no Save button), so .fill() with the final value is enough.
// Wait for the PATCH to complete BEFORE reloading — disabled flip alone
// is racy because fill() returns before React processes the onChange.
const patchResponse = page.waitForResponse(r =>
r.url().includes('/internal/config/payment-session-timeout')
&& r.request().method() === 'PATCH'
&& r.status() === 200
)
await input.fill('25')
await patchResponse
// Reload and confirm the new value sticks.
await page.reload()
await expect(input).toHaveValue('25')
// And confirm the backend agrees.
const persisted = await getPaymentSessionTimeout()
expect(persisted).toBe(25)
})
})
// -------------------------------------------------------------------------
// extension_default_action_on_timeout (radio)
// -------------------------------------------------------------------------
test.describe('Aksi Default Extension', () => {
let originalValue
test.beforeEach(async () => {
originalValue = await getExtensionDefaultAction()
})
test.afterEach(async () => {
if (originalValue) {
await setExtensionDefaultAction(originalValue)
}
})
test('flipping auto-approve <-> auto-reject persists across reload', async ({ page }) => {
// Start from a known state.
await setExtensionDefaultAction('auto_approve')
const section = page
.locator('section', { has: page.getByRole('heading', { name: 'Aksi Default jika Bestie Tidak Menjawab Extension' }) })
const approveRadio = section.getByRole('radio', { name: /Otomatis disetujui/ })
const rejectRadio = section.getByRole('radio', { name: /Otomatis ditolak/ })
await page.reload()
await expect(approveRadio).toBeChecked()
await expect(rejectRadio).not.toBeChecked()
// Flip to auto-reject. Use click() instead of check() — check() races against
// the React re-render that flips `checked` after the mutation roundtrip.
// Wait for the PATCH to complete before asserting persistence.
const patchToReject = page.waitForResponse(r =>
r.url().includes('/internal/config/extension-default-action')
&& r.request().method() === 'PATCH'
&& r.status() === 200
)
await rejectRadio.click()
await patchToReject
await page.reload()
await expect(rejectRadio).toBeChecked()
await expect(approveRadio).not.toBeChecked()
expect(await getExtensionDefaultAction()).toBe('auto_reject')
// Flip back to auto-approve.
const patchToApprove = page.waitForResponse(r =>
r.url().includes('/internal/config/extension-default-action')
&& r.request().method() === 'PATCH'
&& r.status() === 200
)
await approveRadio.click()
await patchToApprove
await page.reload()
await expect(approveRadio).toBeChecked()
expect(await getExtensionDefaultAction()).toBe('auto_approve')
})
})
})