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

@@ -1,2 +1,32 @@
# Internal API base URL — accessible via VPN only
# =============================================================================
# Control Center — environment variables
# =============================================================================
# Copy this file to `.env` and fill in your local values. `.env` is gitignored.
#
# Two sets of vars live here:
# 1. Vite build-time vars (VITE_*) read by the React app at dev/build time.
# 2. Playwright test runner vars read only by the e2e test process.
# =============================================================================
# -----------------------------------------------------------------------------
# Vite (read by the SPA itself)
# -----------------------------------------------------------------------------
# Internal API base URL — accessible via VPN only.
VITE_API_BASE_URL=https://internal.halobestie.com
# -----------------------------------------------------------------------------
# Playwright e2e tests (read by `npm run test:e2e`)
# -----------------------------------------------------------------------------
# Where the CC dev server is reachable (the SPA itself). Override to point at
# a CC dev server running on another machine.
CC_BASE_URL=http://localhost:5173
# Where the internal backend listener is reachable. Used by test setup
# helpers that mint test JWTs / seed fixtures via the internal API.
BACKEND_INTERNAL_URL=http://localhost:3001
# Test CC user credentials — must already exist in the control_center_users
# table on the target backend. Use the seeded admin (admin@halobestie.com /
# ChangeMe123!) for local dev, or provision a dedicated test operator.
CC_TEST_EMAIL=test-operator@example.com
CC_TEST_PASSWORD=changeme

View File

@@ -2,3 +2,8 @@ node_modules/
dist/
.env
*.log
# Playwright
test-results/
playwright-report/
playwright/.cache/

View File

@@ -15,9 +15,11 @@
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"dotenv": "^17.4.2",
"vite": "^5.3.1"
}
},
@@ -745,6 +747,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1391,6 +1409,19 @@
"node": ">=0.4.0"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1788,6 +1819,53 @@
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",

View File

@@ -6,19 +6,25 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:headed": "HEADED=1 playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "PWDEBUG=1 playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.45.1",
"axios": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"axios": "^1.7.2",
"@tanstack/react-query": "^5.45.1"
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"dotenv": "^17.4.2",
"vite": "^5.3.1"
}
}

View File

@@ -0,0 +1,72 @@
import { defineConfig, devices } from '@playwright/test'
import dotenv from 'dotenv'
// Load `.env` so operators can put CC_TEST_EMAIL / CC_TEST_PASSWORD /
// CC_BASE_URL / BACKEND_INTERNAL_URL there instead of remembering to export
// them in every shell. CLI env vars still win — dotenv does not override
// already-set process.env values.
dotenv.config()
/**
* Playwright configuration for the Halo Bestie Control Center.
*
* Cross-machine flexibility is the priority: nothing here is hardcoded to a
* specific host. The operator (or CI) starts the CC dev server + backend
* separately and points these env vars at wherever they're reachable.
*
* Required env vars (with sensible local defaults):
* CC_BASE_URL — where the CC SPA is served (default: http://localhost:5173)
* BACKEND_INTERNAL_URL — where the internal Fastify listener is served
* (default: http://localhost:3001) — used by helpers,
* not by Playwright directly.
* CC_TEST_EMAIL — control-center user email (default: placeholder)
* CC_TEST_PASSWORD — control-center user password (default: placeholder)
*
* Optional toggles:
* HEADED=1 — run with a visible browser (also: --headed CLI flag)
* RECORD=1 — record video for every test (default: off)
* PWDEBUG=1 — Playwright's built-in inspector
*
* NOTE: There is intentionally no `webServer` block — we never auto-start
* the CC dev server. The operator controls when/where it runs so the same
* Playwright suite can target a remote dev server on another machine.
*/
const CC_BASE_URL = process.env.CC_BASE_URL || 'http://localhost:5173'
const RECORD_VIDEO = process.env.RECORD === '1'
export default defineConfig({
testDir: './tests/e2e',
outputDir: './test-results',
timeout: 30_000,
expect: { timeout: 5_000 },
// CC tests touch shared backend state (config rows, fixtures) — keep
// serial by default to avoid flakes from parallel mutation.
workers: 1,
fullyParallel: false,
reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }],
],
use: {
baseURL: CC_BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: RECORD_VIDEO ? 'on' : 'off',
actionTimeout: 10_000,
navigationTimeout: 15_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// To add firefox/webkit later:
// 1. npx playwright install firefox webkit
// 2. Add { name: 'firefox', use: { ...devices['Desktop Firefox'] } } here
],
})

