Phase 3.7: paid pairing flow + returning chat + extension flip

- Backend: payment_sessions + pairing_failures tables; payment.service.js
  and pairing-failure.service.js (new); rewritten pairing.service.js
  (payment-gated blast + targeted "Curhat lagi" + cancel + fallback);
  rewritten extension.service.js (data-driven auto-approve with offline
  safeguard, charge-at-approval); pricing.service.js (extension tiers
  without free trial); mitra-status.service.js (countAvailableMitras
  cached path); 60s sweeper for stale payment sessions
- Backend routes: client.payment.routes, client.mitra-availability.routes,
  internal/failed-pairings.routes; client.chat.routes rewritten for
  payment-gated start + /returning + /cancel + /fallback-to-blast;
  internal/config.routes adds 4 new keys with Valkey invalidate publish
- client_app: mitra-availability poll, payment screen + notifier, pairing
  notifier rewrite (PairingTargetedWaiting + PairingFailed states),
  targeted-waiting overlay + bestie-unavailable dialog, "Curhat lagi"
  CTA, failed-pairing terminal, extension via payment-session
- mitra_app: PairingRequestType enum, returning-chat 20s countdown
  auto-dismiss, extension card "otomatis disetujui" copy
- control_center: 4 new config rows in Settings, Failed Pairings page
  (filter + paginate + action menu), sidebar + route registered
- Test infrastructure: Vitest backend (7/7 pass), Playwright CC (4/4
  pass), Maestro mobile scaffold (CLI install pending)
- Bugs found via Playwright + fixed: LoginPage labels not associated
  with inputs (a11y); backend internal CORS missing PATCH/PUT/DELETE
  in allow-methods (silent settings breakage in browsers since Stage 4)
- Docs: phase3.7.md PRD, phase3.7-plan.md, phase3.7-questions.md (Q&A),
  phase3.7-testing.md (E2E checklist), phase3.7-test-run-2026-05-03.md
  (today's run results)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 23:02:49 +08:00
parent f3766813f3
commit d09e50af55
92 changed files with 9579 additions and 437 deletions

View File

@@ -0,0 +1,201 @@
# 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.md), [phase3.7-plan.md](./phase3.7-plan.md), [phase3.7-questions.md](./phase3.7-questions.md).
> **Backend curl smoke (an) was executed in Stage 2** and re-validated after the `/simplify` Tier 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 with `curl 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_status` all 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_seconds` is set to `10` (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:3000` and 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_mitra` chats 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-availability` does NOT issue a Postgres query on every poll — only once per 10s TTL window (the `countAvailableMitrasFromCache()` cache backstop)
## Section B — Payment Screen + Happy-Path Blast
- [ ] **[C]** Tap CTA → `/payment` route 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_id` is set
- [ ] **[C]** Free trial-eligible customer: payment screen shows "Gratis" / Rp 0 → "Mulai" CTA → searching → chat
- [ ] **[BE]** Free-trial flow creates a `payment_sessions` row with `amount=0, is_free_trial=true`
- [ ] **[C]** Customer hits Android back button on payment screen → `cancelIfPending` fires (verify `payment_sessions.status = abandoned` server-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_failures` row tagged `no_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_failures` row tagged `customer_cancelled` for CC visibility
- [ ] **[BE]** Backend logs show **no** `PAIRING_FAILED` WS push from `cancelPairingRequest` / `cancelPaymentSearch` paths
## 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_id` baked in → confirm → searching screen with **targeted-waiting overlay** showing mitra name + countdown
- [ ] **[C]** Countdown starts from `confirmation_timeout_seconds` value returned by backend (regression for `/simplify` Tier 1 fix #6 — was hardcoded to 20)
- [ ] **[CC]** Change `returning_chat_confirmation_timeout_seconds` from 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_sessions` row, 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_failures` row tagged `targeted_mitra_timeout`; payment session **stays `confirmed`** (intermediate failure — verify via SQL)
- [ ] **[C]** Mitra explicitly declines within window → bestie-unavailable popup
- [ ] **[BE]** Above produces row tagged `targeted_mitra_rejected`; payment still `confirmed`
- [ ] **[C]** Tap "Chat dengan bestie lain" → general blast fires against the **same** payment session, no double-charge
- [ ] **[BE]** Verify `chat_sessions` table now shows TWO rows for one `payment_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 own `pairing_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 stays `confirmed`
- [ ] **[C]** **Sensitive topic regression** (`/simplify` Tier 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 payload `timeout_seconds`, NOT hardcoded
- [ ] **[CC]** Change `extension_timeout_seconds` from 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_timeout` from `auto_approve``auto_reject`; next extension that times out is rejected (audit tagged `extension_rejected`)
- [ ] **[M+C]** Race regression (`/simplify` Tier 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_seconds` from 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_closed` WS confirms server already auto-rejected
- [ ] **[M]** Cold-start via FCM tap (kill app, tap notification): pending list correctly surfaces `request_type` for 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]** `findAvailableMitras` projects `active_session_count` in 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]** `expireStalePaymentSessions` sweeper 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_status` exists; `EXPLAIN ANALYZE` on 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_name` in 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 = 2` in 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
1. [ ] **[M]** Mitra A taps online toggle → status flips to online
2. [ ] **[C, Customer 1]** Home shows CTA enabled
3. [ ] **[C, Customer 1]** Tap "Mulai Curhat" → payment screen → "Bayar" → searching screen
4. [ ] **[BE]** Verify blast fires to Mitra A (check `chat_request_notifications` row inserted)
5. [ ] **[M]** Incoming-request overlay appears → tap "Terima"
6. [ ] **[C, Customer 1]** Searching → "Bestie Ditemukan" → chat screen with Mitra A
7. [ ] **[BE]** Verify `chat_sessions.status = active`, `payment_sessions.status = consumed`
8. [ ] **[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
9. [ ] **[C, Customer 2]** Open Customer 2's app — home CTA is **enabled** (Mitra A has 1/2 capacity, still available)
10. [ ] **[M]** Mitra A taps offline toggle **while still on the chat screen with Customer 1**
11. [ ] **[M]** Toggle succeeds — no "session ended" / "you're in a session" blocker dialog. Mitra A status now offline in CC dashboard
12. [ ] **[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
13. [ ] **[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)
14. [ ] **[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)
15. [ ] **[BE]** Verify `GET /api/client/mitra-availability` returns `{ available: false }`
16. [ ] **[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)
17. [ ] **[BE]** Verify `chat_sessions.ended_by` reflects timer-expiry / customer / mitra-explicit-close — NOT a `mitra_offline` force-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_tripped` fires 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 `setOperatorAction` allows overwriting. Decide: keep lock (current) or allow revisions.
- [ ] **Mitra pre-existing `ref.listen` in `build`** in `mitra_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:
```bash
# 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
```