Phase 4 §4: payment-before-pair for returning users + Maestro suite

Stages 5.1, 5.3, 5.4 of the returning-user flow rework. All three §4
entry paths now require payment BEFORE pairing, matching the updated
mermaid spec.

* Spec (requirement/flow_customer.mermaid.md §4): payment block converges
  three call-sites (bestie-yang-udah-kenal-online, bestie-baru,
  offline-popup → cari bestie lain). PairRoute dispatches lama → targeted
  pair, baru/cari-lain → §3 blast. §3 retains its post-payment-shared
  contract.

* Stage 5.1 (client_app): PaymentDraft carries targetedMitraId +
  topicSensitivity. bestie_history_list seeds the draft + pushes
  /payment/entry (was legacy /payment). searching_screen branches on
  draft.targetedMitraId for blast-vs-targeted dispatch.
  payment_entry uses resetExceptTarget(); bestie_choice_sheet + home
  _onCurhatBestieBaruPressed call explicit reset() before push so
  the keepAlive draft can't leak stale targeting into a blast.

* Stage 5.3 (client_app): new BestieOfflineVariant.prePayReturning.
  Bestie-history-list _BestieRow splits tappable from dim so offline
  rows render dimmed but route taps into the popup. CTA "cari bestie
  lain" resets the draft + pushes /payment/entry.

* Stage 5.4 (client_app): deleted legacy /payment route,
  payment_screen.dart, payment_notifier.dart(+.g.dart). router cleaned.

* Tests (requirement/phase4-customer-flow.md + client_app/.maestro/):
  six Maestro flows TS-01..TS-06 covering every §4 branching point,
  all passing end-to-end. Shared onboarding prelude under
  .maestro/subflows/. New helper scripts: accept_latest_pending,
  force_mitra_offline, force_other_mitra_online,
  reset_all_mitras_online, mitra_accept_latest_internal. New backend
  _test endpoints to match. /reset-phone now cascade-deletes
  customer_transactions (FK was blocking). /force-pairing-timeout
  branches targeted (RETURNING_CHAT_TIMEOUT via
  expireTargetedPairingRequest, now exported) vs blast (PAIRING_FAILED).
  seed_history_session also outputs MITRA_NAME_RE (regex-escaped) for
  reliable selectors against display names containing regex specials.

* mitra_app: dispose-during-deactivate guardrail for back-press on the
  mitra chat screen after the customer's goodbye message. Pending real
  emulator repro verification (carried over from 2026-05-15).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:25:15 +08:00
parent 1c9d81d81d
commit e09f76ceb6
32 changed files with 1755 additions and 680 deletions

View File