View File

@@ -7,6 +7,7 @@ import SessionsPage from './pages/sessions/SessionsPage'
import UsersPage from './pages/users/UsersPage'
import SettingsPage from './pages/settings/SettingsPage'
import MitraActivityPage from './pages/mitra-activity/MitraActivityPage'
import FailedPairingsPage from './pages/failed-pairings/FailedPairingsPage'
import Layout from './components/Layout'
const ProtectedRoute = ({ children }) => {
@@ -24,6 +25,7 @@ export default function App() {
<Route path="dashboard" element={<DashboardPage />} />
<Route path="mitras" element={<MitrasPage />} />
<Route path="sessions" element={<SessionsPage />} />
<Route path="failed-pairings" element={<FailedPairingsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="mitra-activity" element={<MitraActivityPage />} />

View File

@@ -63,6 +63,7 @@ export default function Layout() {
<li><NavLink to="/dashboard">Dashboard</NavLink></li>
<li><NavLink to="/mitras">Mitra</NavLink></li>
<li><NavLink to="/sessions">Sesi</NavLink></li>
<li><NavLink to="/failed-pairings">Failed Pairings</NavLink></li>
<li><NavLink to="/users">Users</NavLink></li>
<li><NavLink to="/mitra-activity">Aktivitas Mitra</NavLink></li>
<li><NavLink to="/settings">Settings</NavLink></li>

View File

@@ -0,0 +1,46 @@
// Frontend mirror of selected backend enums (backend/src/constants.js).
// Keep in sync when new values are added on the server.
// Pairing failure cause tags — used by the Failed Pairings screen filter.
export const PairingFailureCause = Object.freeze({
NO_MITRA_AVAILABLE: 'no_mitra_available',
ALL_MITRAS_REJECTED: 'all_mitras_rejected',
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
PAYMENT_SESSION_EXPIRED: 'payment_session_expired',
CUSTOMER_CANCELLED: 'customer_cancelled',
EXTENSION_REJECTED: 'extension_rejected',
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',
})
export const PairingFailureCauseLabel = Object.freeze({
[PairingFailureCause.NO_MITRA_AVAILABLE]: 'No mitra available',
[PairingFailureCause.ALL_MITRAS_REJECTED]: 'All mitras rejected',
[PairingFailureCause.TARGETED_MITRA_OFFLINE]: 'Targeted mitra offline',
[PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected',
[PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout',
[PairingFailureCause.PAYMENT_SESSION_EXPIRED]: 'Payment session expired',
[PairingFailureCause.CUSTOMER_CANCELLED]: 'Customer cancelled',
[PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected',
[PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped',
})
// Operator actions on a failed-pairing row.
export const PairingFailureOperatorAction = Object.freeze({
REFUNDED: 'refunded',
CREDITED: 'credited',
NO_ACTION: 'no_action',
})
export const PairingFailureOperatorActionLabel = Object.freeze({
[PairingFailureOperatorAction.REFUNDED]: 'Refunded',
[PairingFailureOperatorAction.CREDITED]: 'Credited',
[PairingFailureOperatorAction.NO_ACTION]: 'No Action',
})
// Default action when the mitra fails to respond to an extension request in time.
export const ExtensionTimeoutAction = Object.freeze({
AUTO_APPROVE: 'auto_approve',
AUTO_REJECT: 'auto_reject',
})

View File

@@ -0,0 +1,257 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
import {
PairingFailureCause,
PairingFailureCauseLabel,
PairingFailureOperatorAction,
PairingFailureOperatorActionLabel,
} from '../../core/constants'
const PAGE_SIZE = 50
const CAUSE_OPTIONS = Object.values(PairingFailureCause).map((value) => ({
value,
label: PairingFailureCauseLabel[value],
}))
const fetchFailedPairings = async ({ causeTags, dateFrom, dateTo, limit, offset }) => {
const params = new URLSearchParams()
for (const tag of causeTags) params.append('cause_tags', tag)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
params.set('limit', String(limit))
params.set('offset', String(offset))
const res = await apiClient.get(`/internal/failed-pairings?${params}`)
return res.data.data
}
const submitOperatorAction = async ({ id, action }) => {
const res = await apiClient.post(`/internal/failed-pairings/${id}/action`, { action })
return res.data.data
}
const formatRupiah = (amount) => {
if (amount === null || amount === undefined) return '-'
return `Rp ${Number(amount).toLocaleString('id-ID')}`
}
const formatDateTime = (iso) => {
if (!iso) return '-'
return new Date(iso).toLocaleString('id-ID')
}
const operatorActionLabel = (row) => {
if (!row.operator_action) return '-'
return PairingFailureOperatorActionLabel[row.operator_action] ?? row.operator_action
}
export default function FailedPairingsPage() {
const queryClient = useQueryClient()
const [selectedCauses, setSelectedCauses] = useState([])
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [page, setPage] = useState(1)
const [openMenuId, setOpenMenuId] = useState(null)
const offset = (page - 1) * PAGE_SIZE
const { data, isLoading, isError } = useQuery({
queryKey: ['failed-pairings', selectedCauses, dateFrom, dateTo, page],
queryFn: () => fetchFailedPairings({
causeTags: selectedCauses,
dateFrom: dateFrom || null,
dateTo: dateTo || null,
limit: PAGE_SIZE,
offset,
}),
keepPreviousData: true,
})
const actionMutation = useMutation({
mutationFn: submitOperatorAction,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['failed-pairings'] })
setOpenMenuId(null)
},
})
const toggleCause = (value) => {
setPage(1)
setSelectedCauses((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
)
}
const clearFilters = () => {
setSelectedCauses([])
setDateFrom('')
setDateTo('')
setPage(1)
}
const total = data?.total ?? 0
const rows = data?.rows ?? []
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div>
<h1>Failed Pairings</h1>
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<strong style={{ marginRight: 8 }}>Cause:</strong>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 }}>
{CAUSE_OPTIONS.map((opt) => (
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13 }}>
<input
type="checkbox"
checked={selectedCauses.includes(opt.value)}
onChange={() => toggleCause(opt.value)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>From:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
/>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<label style={{ fontSize: 13 }}>To:</label>
<input
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
/>
</div>
<button onClick={clearFilters} style={{ fontSize: 12 }}>Clear filters</button>
</div>
</div>
{isLoading && <div>Loading...</div>}
{isError && <p style={{ color: 'red' }}>Gagal memuat data failed pairings.</p>}
{!isLoading && !isError && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Created</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Targeted Mitra</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Cause</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Amount</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Operator Action</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned By</th>
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned At</th>
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={9} style={{ padding: 24, textAlign: 'center', color: '#666' }}>
Belum ada data failed pairings.
</td>
</tr>
)}
{rows.map((row) => {
const canAction = !row.operator_action
return (
<tr key={row.id}>
<td style={{ padding: 8 }}>{formatDateTime(row.created_at)}</td>
<td style={{ padding: 8 }}>{row.customer_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>{row.targeted_mitra_call_name ?? '-'}</td>
<td style={{ padding: 8 }}>
{PairingFailureCauseLabel[row.cause_tag] ?? row.cause_tag}
</td>
<td style={{ padding: 8 }}>{formatRupiah(row.amount)}</td>
<td style={{ padding: 8 }}>{operatorActionLabel(row)}</td>
<td style={{ padding: 8 }}>{row.actioned_by_name ?? '-'}</td>
<td style={{ padding: 8 }}>{formatDateTime(row.actioned_at)}</td>
<td style={{ padding: 8, position: 'relative' }}>
{canAction ? (
<>
<button
onClick={() => setOpenMenuId(openMenuId === row.id ? null : row.id)}
disabled={actionMutation.isPending}
style={{ fontSize: 12 }}
>
Action
</button>
{openMenuId === row.id && (
<div style={{
position: 'absolute',
right: 8,
top: '100%',
background: 'white',
border: '1px solid #ddd',
boxShadow: '0 2px 6px rgba(0,0,0,0.08)',
zIndex: 10,
minWidth: 180,
}}>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.REFUNDED })}
>
Mark as refunded
</button>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.CREDITED })}
>
Mark as credited
</button>
<button
style={menuItemStyle}
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.NO_ACTION })}
>
Mark as no-action
</button>
</div>
)}
</>
) : (
<span style={{ color: '#999', fontSize: 12 }}></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
<span>Page {page} of {totalPages} ({total} total)</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</>
)}
{actionMutation.isError && (
<p style={{ color: 'red', marginTop: 8 }}>Gagal menyimpan operator action.</p>
)}
</div>
)
}
const menuItemStyle = {
display: 'block',
width: '100%',
padding: '8px 12px',
background: 'white',
border: 'none',
borderBottom: '1px solid #f0f0f0',
textAlign: 'left',
cursor: 'pointer',
fontSize: 13,
}

