Phase 4 §2 + §1/§4: OnboardingIntent post-OTP routing + test naming + register-screen overflow

Spec §2 (flow_customer.mermaid) routes post-OTP based on user-lookup +
has_transacted, but the implementation previously dumped every OTP
success on /home. Introduce `OnboardingIntent` provider: set to
`onboarding` by routeForVerifChoice's verified branch (the "aku mau
curhat" transaction journey), set to `recover` by SHome1st's masuk →
banner. Router redirect on AuthAuthenticatedData+isAuthRoute consumes it:
`onboarding` → /payment/entry (dispatches S6 paywall vs PickMethod via
first_session_discount.eligible); `recover` → /home. Intent is reset in
/payment/entry's initState so subsequent masuk → flows don't inherit it.

auth_notifier.verifyOtp uses .copyWithPrevious on AsyncError so
valueOrNull retains AuthOtpSentData/AuthAnonymousData through OTP
failures — required for the OTP-blocked recovery path
(/onboarding/anon/method → /payment/method-pick) to clear the global
redirect without bouncing to /home. Router also extends the
isAuthRoute/isOnboardingFlow carve-out to AuthOtpSentData.

Maestro tests adopt `ts-<app>-<NN>-<MM>-<descriptor>.yaml` convention:
NN = mermaid section, MM = sub-flow index. New ts-customer-02-01..05
cover the §2 branches (verified brand-new → S6, existing-no-tx → S6,
existing-tx → method-pick, OTP-blocked → method-pick, anonymous first-
timer → method-pick); deferred 02-06/07/08/09 documented in
README_section_02.md. TS-07 → ts-customer-02-10 (masuk → recovery);
TS-01..06 → ts-customer-04-01..06 (§4 returning-user). Shared
onboarding_new_user_verified.yaml subflow extracted.

Register screen's body Column now uses LayoutBuilder + SingleChildScrollView
+ ConstrainedBox + IntrinsicHeight so the keyboard-open layout no
longer overflows by 1.3 px (verified visually).

Spec prose updated at flow_customer.mermaid §2 to describe the
intent-driven routing + login-vs-transaction divergence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:50:04 +08:00
parent 938954bbe8
commit 093256ff7d
22 changed files with 666 additions and 76 deletions

View File

