Files
halobestie-clone/control_center/tests/e2e/helpers/backend-api.js
ramadhan sjamsani d09e50af55 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>
2026-05-03 23:02:49 +08:00

165 lines
6.1 KiB
JavaScript

/**
* 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
}