@@ -0,0 +1,155 @@
# TS-01 — Returning user re-pays an online bestie (lama happy path)
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-01).
#
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(yes) →
# PickMethod → PickDuration → PayMethod → Bayar → WaitPay → paid →
# notif-gate → PairRoute(lama) → Targeted → accept → S10 chat.
#
# Pre-reqs (HARD — flow assumes these):
# - Backend reachable (BACKEND_INTERNAL_URL).
# - NODE_ENV != 'production' (so /internal/_test/* routes are registered).
# - At least ONE mitra is currently online in dev DB. The dev seed
# (backend/src/db/seed.js) does NOT auto-create mitras — ops must sign
# in a test mitra via mitra_app + heartbeat (or insert
# mitra_online_status manually). `seed_history_session.js` picks the
# most-recently-online mitra; that same mitra must still be online when
# the flow reaches the post-payment targeted-wait so
# `mitra_accept_latest_internal.js` can drive accept.
#
# Run:
# maestro test client_app/.maestro/flows/ts-01_returning_lama_online.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed a completed chat_sessions row so the bestie history list isn't
# empty. The seed picks the most-recently-online mitra; that mitra remains
# online (this is the "lama" branch — TS-01 needs them online). ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# bestieHistoryProvider was already evaluated empty on the first home
# render. Pull-to-refresh to re-fetch /chat/history so the seeded row shows
# up and the CTA opens BestieChoiceSheet (instead of jumping straight to
# /payment/entry, which is the no-history branch).
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Step 1: tap CTA → Bestie Choice Sheet ---
# After seeding, the CTA label flips from "aku mau curhat" (SHome1st) to
# "curhat sama bestie baru" (SHomeReturning).
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
# Sheet title "mau curhat sama siapa?" — `?` is regex meta; wrap in (?s).*…*
# and use `.` for the trailing literal `?`.
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
# --- Step 2: pick "bestie yang udah kenal" → history list ---
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
# --- Step 3: tap the seeded mitra row → /payment/entry → method-pick ---
# Row label is "bestie <name>" + ONLINE pill (merged blob).
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
# /payment/entry is a routing shim; for a returning customer with prior
# completed sessions the discount is NOT eligible, so it forwards to
# /payment/method-pick (`pilih cara curhat`).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
# --- Step 4: pick chat mode → /payment/duration-pick ---
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
# --- Step 5: pick cheapest tier → CTA shows "bayar Rp X" ---
# Tiers render via tier.label / formatRupiah; "5 Menit" is current label.
- tapOn: "(?s).*5 Menit.*"
- tapOn: "(?s).*bayar Rp.*"
# --- Step 6: cara-bayar screen → QRIS preselected → tap bayar ---
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
# --- Step 7: waiting-payment screen → force-confirm ---
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 8: notif-gate auto-advances (or "nanti aja" if shown) →
# /chat/searching kicks off targeted-pair → routes to
# /chat/waiting-targeted/<mitraId> ---
# We accept either landing on notif-gate (if perm not yet decided) or
# directly on targeted-waiting (if perm was already granted).
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*MENUNGGU JAWABAN.*"
timeout: 20000
- assertVisible: "(?s).*lagi nungguin.*"
# --- Step 9: mitra-side accept via dev-only internal route ---
- runScript:
file: ../scripts/mitra_accept_latest_internal.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 10: chat screen renders ---
# Header shows mitra name + "online" status pill. The presence of either
# the mitra name (post-accept) or the "online" indicator confirms we're on
# the chat screen.
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000
- assertVisible: "(?s).*${output.MITRA_NAME_RE}.*"

View File