View File

@@ -49,12 +49,12 @@ export default function LoginPage() {
<h2>Control Center</h2>
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
<label htmlFor="cc-login-email">Email</label>
<input id="cc-login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
<div>
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
<label htmlFor="cc-login-password">Password</label>
<input id="cc-login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={loading} style={{ width: '100%' }}>

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../../core/api/api-client'
import { ExtensionTimeoutAction } from '../../core/constants'
const fetchAnonymityConfig = async () => {
const res = await apiClient.get('/internal/config/anonymity')
@@ -74,6 +75,47 @@ const updateSensitivityConfig = async (data) => {
return res.data.data
}
// Paid pairing flow + extension flip
const fetchPairingBlastTimeout = async () => {
const res = await apiClient.get('/internal/config/pairing-blast-timeout')
return res.data.data
}
const updatePairingBlastTimeout = async (pairing_blast_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/pairing-blast-timeout', { pairing_blast_timeout_seconds })
return res.data.data
}
const fetchPaymentSessionTimeout = async () => {
const res = await apiClient.get('/internal/config/payment-session-timeout')
return res.data.data
}
const updatePaymentSessionTimeout = async (payment_session_timeout_minutes) => {
const res = await apiClient.patch('/internal/config/payment-session-timeout', { payment_session_timeout_minutes })
return res.data.data
}
const fetchReturningChatTimeout = async () => {
const res = await apiClient.get('/internal/config/returning-chat-timeout')
return res.data.data
}
const updateReturningChatTimeout = async (returning_chat_confirmation_timeout_seconds) => {
const res = await apiClient.patch('/internal/config/returning-chat-timeout', { returning_chat_confirmation_timeout_seconds })
return res.data.data
}
const fetchExtensionDefaultAction = async () => {
const res = await apiClient.get('/internal/config/extension-default-action')
return res.data.data
}
const updateExtensionDefaultAction = async (extension_default_action_on_timeout) => {
const res = await apiClient.patch('/internal/config/extension-default-action', { extension_default_action_on_timeout })
return res.data.data
}
export default function SettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
@@ -143,7 +185,50 @@ export default function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-sensitivity'] }),
})
if (isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading) return <div>Loading...</div>
// Pairing Blast Timeout
const { data: pbtData, isLoading: pbtLoading } = useQuery({
queryKey: ['config-pairing-blast-timeout'],
queryFn: fetchPairingBlastTimeout,
})
const pbtMutation = useMutation({
mutationFn: updatePairingBlastTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pairing-blast-timeout'] }),
})
// Payment Session Timeout
const { data: pstData, isLoading: pstLoading } = useQuery({
queryKey: ['config-payment-session-timeout'],
queryFn: fetchPaymentSessionTimeout,
})
const pstMutation = useMutation({
mutationFn: updatePaymentSessionTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-payment-session-timeout'] }),
})
// Returning Chat Confirmation Timeout
const { data: rctData, isLoading: rctLoading } = useQuery({
queryKey: ['config-returning-chat-timeout'],
queryFn: fetchReturningChatTimeout,
})
const rctMutation = useMutation({
mutationFn: updateReturningChatTimeout,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-returning-chat-timeout'] }),
})
// Extension Default Action on Timeout
const { data: edaData, isLoading: edaLoading } = useQuery({
queryKey: ['config-extension-default-action'],
queryFn: fetchExtensionDefaultAction,
})
const edaMutation = useMutation({
mutationFn: updateExtensionDefaultAction,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }),
})
if (
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
pbtLoading || pstLoading || rctLoading || edaLoading
) return <div>Loading...</div>
return (
<div>
@@ -320,6 +405,94 @@ export default function SettingsPage() {
</p>
{senMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Blast Pairing</h2>
<p>Berapa lama backend menunggu mitra menerima sebelum blast dianggap gagal.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={pbtData?.pairing_blast_timeout_seconds ?? 60}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) pbtMutation.mutate(val)
}}
disabled={pbtMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{pbtMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Sesi Pembayaran</h2>
<p>Sesi pembayaran customer akan kedaluwarsa otomatis setelah durasi ini jika belum dikonfirmasi atau belum menghasilkan chat.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="1"
value={pstData?.payment_session_timeout_minutes ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 1) pstMutation.mutate(val)
}}
disabled={pstMutation.isPending}
style={{ width: 80 }}
/>
<span>menit</span>
</div>
{pstMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Batas Waktu Konfirmasi Chat Lanjutan</h2>
<p>Berapa detik bestie punya waktu untuk menerima/menolak permintaan chat lanjutan dari customer sebelum otomatis ditolak.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="number"
min="5"
value={rctData?.returning_chat_confirmation_timeout_seconds ?? 20}
onChange={e => {
const val = parseInt(e.target.value, 10)
if (val >= 5) rctMutation.mutate(val)
}}
disabled={rctMutation.isPending}
style={{ width: 80 }}
/>
<span>detik</span>
</div>
{rctMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
<section style={{ marginBottom: 24 }}>
<h2>Aksi Default jika Bestie Tidak Menjawab Extension</h2>
<p>Jika bestie tidak merespon permintaan extension dalam batas waktu, sistem akan otomatis menerima atau menolak sesuai pilihan ini.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_APPROVE}
checked={(edaData?.extension_default_action_on_timeout ?? ExtensionTimeoutAction.AUTO_APPROVE) === ExtensionTimeoutAction.AUTO_APPROVE}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_APPROVE)}
disabled={edaMutation.isPending}
/>
Otomatis disetujui (auto-approve)
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="radio"
name="extension_default_action_on_timeout"
value={ExtensionTimeoutAction.AUTO_REJECT}
checked={edaData?.extension_default_action_on_timeout === ExtensionTimeoutAction.AUTO_REJECT}
onChange={() => edaMutation.mutate(ExtensionTimeoutAction.AUTO_REJECT)}
disabled={edaMutation.isPending}
/>
Otomatis ditolak (auto-reject)
</label>
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section>
</div>
)
}

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')
})
})
})