@@ -0,0 +1,89 @@
# §2 — New-User Onboarding test plan
Spec: [requirement/flow_customer.mermaid.md §2 + §2.1](../../../requirement/flow_customer.mermaid.md).
Tests use the naming convention `ts-customer-<section>-<sub>-<description>.yaml`:
- `<section>` — flow_customer.mermaid section number (`02` for §2 + §2.1).
- `<sub>` — sub-flow index within the section.
- `<description>` — snake_case summary of the branch under test.
## Implemented
| File | Branch (spec ref) | Expected destination |
|---|---|---|
| `ts-customer-02-01-verified_brand_new_to_s6_paywall.yaml` | §2 verified · UserLookup=no (brand-new) | `/payment/discount-paywall` (S6) |
| `ts-customer-02-02-verified_existing_no_tx_to_s6_paywall.yaml` | §2 verified · existing customer · has_transacted=false | `/payment/discount-paywall` (S6) |
| `ts-customer-02-03-verified_existing_transacted_to_method_pick.yaml` | §2 verified · existing customer · has_transacted=true | `/payment/method-pick` (PickMethod) |
| `ts-customer-02-04-otp_blocked_fallback_anonymous_to_method_pick.yaml` | §2 verified · OTPok="too many retries" → OTPBlock → fallback | `/payment/method-pick` (PickMethod) |
| `ts-customer-02-05-anonymous_first_timer_to_method_pick.yaml` | §2 anonymous · USPGateB=first-timer → USPb | `/payment/method-pick` (PickMethod) |
| `ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml` | SHome1st "masuk →" recover · existing identified user | `/home` (returning view) |
The shared verified-onboarding prelude lives at
[`../subflows/onboarding_new_user_verified.yaml`](../subflows/onboarding_new_user_verified.yaml).
## Deferred (not yet implemented — see reasons)
### `ts-customer-02-06` — anonymous · USP already seen → PickMethod directly
**Branch:** §2 anonymous · USPGateB=seen → skip USP → PickMethod.
**Why deferred:** verifying the seen=true skip path literally requires the
user to reach `VerifChoiceSheet` while local `flutter.usp_seen=true` AND
auth state is fresh (`AuthInitialData`, no anon customer). Maestro's
`launchApp clearState:true` wipes both `SharedPreferences` and
`FlutterSecureStorage` together — there's no built-in way to clear ONLY
secure storage between passes. A two-pass test that runs USP once then
relaunches preserves both prefs and the anonymous session, so Pass 2 lands
on `SHomeReturning` instead of `SHome1st`, bypassing VerifChoiceSheet.
**Possible future approach:** add a shell-level pre-test step that runs
`adb shell run-as <pkg> rm shared_prefs/FlutterSecureStorage*.xml` and
preserves `FlutterSharedPreferences.xml`, then invoke maestro. Requires a
wrapper script or a maestro extension that can call adb.
### `ts-customer-02-07` — §2.1 5.1 anon transact → OTP new phone → upgrade in place
### `ts-customer-02-08` — §2.1 5.2 anon transact → OTP existing phone → merge (existing has_transacted=true)
### `ts-customer-02-09` — §2.1 5.2 merge · existing has_transacted=false
**Branch:** §2.1 anonymous → existing-user merge sub-flow.
**Why deferred:** §2.1's prereq is "anonymous customer has completed at
least one transaction". The app derives `has_transacted` from
`chat_sessions` rows, and the auth flow ties customer identity to the
device's `FlutterSecureStorage` refresh token. Reproducing the prereq in
Maestro requires either:
1. Driving the app UI all the way through anonymous transaction → payment
confirm → pairing → chat → end-session (a full §3 + §5 + §6 traversal),
then triggering OTP from the SHomeReturning state. The SHomeReturning
"curhat sama bestie baru" CTA bypasses `VerifChoiceSheet` entirely, so
the only OTP entry point from this state is the SHome1st "masuk →"
banner — which is only shown on SHome1st (no identity), not on
SHomeReturning.
2. Pre-seeding a customer + chat_session in the DB *and* injecting the
corresponding refresh token into the device's FlutterSecureStorage so
the app boots as that anonymous-with-transactions customer. Maestro
doesn't expose adb shell exec, so this requires a wrapper script.
In practice, the §2.1 merge logic is more naturally covered by **backend
integration tests** against `resolveCustomerForIdentity` in
`backend/src/services/auth.service.js` — the app-side behavior after
re-login is the same `/home` landing (login intent) tested by 02-10. The
merge state is a backend invariant, not a UI invariant.
## Behavior notes
- §2 verified branches route post-OTP via `/payment/entry`, which then
dispatches based on the backend's `first_session_discount.eligible`
(see `backend/src/services/pricing.service.js::isCustomerEligibleForFirstSessionDiscount`).
This handles both "brand-new" and "existing-but-never-paid" with a
single check.
- The `OnboardingIntent` provider (added 2026-05-18 with the §2 fix)
distinguishes transaction-CTA entries (`onboarding`) from login-recover
entries (`recover`). Set at `verif_choice_sheet.dart::routeForVerifChoice`
(verified branch) and at `home_screen.dart::_LoginRecoverBanner`
(masuk →). Consumed by `router.dart`'s post-OTP redirect.
- `auth_notifier.dart::verifyOtp` uses `.copyWithPrevious(previous)` when
setting `AsyncError`, so `valueOrNull` retains the prior
`AuthOtpSentData` / `AuthAnonymousData` through the OTP-blocked popup
→ fallback path.

View File

@@ -0,0 +1,33 @@
# ts-customer-02-01 — §2 verified path, BRAND-NEW user → S6 paywall.
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# OTPok="verified" → UserLookup="no · brand-new" → S6 Paywall.
#
# Verifies the post-OTP intent fix: the user who arrived via the §2
# transaction CTA ("aku mau curhat") is routed by the router redirect to
# /payment/entry → /payment/discount-paywall, NOT /home.
#
# Pre-reqs:
# - Backend reachable; NODE_ENV != 'production'.
# - ≥1 mitra online (mitraAvailable gates the "aku mau curhat" CTA).
# - first_session_discount is enabled in the pricing config (default).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_new_user_verified.yaml
# Post-OTP: router consumes intent=onboarding → /payment/discount-paywall.
# Brand-new user (no chat_sessions) is eligible for first_session_discount.
- extendedWaitUntil:
visible:
text: "(?s).*biar yakin yang mau cerita.*"
timeout: 20000
- assertVisible: "(?s).*mulai ·.*"