@@ -0,0 +1,172 @@
# TS-02 — Returning user picks offline bestie → "cari bestie lain" → blast
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-02).
#
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(no) →
# OfflinePopup(pre-pay) → "cari bestie lain" → PickMethod → … → paid →
# PairRoute(baru) → BlastFlow → S10 chat.
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 2 mitras online in dev DB at flow start. The flow:
# 1. `seed_history_session.js` picks the most-recently-online mitra
# (call this M1) and creates a history row.
# 2. `force_mitra_offline.js` forces M1 offline so the history list
# renders dim.
# 3. After the user takes the blast branch, a SECOND online mitra
# (M2) is required to accept the blast via
# `mitra_accept_latest_internal.js`.
# If only one mitra exists, the blast will have no acceptor and step
# 10's chat-screen assertion will time out.
# - Dev seed (backend/src/db/seed.js) does NOT auto-create mitras — ops
# must sign in >= 2 test mitras via mitra_app + heartbeat.
#
# Run:
# maestro test client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
# Second online mitra that will accept the blast. Maestro flows currently
# don't have a "pick any online mitra" helper, so we rely on
# mitra-accept-latest taking the FIRST mitra that received the blast.
# See "Open question" in the task spec — this is the "TS-02 needs two
# online mitras" caveat.
TEST_MITRA_ID_ACCEPTOR: "${TEST_MITRA_ID_ACCEPTOR}"
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Reset every mitra online first (so seed has someone to pick after
# earlier offline-forcing tests left state dirty). ---
- runScript:
file: ../scripts/reset_all_mitras_online.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Seed history with M1, ensure a different M2 is online (the eventual
# blast acceptor), then force M1 offline. ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_other_mitra_online.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_mitra_offline.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Refresh home to pick up the seeded history row + offline status.
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Steps 1-2: tap CTA → choice sheet → "bestie yang udah kenal" ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
# --- Step 3: tap the DIMMED M1 row → BestieOfflinePopup (prePayReturning)
# Popup title: "<mitraName> lagi nggak online" (verified against
# bestie_unavailable_dialog.dart). CTA labels: "cari bestie lain" + "tanya
# admin" + "kembali ke home" (ghost). ---
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
- extendedWaitUntil:
visible:
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
timeout: 10000
- assertVisible: "(?s).*cari bestie lain.*"
- assertVisible: "(?s).*tanya admin.*"
# --- Step 4: tap "cari bestie lain" → draft.reset() → /payment/entry ---
- tapOn: "(?s).*cari bestie lain.*"
# /payment/entry forwards to /payment/method-pick for an ineligible
# customer (prior completed session → no first-session discount).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
# --- Step 5: pick chat → duration → tier → cara bayar → bayar ---
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
# --- Step 6: waiting-payment → force-confirm ---
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 7: notif-gate → /chat/searching (BLAST, not targeted-wait) ---
# Because the popup's "cari bestie lain" CTA reset the draft, targetedMitraId
# is null → searching_screen.dart fires startSearch() (blast), NOT
# startTargetedSearch().
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*lagi nyari bestie.*"
timeout: 20000
# --- Step 8: any other online mitra accepts the blast ---
# accept-latest-pending picks whichever mitra received the latest blast
# notification — we don't care which one as long as it's not the seeded
# (force-offline) one. Pre-req: ≥1 OTHER online mitra in the dev DB.
- runScript:
file: ../scripts/accept_latest_pending.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 9: chat screen renders ---
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000

View File

@@ -0,0 +1,104 @@
# TS-03 — Returning user picks offline bestie → "tanya admin" (escape)
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-03).
#
# §4 branch covered: Choice → "bestie yang udah kenal" → CheckOnline(no) →
# OfflinePopup(pre-pay) → "tanya admin" → AdminSheet (terminal).
#
# This is a terminal-escape scenario: no payment row, no chat. The flow
# ends after asserting the admin sheet renders with at least one contact
# option (WhatsApp / Telegram).
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 1 mitra online in dev DB so `seed_history_session.js` can pick
# someone to be the (about-to-be-forced-offline) M1. No second mitra
# needed (no blast or accept in this scenario).
# - `support_handles_json` in app_config has at least WA or Telegram
# populated (default seed includes both). If empty, the admin sheet
# renders "kontak admin belum tersedia" — the assertion will still pass
# on the "tanya admin" title but the deeplink CTA assertion below would
# fail; in that case the WA/Telegram assertion is the verification of
# interest.
#
# Run:
# maestro test client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed history with M1 then force M1 offline ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_mitra_offline.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Refresh home so history list shows the dimmed row.
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Steps 1-2: choice sheet → "bestie yang udah kenal" → history list ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
# --- Step 3: tap dimmed M1 row → BestieOfflinePopup (prePayReturning) ---
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
- extendedWaitUntil:
visible:
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
timeout: 10000
- assertVisible: "(?s).*cari bestie lain.*"
- assertVisible: "(?s).*tanya admin.*"
# --- Step 4: tap "tanya admin" → admin sheet opens ON TOP of the popup
# (per bestie_unavailable_dialog.dart L181-187 the popup stays alive under
# the sheet). The sheet title is "tanya admin". ---
- tapOn: "(?s).*tanya admin.*"
# The admin sheet's title is "tanya admin" — but so was the CTA we just
# tapped, which remains rendered behind the sheet. Disambiguate by also
# asserting on the subtitle copy "pilih cara yang paling enak buat kamu",
# which only exists on the sheet (verified against tanya_admin_sheet.dart).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara yang paling enak buat kamu.*"
timeout: 10000
- assertVisible: "(?s).*tanya admin.*"
# Confirm at least one contact option renders. If support_handles_json is
# empty in app_config the sheet renders "kontak admin belum tersedia" — in
# that case the assertVisible above (title + subtitle) is the meaningful
# check; the WhatsApp/Telegram presence depends on CC seed.
# TODO(test-data): if dev seeds reliably populate WA + Telegram, tighten
# this to assertVisible "WhatsApp|Telegram".

View File

@@ -0,0 +1,134 @@
# TS-04 — Returning user picks "bestie baru" → blast happy path
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-04).
#
# §4 branch covered: Choice → "bestie baru" → PickMethod → PickDuration →
# PayMethod → Bayar → WaitPay → paid → PairRoute(baru) → BlastFlow →
# S10 chat.
#
# What this proves: tapping "bestie baru" explicitly resets the payment
# draft (clearing any stale targetedMitraId — Stage 5.1 Risk #4
# mitigation), so the post-payment route is /chat/searching (blast), NOT
# /chat/waiting-targeted/...
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - At least ONE mitra is online to accept the blast.
# - The customer must qualify as "returning" so the choice sheet renders
# (not auto-jumps to /payment/entry). We seed a history row to force
# this. The seeded mitra does NOT need to stay online — they're not
# the acceptor here (this is the "bestie baru" branch — blast goes to
# whoever's online).
# - Dev seed (backend/src/db/seed.js) does NOT auto-create mitras — sign
# in at least one test mitra via mitra_app + heartbeat.
#
# Run:
# maestro test client_app/.maestro/flows/ts-04_returning_baru_blast.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed history so the customer qualifies as "returning" and the choice
# sheet (with both options) renders. The seeded mitra is who'll later
# accept the blast — they just need to stay online. ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Steps 1-2: tap CTA → choice sheet → "bestie baru" ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
# "bestie baru" branch — onTap path resets the draft + pushes
# /payment/entry. The label "bestie baru" alone is too short and may also
# match the CTA we just tapped on home; use the subtitle text "cari bestie
# baru yang siap dengerin" which is unique to this card.
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
# /payment/entry → /payment/method-pick (returning customer, no discount).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
# --- Step 3: pick chat → duration → tier → cara bayar → bayar ---
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
# --- Step 4: waiting-payment → force-confirm ---
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 5: notif-gate → /chat/searching (BLAST, not targeted-wait) ---
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*lagi nyari bestie.*"
timeout: 20000
# --- Step 6: any online mitra accepts the blast ---
# We use the seeded mitra's id as a known-online acceptor. (If they're no
# longer online, the assertion below will time out — re-run after
# refreshing their heartbeat.)
- runScript:
file: ../scripts/mitra_accept_latest_internal.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Step 7: chat screen renders ---
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000
- assertVisible: "(?s).*${output.MITRA_NAME_RE}.*"

