- 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>
17 KiB
Phase 3.7 Testing Checklist
End-to-end verification for Phase 3.7 (Paid Pairing Flow + Returning-Chat + Extension Flip). Tick boxes as you verify. Cluster labels: [BE] backend / curl, [CC] control_center, [M] mitra_app, [C] client_app.
Related docs: phase3.7.md, phase3.7-plan.md, phase3.7-questions.md.
Backend curl smoke (a–n) was executed in Stage 2 and re-validated after the
/simplifyTier 1 bug fixes. This document focuses on the real-device E2E layer that the agent sandbox couldn't run, plus the regression checks for the bugs caught in/simplify.
Setup
- Backend running on
192.168.88.247:3000(public) +:3001(internal) — verify withcurl http://192.168.88.247:3000/health - Migrate has been re-run after Phase 3.7 (
payment_sessions,pairing_failures,chat_sessions.payment_session_id,idx_chat_sessions_mitra_statusall present) - Seed has been re-run / 4 new app_config keys exist:
payment_session_timeout_minutes=20,returning_chat_confirmation_timeout_seconds=20,extension_default_action_on_timeout=auto_approve,pairing_blast_timeout_seconds=60 - Existing
extension_timeout_secondsis set to10(default flip in 3.7 — manual update needed on existing dev DBs) - Both apps built with
--dart-define=API_BASE_URL=http://192.168.88.247:3000and installed on real devices (or 1 device + 1 emulator) - Control center reachable in browser (port 3001 or whatever the dev URL is) and CC operator logged in
- At least 2 mitra accounts available (1 to be online by default, 1 to flip during tests)
Section A — Home CTA Gating + 5s Availability Poll
- [C] No mitra online → "Mulai Curhat" CTA is greyed-out with subtitle "Belum ada bestie tersedia"
- [C] Mitra goes online → CTA enables within 5 seconds (check the polling cadence in backend logs)
- [C] Last mitra hits capacity (start
max_customers_per_mitrachats with that mitra) → CTA flips disabled within 5 seconds - [C] Background the app for 60s → poll pauses (no per-5s log entries from this device); foreground → immediate poll fires
- [C] Pull-to-refresh on home → manual one-shot poll fires
- [C] Kill backend (or disconnect WiFi) → CTA flips disabled within 5s (poll-fail default per PRD §1.3)
- [C] Customer UI never displays the count — only the binary state (PRD §1.5)
- [BE] Backend logs confirm
mitra-availabilitydoes NOT issue a Postgres query on every poll — only once per 10s TTL window (thecountAvailableMitrasFromCache()cache backstop)
Section B — Payment Screen + Happy-Path Blast
- [C] Tap CTA →
/paymentroute renders the tier picker - [C] Paid tier: pick a tier → "Bayar" CTA → searching screen → mitra accepts → chat starts
- [BE] After chat starts:
payment_sessions.status = consumed,chat_sessions.payment_session_idis set - [C] Free trial-eligible customer: payment screen shows "Gratis" / Rp 0 → "Mulai" CTA → searching → chat
- [BE] Free-trial flow creates a
payment_sessionsrow withamount=0, is_free_trial=true - [C] Customer hits Android back button on payment screen →
cancelIfPendingfires (verifypayment_sessions.status = abandonedserver-side) - [C] Customer abandons payment screen, waits >20 minutes → row auto-expires (
payment_sessions.status = expired) via the 60s sweeper - [C] Customer confirms payment, then no mitra accepts within blast window (60s default) → terminal screen with copy "Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera." + "Kembali ke beranda" CTA
- [BE] Above produces a
pairing_failuresrow taggedno_mitra_available+payment_sessions.status = failed_pairing - [C] Customer confirms, all blasted mitras explicitly decline → terminal screen
- [BE] Above produces a row tagged
all_mitras_rejected
Section C — Customer Cancel (Regression for /simplify Tier 1 Bug Fix #3)
- [C] Customer hits "Batalkan" mid-search → routes to home, NOT to the failed-pairing terminal screen
- [C] No FCM "Sesi gagal" notification fires for the canceller's own action (test with app backgrounded — kill the app, send the cancel from another device, verify no FCM lands)
- [BE] Cancel still produces a
pairing_failuresrow taggedcustomer_cancelledfor CC visibility - [BE] Backend logs show no
PAIRING_FAILEDWS push fromcancelPairingRequest/cancelPaymentSearchpaths
Section D — Returning Chat ("Curhat lagi")
- [C] Chat history row has "Curhat lagi" CTA on every entry
- [C] Tap "Curhat lagi" → payment screen with
targeted_mitra_idbaked in → confirm → searching screen with targeted-waiting overlay showing mitra name + countdown - [C] Countdown starts from
confirmation_timeout_secondsvalue returned by backend (regression for/simplifyTier 1 fix #6 — was hardcoded to 20) - [CC] Change
returning_chat_confirmation_timeout_secondsfrom 20 → 7 in CC; next "Curhat lagi" overlay starts at 7 - [C] Mitra accepts within window → chat starts (
payment_sessions.status = consumed) - [BE] Verify exactly one
chat_sessionsrow, FK'd to the original payment session - [C] Mitra ignores the card → 20s elapses → bestie-unavailable popup ("Bestie sedang tidak online" + "Chat dengan bestie lain" + "Kembali")
- [BE] Above produces
pairing_failuresrow taggedtargeted_mitra_timeout; payment session staysconfirmed(intermediate failure — verify via SQL) - [C] Mitra explicitly declines within window → bestie-unavailable popup
- [BE] Above produces row tagged
targeted_mitra_rejected; payment stillconfirmed - [C] Tap "Chat dengan bestie lain" → general blast fires against the same payment session, no double-charge
- [BE] Verify
chat_sessionstable now shows TWO rows for onepayment_session_id(original targeted = expired/cancelled, fallback blast = active or whatever it ends as). The CC Failed Pairings table will show the targeted attempt as a row keyed on its ownpairing_failures.id. - [C] Tap "Kembali" instead → home (no fallback fired)
- [C] Targeted mitra is offline at tap time → 409 from server → bestie-unavailable popup opens directly (no overlay was rendered)
- [BE] Above produces row tagged
targeted_mitra_offline; payment staysconfirmed - [C] Sensitive topic regression (
/simplifyTier 1 fix #7): start a sensitive-topic session, return-to-history, "Curhat lagi" → fallback to general blast → verify the new general blast carries the sensitive flag (mitra sees the warning badge). Pre-fix: was hardcoded to regular. - [C] Mitra is at-capacity-but-mid-session-with-this-customer → returning request still goes through (PRD §3.4 carve-out)
- [C] Mitra is at-capacity with someone else (not this customer) → 409 → bestie-unavailable popup
Section E — Extension Flip (10s Auto-Approve + Safeguard)
- [C] In an active chat, tap extension → picker shows tiers (no free-trial option per PRD §5.2)
- [BE] Customer cannot create an extension payment_session with
is_extension=true AND is_free_trial=true(server returns 400) - [M] Extension card on mitra side shows the new copy: "Tidak menjawab dalam {X} detik = otomatis disetujui" —
{X}is read from WS payloadtimeout_seconds, NOT hardcoded - [CC] Change
extension_timeout_secondsfrom 10 → 6 in CC; next extension card shows "6 detik" - [M+C] Mitra explicitly approves within 10s → extension applied, payment consumed, transaction recorded
- [M+C] Mitra explicitly declines within 10s → no extension, no charge, audit row tagged
extension_rejected - [M+C] Mitra ignores the card 10s, stays online + WS connected → auto-approve fires, charge applied
- [M+C] Mitra toggles offline mid-window → 10s expires → safeguard auto-rejects, no charge, audit row tagged
extension_safeguard_tripped - [M+C] Mitra force-quits the app mid-window → same as offline (WS disconnect detected by safeguard)
- [CC] Flip
extension_default_action_on_timeoutfromauto_approve→auto_reject; next extension that times out is rejected (audit taggedextension_rejected) - [M+C] Race regression (
/simplifyTier 1 fix #4): mitra approves at exactly the moment timer fires → exactly one outcome wins; no double-action; no spurious "session closing" push if approve won
Section F — Mitra App: Returning vs General Card
- [M] General blast WS arrives → overlay shows accept/decline (NO countdown)
- [M] Returning chat WS arrives → overlay shows accept/decline + visible countdown starting at
confirmation_timeout_secondsfrom payload - [M] Countdown ticks down 1Hz; at 0s, overlay auto-dismisses (server is source of truth — no client-side decline call)
- [M] Subsequent
chat_request_closedWS confirms server already auto-rejected - [M] Cold-start via FCM tap (kill app, tap notification): pending list correctly surfaces
request_typefor in-flight requests; returning ones get the countdown - [M] Mitra accepts a general blast that another mitra also accepts simultaneously → exactly one wins, the other sees "request no longer available" (existing DB unique constraint regression check)
Section G — Control Center
G.1 Settings page
- [CC] New row "Batas Waktu Blast Pairing" — number input, current value visible, save fires PATCH
- [CC] New row "Batas Waktu Sesi Pembayaran" — minutes input, save works
- [CC] New row "Batas Waktu Konfirmasi Chat Lanjutan" — seconds input, save works
- [CC] New row "Aksi Default Jika Bestie Tidak Menjawab Extension" — radio group
Auto-approve/Auto-reject, save works - [CC] Each save triggers in-memory cache invalidation (verify by changing the value and seeing the new behavior on the next request, no backend restart needed)
G.2 Failed Pairings page
- [CC] Sidebar entry "Failed Pairings" appears between Sesi and Users
- [CC] Page lists rows with: Created, Customer, Targeted Mitra (or "—"), Cause, Amount, Operator Action, Actioned By, Actioned At
- [CC] Cause-tag multi-select filter narrows the list correctly
- [CC] Date range filter narrows correctly
- [CC] Per-row action menu: "Mark as refunded" / "Mark as credited" / "Mark as no-action" — each updates the row
- [CC] Already-actioned rows show "—" instead of the action button (current client-side lock — see Open Question below)
- [CC] Pagination Prev/Next works at >50 rows (seed enough failures to test)
Section H — Cross-Cutting + Hot-Path Verification
- [BE]
findAvailableMitrasprojectsactive_session_countin one query (no per-mitra COUNT roundtrip) — confirm by enabling pg query logging during a blast with 5+ available mitras - [BE] Blast loop runs notify+insert in parallel — verify total blast time scales sub-linearly with mitra count
- [BE]
expireStalePaymentSessionssweeper runs every 60s; under load (manually create 20 stale rows via direct DB INSERT), single batch UPDATE flips them all + parallel notifies - [BE] Composite index
idx_chat_sessions_mitra_statusexists;EXPLAIN ANALYZEon the per-mitra capacity subquery uses the index, not a seq scan - [C] Availability notifier no-op guard: keep mitra availability stable, observe Riverpod listeners on home — no rebuild every 5s when the value is unchanged (use Flutter DevTools rebuild counter)
- [BE] Mitra goes offline mid-session → existing chat continues; mitra app shows offline toggle; new returning-chat requests to that mitra immediately auto-reject (Section D check)
Section I — Anonymity Regression (existing rule, must not regress)
- [M] Mitra always sees customer's
call_namein chat header, request card, history, returning-chat card - [M] Mitra never sees customer phone, email, or social ID anywhere in 3.7-touched UI
Section J — Multi-Actor Scenario: Mitra Goes Offline Mid-Session
Verifies the domain rule that the mitra offline toggle is independent of active session state — going offline mid-chat must not interrupt the in-progress session, but availability for new customers must reflect the offline state immediately.
Memory reference: "Mitra Can Go Offline Mid-Session" — never use "in-session" as a proxy for "online".
Setup
- Set
max_customers_per_mitra = 2in CC (so a single chat doesn't auto-fill capacity and mask the test signal) - Two customer accounts (Customer 1, Customer 2) signed in on two devices/emulators (or one of each)
- One mitra account (Mitra A) signed in on the mitra app
- No other mitra is online (verify via CC mitra dashboard)
Steps
- [M] Mitra A taps online toggle → status flips to online
- [C, Customer 1] Home shows CTA enabled
- [C, Customer 1] Tap "Mulai Curhat" → payment screen → "Bayar" → searching screen
- [BE] Verify blast fires to Mitra A (check
chat_request_notificationsrow inserted) - [M] Incoming-request overlay appears → tap "Terima"
- [C, Customer 1] Searching → "Bestie Ditemukan" → chat screen with Mitra A
- [BE] Verify
chat_sessions.status = active,payment_sessions.status = consumed - [C, Customer 1 + M] Exchange messages both ways (e.g., "hai" → "halo apa kabar" → "baik" → "ada yang bisa dibantu") — each message renders on both sides with delivery + read status
- [C, Customer 2] Open Customer 2's app — home CTA is enabled (Mitra A has 1/2 capacity, still available)
- [M] Mitra A taps offline toggle while still on the chat screen with Customer 1
- [M] Toggle succeeds — no "session ended" / "you're in a session" blocker dialog. Mitra A status now offline in CC dashboard
- [C, Customer 1] Chat screen is uninterrupted:
- No "session ended" banner
- No closure dialog
- Session timer continues counting down
- Messages sent before are still visible
- [C, Customer 1 + M] Exchange more messages after Mitra went offline ("masih ada?" → "ada, masih disini") — messages still flow normally via WS (mitra's WS connection is independent of the online toggle)
- [C, Customer 2] Wait up to 5 seconds (one poll cycle) → CTA flips to disabled with subtitle "Belum ada bestie tersedia" (Mitra A is now offline, no other mitras online)
- [BE] Verify
GET /api/client/mitra-availabilityreturns{ available: false } - [C, Customer 1] Let session run to natural expiry (or end early if
early_end_*flag enabled) — verify normal session-close flow (closing screen → goodbye composer → completed) - [BE] Verify
chat_sessions.ended_byreflects timer-expiry / customer / mitra-explicit-close — NOT amitra_offlineforce-close
Regression checks tied to this scenario
- [C, Customer 1] While Mitra A is offline mid-session, Customer 1 requests an extension → extension request fires normally (existing chat scope), but the safeguard tagged
extension_safeguard_trippedfires when the 10s auto-approve timer hits because mitra is unreachable (PRD §5.5 — already in Section E.23, this is the multi-actor variant) - [C, Customer 2] While Mitra A is offline + Customer 1's session is still active, Customer 2 attempting "Curhat lagi" against Mitra A from a prior chat history row → 409
targeted_mitra_offline→ bestie-unavailable popup (auto-reject immediate per Section D) - [CC] While Mitra A is offline mid-session, the active sessions admin view still lists Customer 1's session as active — operator should not be misled into thinking the session was force-closed
Outstanding Open Questions (need user decision before resolving)
- Re-action lock: Failed Pairings rows are locked client-side after first operator action even though backend
setOperatorActionallows overwriting. Decide: keep lock (current) or allow revisions. - Mitra pre-existing
ref.listeninbuildinmitra_app/lib/core/chat/chat_request_notifier.dart— flagged in/simplify, out of 3.7 scope, separate cleanup pass. - Browser smoke for Failed Pairings page — Stage 4 verified backend round-trip but skipped real browser click-through (no headless browser in agent sandbox).
Test Data Setup Helpers
Quick curl commands to seed test conditions:
# Force a mitra online for testing
curl -X POST http://192.168.88.247:3000/api/mitra/status/online \
-H "Authorization: Bearer <mitra-jwt>"
# Create a stale payment_session for sweeper testing
psql -c "INSERT INTO payment_sessions (customer_id, amount, duration_minutes, status, expires_at)
VALUES ('<customer-uuid>', 50000, 30, 'confirmed', NOW() - INTERVAL '1 minute')"
# Force a config invalidation event (simulating multi-instance)
redis-cli PUBLISH config:invalidate '{"key":"max_customers_per_mitra","ts":1234567890}'
# List all failed-pairing rows by tag
curl -s http://localhost:3001/internal/failed-pairings?cause_tags=customer_cancelled \
-H "Authorization: Bearer <cc-jwt>" | jq