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:
@@ -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
|
||||
|
||||
5
control_center/.gitignore
vendored
5
control_center/.gitignore
vendored
@@ -2,3 +2,8 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
78
control_center/package-lock.json
generated
78
control_center/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
72
control_center/playwright.config.js
Normal file
72
control_center/playwright.config.js
Normal 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
|
||||
],
|
||||
})
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
46
control_center/src/core/constants.js
Normal file
46
control_center/src/core/constants.js
Normal 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',
|
||||
})
|
||||
257
control_center/src/pages/failed-pairings/FailedPairingsPage.jsx
Normal file
257
control_center/src/pages/failed-pairings/FailedPairingsPage.jsx
Normal 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,
|
||||
}
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
160
control_center/tests/e2e/README.md
Normal file
160
control_center/tests/e2e/README.md
Normal 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)
|
||||
```
|
||||
96
control_center/tests/e2e/failed-pairings.spec.js
Normal file
96
control_center/tests/e2e/failed-pairings.spec.js
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
52
control_center/tests/e2e/helpers/auth.js
Normal file
52
control_center/tests/e2e/helpers/auth.js
Normal 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/)
|
||||
}
|
||||
164
control_center/tests/e2e/helpers/backend-api.js
Normal file
164
control_center/tests/e2e/helpers/backend-api.js
Normal 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
|
||||
}
|
||||
138
control_center/tests/e2e/settings.spec.js
Normal file
138
control_center/tests/e2e/settings.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user