View File

@@ -0,0 +1,143 @@
# TS-05 — Payment expired → retry preserves targeting
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-05).
#
# §4 branch covered: PickMethod → PickDuration → PayMethod → WaitPay →
# PayStat(timeout 20 min) → PayExpired → Pay(retry) → paid →
# PairRoute(lama) → Targeted → S10.
#
# What this proves: the `targetedMitraId` on the payment draft survives
# the expired-retry round trip (Stage 5.1 `resetExceptTarget` invariant).
# After the retry pays, the customer lands on /chat/waiting-targeted/<SAME
# mitraId> — NOT a fresh blast.
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 1 mitra online — they're the targeted mitra throughout. The flow
# does NOT drive accept (we stop at the targeted-waiting screen — the
# assertion that targeting survived the retry is the goal).
#
# Run:
# maestro test client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Seed history with online M1 (targeted throughout). ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Walk TS-01 steps 1-6 to reach /payment/waiting for the targeted
# attempt against M1. ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
# /payment/entry → /payment/method-pick (returning, no discount).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
# --- Force-expire the latest pending payment ---
- runScript:
file: ../scripts/force_expire_latest_payment.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Poller picks `expired` within ~3s → /payment/expired/:paymentId ---
- extendedWaitUntil:
visible:
text: "(?s).*pembayaran kedaluwarsa.*"
timeout: 10000
- assertVisible: "(?s).*coba lagi.*"
# --- Tap retry → /payment/method (NOT /payment/method-pick — the draft
# was preserved via resetExceptTarget, so we skip mode + duration). ---
- tapOn: "(?s).*coba lagi.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
# Sanity check: we did NOT bounce back to the mode picker.
- assertNotVisible: "(?s).*pilih cara curhat.*"
# --- Re-pay ---
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Notif-gate → targeted-waiting screen for the SAME mitra (M1).
# This is the load-bearing assertion: if targetedMitraId had been wiped
# by the expired-retry round trip, the customer would land on
# /chat/searching (blast) and we'd see "lagi nyari bestie" instead. ---
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*MENUNGGU JAWABAN.*"
timeout: 20000
- assertVisible: "(?s).*lagi nungguin ${output.MITRA_NAME_RE}.*"

View File