View File

@@ -0,0 +1,44 @@
# ts-customer-02-02 — §2 verified path, EXISTING customer with no prior
# transactions → S6 paywall.
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# OTPok="verified" → UserLookup="yes · existing" → LoadCallSign →
# TransactedCheck="no · never paid" → S6 Paywall.
#
# Pre-seeds an identified customer with display_name but no chat_sessions
# before the run, so:
# 1. The OTP-verify backend lookup finds the existing customer.
# 2. The stored display_name overwrites the typed S2 Nama (per spec L62).
# 3. has_transacted is implicitly false (no chat_sessions →
# isCustomerEligibleForFirstSessionDiscount returns true).
# 4. /payment/entry routes to /payment/discount-paywall (S6).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Seed the existing identified customer AFTER reset (which would otherwise
# drop them). No history session is seeded → has_transacted=false.
- runScript:
file: ../scripts/seed_customer.js
env:
TEST_PHONE: ${TEST_PHONE}
DISPLAY_NAME: "ExistingPriorName"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_new_user_verified.yaml
# Post-OTP: existing customer with no prior chat_sessions →
# first_session_discount.eligible=true → S6 paywall.
- extendedWaitUntil:
visible:
text: "(?s).*biar yakin yang mau cerita.*"
timeout: 20000
- assertVisible: "(?s).*mulai ·.*"

View File

@@ -0,0 +1,49 @@
# ts-customer-02-03 — §2 verified path, EXISTING customer with prior
# transactions → PickMethod (skip S6).
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# OTPok="verified" → UserLookup="yes · existing" → LoadCallSign →
# TransactedCheck="yes · returning verified" → PickMethod.
#
# Pre-seeds an identified customer with a completed chat_session so
# `isCustomerEligibleForFirstSessionDiscount` returns false → /payment/entry
# routes to /payment/method-pick instead of /payment/discount-paywall.
#
# Pre-reqs:
# - ≥1 mitra online (seed_history_session pairs with the most-recent
# online mitra to write the chat_sessions row).
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Seed identified customer first — seed_history_session needs an existing
# customers row to attach the chat_session FK to.
- runScript:
file: ../scripts/seed_customer.js
env:
TEST_PHONE: ${TEST_PHONE}
DISPLAY_NAME: "ExistingTxName"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_new_user_verified.yaml
# Post-OTP: existing customer with prior chat_session →
# first_session_discount.eligible=false → /payment/method-pick (PickMethod).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 20000
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"

View File

@@ -0,0 +1,95 @@
# ts-customer-02-04 — §2 verified path, OTP blocked → fallback to anonymous
# → PickMethod.
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# S3b OTP → OTPok="too many retries" → OTPBlock → fallback to Anon → USPGateB.
#
# The OtpBlockedPopup is gated by OTP_ATTEMPTS_EXCEEDED (default config:
# 5 verification attempts per OTP request; the 6th submission triggers the
# 429). Popup CTA "lanjut tanpa verif" calls context.go('/onboarding/anon/method')
# which redirects to /payment/method-pick — USP is presumed already evaluated
# upstream (verif_choice_sheet shown → USPb skipped), so the anonymous flow
# jumps straight into PickMethod.
#
# This flow inlines the pre-OTP onboarding steps (instead of using the
# onboarding_new_user_verified subflow) because we want to enter wrong OTPs
# rather than the peeked valid one.
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*aku mau curhat.*"
timeout: 30000
- tapOn: "(?s).*aku mau curhat.*"
- extendedWaitUntil:
visible:
text: "(?s).*Siapa namamu.*"
timeout: 10000
- tapOn:
point: "50%, 28%"
- inputText: "MaestroBlock"
- tapOn: "lanjut"
- extendedWaitUntil:
visible:
text: "(?s).*Mau curhat sebagai siapa.*"
timeout: 10000
- tapOn: "(?s).*verifikasi nomor HP.*"
- runFlow:
when:
visible:
text: "Sebelum mulai"
commands:
- tapOn: "(?s).*aku ngerti.*"
- extendedWaitUntil:
visible:
text: "(?s).*nomor wa-mu.*"
timeout: 10000
- tapOn:
point: "60%, 47%"
- inputText: "81234567890"
- tapOn: "(?s).*kirim kode.*"
# OTP screen — type wrong "000000" six times. Default config is
# verify_max_attempts=5 (config.service.js L218); attempt 6 throws
# OTP_ATTEMPTS_EXCEEDED → OtpBlockedPopup fires.
- extendedWaitUntil:
visible:
text: "Masukkan OTP"
timeout: 15000
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
- inputText: "000000"
# Popup: "Verifikasi nomor lagi penuh" with primary CTA "lanjut tanpa verif".
- extendedWaitUntil:
visible:
text: "(?s).*Verifikasi nomor lagi penuh.*"
timeout: 15000
- tapOn: "(?s).*lanjut tanpa verif.*"
# Assert: redirected to /payment/method-pick (PickMethod).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 15000
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"

