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:
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