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

View File

@@ -0,0 +1,17 @@
// Accept the latest pending pairing notification regardless of mitra. Used
// by flows where the acceptor mitra UUID isn't known in advance — e.g.
// TS-02 (blast where the seeded mitra was forced offline, so an unknown
// OTHER online mitra got the chat_request_notification row).
//
// Backed by /internal/_test/accept-latest-pending (no body needed).
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/accept-latest-pending`, {
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
if (resp.status !== 200) {
throw new Error(`accept-latest-pending failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.ACCEPTED_SESSION_ID = data.session_id
output.ACCEPTOR_MITRA_ID = data.mitra_id

View File

@@ -0,0 +1,20 @@
// Force a specific mitra OFFLINE via the dev-only
// /internal/_test/force-mitra-offline endpoint. Used by Maestro flows that
// need the bestie-history-list row for a particular mitra to render in its
// offline (dimmed) state — see TS-02 / TS-03 in
// requirement/phase4-customer-flow.md.
//
// Reads MITRA_ID from env (typically `${output.MITRA_ID}` from a prior
// seed_history_session.js run) and BACKEND_INTERNAL_URL.
const mitraId = MITRA_ID
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!mitraId) {
throw new Error('MITRA_ID env not set — pass output.MITRA_ID from seed_history_session.js')
}
const resp = http.post(`${url}/internal/_test/force-mitra-offline`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mitra_id: mitraId }),
})
if (resp.status !== 200) {
throw new Error(`force-mitra-offline failed (${resp.status}): ${resp.body}`)
}

View File

@@ -0,0 +1,17 @@
// Force a DIFFERENT mitra online (one other than the seeded one) so blast
// flows (TS-02, TS-06) have an acceptor available after the seeded mitra is
// forced offline. Picks any currently-offline mitra excluding MITRA_ID.
//
// Reads MITRA_ID (the seeded mitra to exclude, from seed_history_session.js)
// and BACKEND_INTERNAL_URL.
const excludeId = MITRA_ID
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/force-mitra-online`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exclude_mitra_id: excludeId }),
})
if (resp.status !== 200) {
throw new Error(`force-mitra-online failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.OTHER_MITRA_ID = data.mitra_id

View File

@@ -0,0 +1,19 @@
// Have the test mitra "accept" the most recent pending pairing request via
// the dev-only /internal/_test/mitra-accept-latest endpoint (no JWT needed).
//
// Reads MITRA_ID from the env that the calling flow injects — typically
// `${output.MITRA_ID}` from a prior seed_history_session.js run.
const mitraId = MITRA_ID
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!mitraId) {
throw new Error('MITRA_ID env not set — pass output.MITRA_ID from seed_history_session.js')
}
const resp = http.post(`${url}/internal/_test/mitra-accept-latest`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mitra_id: mitraId }),
})
if (resp.status !== 200) {
throw new Error(`mitra-accept-latest failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.ACCEPTED_SESSION_ID = data.session_id

View File

@@ -0,0 +1,16 @@
// Bulk-mark every mitra row online in mitra_online_status. Used as a setup
// step at the start of each Maestro flow so seed_history_session has at
// least one online mitra to pick, regardless of what previous tests did
// (e.g. force-mitra-offline lingering from a prior TS-02/TS-03 run).
//
// Backed by /internal/_test/reset-all-mitras-online.
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/reset-all-mitras-online`, {
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
if (resp.status !== 200) {
throw new Error(`reset-all-mitras-online failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.ONLINE_COUNT = data.online_count

View File

@@ -16,3 +16,7 @@ const data = json(resp.body)
output.SESSION_ID = data.session_id
output.MITRA_ID = data.mitra_id
output.MITRA_NAME = data.mitra_name
// Regex-escaped variant for Maestro `text:` selectors (which do FULL-string
// regex match). Display names can contain `+` (phone-as-name), `.`, etc.
// which break selectors otherwise.
output.MITRA_NAME_RE = data.mitra_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

View File

@@ -0,0 +1,100 @@
# Shared onboarding prelude for Phase 4 §4 "returning user" Maestro flows
# (TS-01 through TS-06 — see requirement/phase4-customer-flow.md).
#
# This subflow drives a clean-slate emulator from cold start to /home as a
# verified customer. The verified+display-name'd state is the precondition
# for every TS scenario in §4, so we extract it here to avoid ~80 lines of
# duplication across the six flows.
#
# Pre-reqs (parent flow's responsibility):
# - Parent flow has `env:` block defining TEST_PHONE and
# BACKEND_INTERNAL_URL (Maestro subflows inherit env from caller).
# - Parent flow runs `reset_phone.js` + `launchApp clearState: true`
# BEFORE invoking this subflow.
# - NODE_ENV != 'production' on backend (so /internal/_test routes exist).
#
# Path taken:
# Welcome carousel ("Mulai") → Home with anon banner →
# tap "masuk →" → /auth/register → enter +62 subscriber digits →
# "kirim kode" → OTP screen → peek OTP from stub → auto-submit →
# /auth/set-name (because AuthNeedsDisplayNameData) → enter "Maestro" →
# "Lanjut" → /home (returning view: "curhatan sebelumnya" header).
#
# Selector style: Flutter merges sibling Text widgets inside a single
# tappable parent into ONE accessibility blob. Maestro's `text:` selector
# does a FULL-string regex match, so we wrap selectors in `(?s).*…*` for
# anything that lives inside an InkWell with multiple Texts. Empty
# TextFields don't expose their hint to Maestro a11y, so we tap by point
# inside the field's pill before typing.
appId: ${APP_ID_ANDROID}
---
# Welcome carousel — the "Mulai" button is the only Text inside its
# tappable region, so a plain selector works.
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Home — login-recover banner ("udah pernah pakai HaloBestie? … masuk →")
# is one InkWell, so any token within its merged blob reaches the
# /auth/register handler.
- extendedWaitUntil:
visible:
text: "(?s).*udah pernah pakai HaloBestie.*"
timeout: 30000
- tapOn:
text: "(?s).*masuk →.*"
# Register screen — personalised title "nomor wa-mu, {name}?" is in a
# merged blob with the subtitle copy.
- extendedWaitUntil:
visible:
text: "(?s).*nomor wa-mu.*"
timeout: 10000
# Phone input — +62 is a static prefix chip; type only subscriber digits
# (no leading 0). +6281234567890 → 81234567890. Tap by point inside the
# pill (well right of the +62 chip, well above the kirim-kode button).
- tapOn:
point: "60%, 47%"
- inputText: "81234567890"
- hideKeyboard
- tapOn:
text: "(?s).*kirim kode.*"
# OTP screen — peek the code from the stub endpoint.
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# After 6th digit OTP auto-submits. The anon customer's phone is attached
# but display_name is still empty → AuthNeedsDisplayNameData → router
# pushes /auth/set-name ("Siapa namamu?").
- extendedWaitUntil:
visible:
text: "(?s).*Siapa namamu.*"
timeout: 20000
# Same TextField-hint-invisible-to-Maestro issue as the phone field — tap
# by point inside the "Nama panggilan" pill, then type a display name.
- tapOn:
point: "50%, 30%"
- inputText: "Maestro"
- hideKeyboard
- tapOn: "Lanjut"
# Now home renders the returning view ("curhatan sebelumnya" section
# header is the deterministic landmark — appears regardless of history).
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 30000