View File

@@ -0,0 +1,71 @@
# ts-customer-02-05 — §2 anonymous path, first-timer (USP not yet seen) →
# PickMethod (method-pick screen).
# Spec ref: requirement/flow_customer.mermaid.md §2, branch
# VerifChoice="tanpa verif · Rp5k+" → USPGateB="no · first-timer" →
# USPb → PickMethod.
#
# Anonymous path skips OTP entirely — no router redirect needed (user
# stays AuthAnonymousData). USP screen pushes /payment/method-pick
# directly when verified=false. Verifies onboardingIntent is NOT set
# (it stays `recover` because we picked "curhat anonim", not "verifikasi").
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
# Welcome → SHome1st.
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# "aku mau curhat" → S2 Nama.
- extendedWaitUntil:
visible:
text: "(?s).*aku mau curhat.*"
timeout: 30000
- tapOn: "(?s).*aku mau curhat.*"
# S2 Nama — submit display name.
- extendedWaitUntil:
visible:
text: "(?s).*Siapa namamu.*"
timeout: 10000
- tapOn:
point: "50%, 28%"
- inputText: "MaestroAnon"
- tapOn: "lanjut"
# VerifChoiceSheet — pick "curhat anonim" (anonymous branch). Per
# routeForVerifChoice (verif_choice_sheet.dart L94), USP-not-seen takes
# /onboarding/anon/usp; USP-seen would go directly to /payment/method-pick.
- extendedWaitUntil:
visible:
text: "(?s).*Mau curhat sebagai siapa.*"
timeout: 10000
- tapOn: "(?s).*curhat anonim.*"
# USPb (anonymous variant) — first-timer.
- extendedWaitUntil:
visible:
text: "Sebelum mulai"
timeout: 10000
- tapOn: "(?s).*aku ngerti.*"
# Assert: /payment/method-pick visible (PickMethod in spec).
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 15000
- assertVisible: "(?s).*tulis dan baca dengan tenang.*"

View File

@@ -1,19 +1,28 @@
# TS-07 — Returning user with existing display_name skips set-name screen
# (requirement/phase4-customer-flow.md → Test Scenarios → TS-07).
# ts-customer-02-10 — SHome1st "masuk →" login-recover banner · existing
# identified user → /home directly (no set-name detour).
# Spec refs:
# - requirement/flow_customer.mermaid.md §2 (post-OTP path for existing
# identified customer with display_name stored)
# - Project directive: login-intent entries (masuk → banner) land on
# /home; transaction-CTA entries (aku mau curhat / curhat sama bestie
# baru) land on /payment/entry.
#
# Inverse of TS-01..TS-06: those flows wipe the customer (drop_customer=true)
# so every OTP path hits the new-user set-name branch. TS-07 instead seeds
# an EXISTING customer row with phone + display_name, then verifies the
# OTP sign-in returns the existing row unchanged (via
# resolveCustomerForIdentity branch 1) and the client routes directly to
# /home without showing /auth/set-name.
# Pre-seeds an existing identified customer with phone + display_name.
# After OTP succeeds, backend's resolveCustomerForIdentity returns the
# existing row unchanged. The router's onboardingIntentProvider stays at
# `recover` (the masuk → handler resets it defensively), so the post-OTP
# redirect lands on /home rather than /payment/entry.
#
# This was previously named TS-07 (under the §4-driven naming scheme).
# Renamed 2026-05-18 to the ts-customer-NN-MM-* convention; the assertion
# semantics are unchanged.
#
# Pre-reqs:
# - Backend reachable, NODE_ENV != 'production'.
# - (No mitra requirement — flow stops at /home.)
#
# Run:
# maestro test client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml
# maestro test client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+6281234567890"