@@ -0,0 +1,187 @@
# TS-06 — Targeted request fails post-payment → fallback to blast
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-06).
#
# §4 branch covered: Targeted → TargetedRes(reject/timeout) →
# OfflinePopup(post-pay, returning variant) → "cari bestie lain" →
# fallback-to-blast → §3 BlastFlow → S10.
#
# What this proves: after paying for a targeted attempt, if the picked
# mitra rejects or times out, the customer can fall back to general blast
# WITHOUT a second payment (same payment_sessions row reused).
#
# Pre-reqs (HARD):
# - Backend reachable; NODE_ENV != 'production'.
# - >= 2 mitras online in dev DB:
# 1. M1 — picked from history, becomes the targeted mitra; we
# simulate them rejecting via force_pairing_timeout.js.
# 2. M2 — the blast-fallback acceptor.
# If only one mitra is online, the fallback-to-blast cannot match.
#
# Known backend gap (TODO):
# The current force_pairing_timeout.js endpoint calls
# `expirePairingRequest()` which broadcasts WS PAIRING_FAILED
# (is_terminal=false). On the TargetedWaitingScreen, this maps the
# pairing state to PairingFailedData, NOT PairingTargetedUnavailableData
# — so the `BestieOfflinePopup` (returning variant) won't fire from the
# targeted-waiting screen as written.
#
# To make this flow pass end-to-end the backend test endpoint needs to
# detect when the latest pending_acceptance session is a TARGETED pair
# (chat_sessions.targeted_mitra_id is set / linked to a confirmed
# payment_session.targeted_mitra_id) and route to
# `expireTargetedPairingRequest` instead, which broadcasts
# RETURNING_CHAT_TIMEOUT → PairingTargetedUnavailableData → popup fires.
#
# Until that fix lands, this flow will fail at step "OfflinePopup
# visible". Leave it in place: it correctly expresses the intended
# product behavior and serves as a regression test for the backend fix.
#
# Run:
# maestro test client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
# Second online mitra that will accept the blast fallback. See task spec
# "Open question" — TS-06 needs >= 2 online mitras and no "any online
# acceptor" helper exists yet.
TEST_MITRA_ID_ACCEPTOR: "${TEST_MITRA_ID_ACCEPTOR}"
---
# --- Cold-start reset + onboarding prelude ---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
# --- Reset every mitra online first (test idempotency). ---
- runScript:
file: ../scripts/reset_all_mitras_online.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Seed history with M1 (online — they'll be the targeted mitra). Then
# ensure a different M2 is online so the post-rejection blast has an
# acceptor. ---
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/force_other_mitra_online.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# --- Walk TS-01 steps 1-8 to reach /chat/waiting-targeted/<mitraId> ---
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*bestie yang udah kenal.*"
- extendedWaitUntil:
visible:
text: "(?s).*bestie kamu sebelumnya.*"
timeout: 5000
- tapOn: "(?s).*bestie ${output.MITRA_NAME_RE}.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "scan QRIS untuk bayar"
timeout: 10000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*MENUNGGU JAWABAN.*"
timeout: 20000
# --- Force the targeted pending_acceptance session to expire ---
# TODO(backend): force-pairing-timeout currently calls
# expirePairingRequest (broadcasts PAIRING_FAILED), not
# expireTargetedPairingRequest (which would broadcast
# RETURNING_CHAT_TIMEOUT). The popup assertion below depends on the
# RETURNING_CHAT_TIMEOUT path — see header note.
- runScript:
file: ../scripts/force_pairing_timeout.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- BestieOfflinePopup (returning variant, post-pay) appears ---
# Title from bestie_unavailable_dialog.dart for the `returning` variant:
# "<mitraName> lagi nggak online". Primary CTA when canFallbackToBlast is
# true (M2 is reachable + paymentSessionId is set): "chat dengan bestie
# lain". This differs from the prePayReturning variant's "cari bestie
# lain" CTA — verified in bestie_unavailable_dialog.dart L140-152.
- extendedWaitUntil:
visible:
text: "(?s).*${output.MITRA_NAME_RE}.*lagi nggak online.*"
timeout: 15000
- assertVisible: "(?s).*chat dengan bestie lain.*"
# --- Tap "chat dengan bestie lain" → fallbackToBlast() ---
# In current code, fallback-to-blast creates a fresh pending request that
# may render as either /chat/searching ("lagi nyari bestie") for a
# multi-mitra blast OR /chat/waiting-targeted (MENUNGGU JAWABAN) when the
# backend reuses the existing payment session to target the next available
# mitra. Either pending state is acceptable here — the critical assertion
# is that the customer wasn't charged again (same payment_sessions row).
- tapOn: "(?s).*chat dengan bestie lain.*"
- extendedWaitUntil:
visible:
text: "(?s).*(lagi nyari bestie|MENUNGGU JAWABAN).*"
timeout: 15000
# --- M2 accepts the blast (any other online mitra) ---
- runScript:
file: ../scripts/accept_latest_pending.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# --- Chat screen renders (with M2, not M1) ---
- extendedWaitUntil:
visible:
text: "(?s).*online.*"
timeout: 20000