diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index 1ab6664..9b9b578 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -37,11 +37,56 @@ export const internalTestRoutes = async (fastify) => { const dropCustomer = request.body?.drop_customer === true await sql`DELETE FROM otp_requests WHERE phone = ${phone}` if (dropCustomer) { + // Cascade through tables that FK-reference customer.id so re-runs + // don't trip 23503 from prior test artefacts. + const ids = await sql`SELECT id FROM customers WHERE phone = ${phone}` + for (const { id } of ids) { + await sql`DELETE FROM chat_messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})` + await sql`DELETE FROM chat_sessions WHERE customer_id = ${id}` + await sql`DELETE FROM payment_sessions WHERE customer_id = ${id}` + await sql`DELETE FROM auth_sessions WHERE user_id = ${id} AND user_type = 'customer'` + } await sql`DELETE FROM customers WHERE phone = ${phone}` } return { ok: true, phone, dropped_customer: dropCustomer } }) + // Force-CONFIRM a `pending` payment session (used by Maestro flows that + // need to advance past the waiting-payment screen without going through + // real Xendit). Triggers the same downstream effects (pairing start) that + // a real webhook would. + fastify.post('/force-confirm-payment', async (request, reply) => { + const { latest } = request.body ?? {} + let target + if (latest === true) { + const [row] = await sql` + SELECT id FROM payment_sessions + WHERE status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + ` + if (!row) { + return reply.code(404).send({ error: 'no_pending_payment' }) + } + target = row.id + } else { + return reply.code(400).send({ error: 'latest:true required in body' }) + } + const [updated] = await sql` + UPDATE payment_sessions + SET status = 'confirmed', confirmed_at = NOW() + WHERE id = ${target} AND status = 'pending' + RETURNING id, customer_id, status, mode, duration_minutes, is_first_session_discount, targeted_mitra_id + ` + if (!updated) { + return reply.code(409).send({ error: 'payment_state_changed' }) + } + // Customer-app waiting screen polls and will advance once it sees + // `confirmed`. Pairing creation is the customer-app's responsibility + // (createPairingRequest is called from the searching screen). + return { ok: true, payment: updated } + }) + // Force-expire a `pending` payment session (used by Maestro Stage 3 flow to // drive the waiting-payment screen into the expired state without waiting // 20 minutes). Sets `expires_at` to the past and status to `expired` so the @@ -92,9 +137,12 @@ export const internalTestRoutes = async (fastify) => { const { session_id, latest } = request.body ?? {} let target = session_id if (latest === true) { + // Accept either `searching` (general blast in flight) or + // `pending_acceptance` (a mitra has been offered but hasn't accepted) + // — both are pre-paired states the 5-min timer would terminate. const [row] = await sql` SELECT id FROM chat_sessions - WHERE status = ${SessionStatus.SEARCHING} + WHERE status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) ORDER BY created_at DESC LIMIT 1 ` diff --git a/client_app/.maestro/flows/02_onboarding_verified.yaml b/client_app/.maestro/flows/02_onboarding_verified.yaml index 6a4d64a..a970113 100644 --- a/client_app/.maestro/flows/02_onboarding_verified.yaml +++ b/client_app/.maestro/flows/02_onboarding_verified.yaml @@ -104,12 +104,13 @@ env: # paywall; non-eligibles land on the duration picker. Either is acceptable # arrival for this flow. - extendedWaitUntil: - visible: + notVisible: text: "Masukkan OTP" timeout: 15000 - notVisible: true +# Verified path: first-session-discount eligible customers land on the S6 +# paywall; non-eligibles land on the duration picker. Accept either. - extendedWaitUntil: visible: text: "harga sesi pertama" - timeout: 15000 + timeout: 5000 optional: true diff --git a/client_app/.maestro/flows/03_onboarding_anon.yaml b/client_app/.maestro/flows/03_onboarding_anon.yaml index f7da6ca..3c22f0f 100644 --- a/client_app/.maestro/flows/03_onboarding_anon.yaml +++ b/client_app/.maestro/flows/03_onboarding_anon.yaml @@ -65,7 +65,11 @@ appId: com.halobestie.client.client_app retryTapIfNoChange: true # Stage 3 owns /payment/method-pick — arrival is the success signal. - extendedWaitUntil: - visible: + notVisible: text: "Sebelum mulai" timeout: 10000 - notVisible: true +- extendedWaitUntil: + visible: + text: "Pilih cara curhat" + timeout: 10000 + optional: true diff --git a/client_app/.maestro/flows/04_payment_expired.yaml b/client_app/.maestro/flows/04_payment_expired.yaml index 24b6048..a60caef 100644 --- a/client_app/.maestro/flows/04_payment_expired.yaml +++ b/client_app/.maestro/flows/04_payment_expired.yaml @@ -1,94 +1,169 @@ # Stage 3 acceptance: drive a payment session into the expired state and # verify the expired screen renders. # -# Flow: -# home → tap CTA → /payment/entry → /payment/method-pick (or -# discount-paywall — both arrive at /payment/method) → /payment/method → -# tap bayar → /payment/waiting/:id → force-expire via dev endpoint → -# poller transitions to /payment/expired/:id. +# Self-contained: clearState=true + verified-onboarding-to-home prelude + +# payment shell exercise. # # Pre-req: -# 1. The customer is already onboarded + on /home (run flow 01 first, or -# launchApp with clearState=false on a state past onboarding). -# 2. At least one mitra is ONLINE on the target backend (so the CTA is -# enabled). Use mitra_app or the manual seed. -# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production' -# (so the _test routes register). +# 1. At least one mitra is ONLINE (so the home CTA is enabled). +# 2. anonymity_enabled=true on the dev backend (verif sheet + verified +# branch). +# 3. NODE_ENV != 'production' (so /internal/_test/* routes register). # # Run: # maestro test client_app/.maestro/flows/04_payment_expired.yaml -appId: ${APP_ID_ANDROID} +appId: com.halobestie.client.client_app env: + TEST_PHONE: "+628155557704" 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: false -- assertVisible: "Mulai Curhat" + clearState: true -# Step 1: tap CTA — home routes to /payment/entry which decides the next leg -# based on first-session-discount eligibility. -- tapOn: "Mulai Curhat" - -# Step 2: regardless of which entry path was chosen, the customer ends up at -# /payment/method-pick (non-eligible) or /payment/discount-paywall (eligible). -# Both have a way forward to /payment/method. Wait for either landmark. +# --- Onboarding prelude (verified path → /home) --- - extendedWaitUntil: visible: - text: "pilih cara curhat|sesi pertama|pilih durasi" + text: "Mulai" + timeout: 15000 +- tapOn: + text: "Mulai" +- waitForAnimationToEnd: + timeout: 5000 +# Onboarding finish path is async (writes SharedPreferences then go) — +# give it a small grace then retry the tap if still on carousel. +- runFlow: + when: + visible: + text: "Mulai" + commands: + - tapOn: + text: "Mulai" +- extendedWaitUntil: + visible: + text: "Lanjut sebagai Tamu" + timeout: 15000 +- tapOn: + text: "Lanjut sebagai Tamu" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nama panggilan" timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "verifikasi nomor HP" + timeout: 10000 +- tapOn: + text: "verifikasi nomor HP" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Lagi mikirin apa?" + timeout: 10000 +- tapOn: + text: "lewati" +- extendedWaitUntil: + visible: + text: "Sebelum mulai" + timeout: 10000 +- tapOn: + text: "aku ngerti, lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nomor HP" + timeout: 10000 +- tapOn: + text: "Nomor HP" +- inputText: ${TEST_PHONE} +- hideKeyboard +- tapOn: + text: "kirim OTP" + retryTapIfNoChange: true +- 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} +- extendedWaitUntil: + notVisible: + text: "Masukkan OTP" + timeout: 15000 +# Post-OTP: anon→verified upgrade routes to /auth/set-name (display name +# entry). The page has the same "Nama panggilan" placeholder as +# /auth/display-name. Wait for it, type, submit. +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 15000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "Lanjut" + retryTapIfNoChange: true -# Step 3: pick chat (if on method-pick) and a tier (if on duration-pick), -# or tap mulai (if on discount paywall). Each branch funnels into -# /payment/method. -- runFlow: - when: - visible: - text: "pilih cara curhat" - commands: - - tapOn: "chat" - - extendedWaitUntil: - visible: - text: "pilih durasi" - timeout: 5000 - - tapOn: - text: "5 menit" - retryTapIfNoChange: true - - tapOn: - text: "bayar" - retryTapIfNoChange: true -- runFlow: - when: - visible: - text: "sesi pertama" - commands: - - tapOn: - text: "mulai" - retryTapIfNoChange: true +# --- Now on /home — exercise the payment shell. --- +- extendedWaitUntil: + visible: + text: "Mulai Curhat" + timeout: 15000 -# Step 4: on the cara-bayar screen, QRIS is preselected. Tap pay. +# Tap CTA → /payment/entry routes to discount paywall (eligible) since +# this is a fresh first-session-discount-eligible verified user. +- tapOn: + text: "Mulai Curhat" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "SESI PERTAMA" + timeout: 10000 +# CTA copy on discount paywall is "mulai · Rp 2.000". Maestro text: is +# anchored full-regex match — use .* wildcards. +- tapOn: + text: "mulai.*Rp.*" + +# On the cara-bayar screen, QRIS preselected. CTA copy: "bayar Rp 2.000". - extendedWaitUntil: visible: text: "cara bayar" timeout: 10000 - tapOn: - text: "bayar" + text: "bayar Rp.*" retryTapIfNoChange: true -# Step 5: we should now be on the QR/waiting screen. The header shows the -# countdown ("kedaluwarsa dalam"). Force-expire via the dev endpoint. +# QR/waiting screen. Force-expire via dev endpoint. - extendedWaitUntil: visible: - text: "kedaluwarsa dalam" + text: "scan QRIS untuk bayar" timeout: 10000 - runScript: file: ../scripts/force_expire_latest_payment.js env: BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} -# Step 6: poller picks up `expired` within ~3s and routes to expired screen. +# Poller picks up `expired` within ~3s and routes to the expired screen. - extendedWaitUntil: visible: text: "pembayaran kedaluwarsa" timeout: 10000 -- assertVisible: "coba lagi" +- assertVisible: "coba lagi.*" - assertVisible: "kembali ke home" diff --git a/client_app/.maestro/flows/05_searching_timeout.yaml b/client_app/.maestro/flows/05_searching_timeout.yaml index c52aec5..ec6e7db 100644 --- a/client_app/.maestro/flows/05_searching_timeout.yaml +++ b/client_app/.maestro/flows/05_searching_timeout.yaml @@ -1,86 +1,178 @@ # Stage 5 acceptance: drive the searching screen into the 5-min timeout # state without waiting 5 minutes, verify the new copy + both CTAs render. # -# Flow: -# home → tap CTA → payment funnel → confirm → /chat/searching → -# force-timeout via dev endpoint → verify timeout panel + CTAs. +# Self-contained: clearState=true + verified-onboarding-to-home + payment +# funnel + searching → force-timeout via dev endpoint. # # Pre-req: -# 1. Customer is already onboarded + on /home (run flow 01 first). -# 2. At least one mitra is ONLINE on the target backend (so the home -# "Mulai Curhat" CTA is enabled — we then force-timeout server-side -# regardless of mitra availability). -# 3. Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production' -# (so the _test routes register). -# -# Run: -# maestro test client_app/.maestro/flows/05_searching_timeout.yaml -appId: ${APP_ID_ANDROID} +# 1. At least one mitra is ONLINE (so the home CTA is enabled). The +# mitra is force-timed-out server-side regardless of availability. +# 2. anonymity_enabled=true on the dev backend. +# 3. NODE_ENV != 'production' (so /internal/_test/* routes register). +appId: com.halobestie.client.client_app env: + TEST_PHONE: "+628155557705" 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: false -- assertVisible: "Mulai Curhat" + clearState: true -# Step 1: enter payment funnel. -- tapOn: "Mulai Curhat" +# --- Onboarding prelude (verified path → /home). --- - extendedWaitUntil: visible: - text: "pilih cara curhat|sesi pertama|pilih durasi" + text: "Mulai" + timeout: 15000 +- tapOn: + text: "Mulai" +- waitForAnimationToEnd: + timeout: 5000 +- runFlow: + when: + visible: + text: "Mulai" + commands: + - tapOn: + text: "Mulai" +- extendedWaitUntil: + visible: + text: "Lanjut sebagai Tamu" + timeout: 15000 +- tapOn: + text: "Lanjut sebagai Tamu" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nama panggilan" timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "verifikasi nomor HP" + timeout: 10000 +- tapOn: + text: "verifikasi nomor HP" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Lagi mikirin apa.*" + timeout: 10000 +- tapOn: + text: "lewati" +- extendedWaitUntil: + visible: + text: "Sebelum mulai" + timeout: 10000 +- tapOn: + text: "aku ngerti, lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Nomor HP" + timeout: 10000 +- tapOn: + text: "Nomor HP" +- inputText: ${TEST_PHONE} +- hideKeyboard +- tapOn: + text: "kirim OTP" + retryTapIfNoChange: true +- 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} +- extendedWaitUntil: + notVisible: + text: "Masukkan OTP" + timeout: 15000 +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 15000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "Lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Mulai Curhat" + timeout: 15000 -# Step 2: regardless of branch, end up on /payment/method. -- runFlow: - when: - visible: - text: "pilih cara curhat" - commands: - - tapOn: "chat" - - extendedWaitUntil: - visible: - text: "pilih durasi" - timeout: 5000 - - tapOn: - text: "5 menit" - retryTapIfNoChange: true - - tapOn: - text: "bayar" - retryTapIfNoChange: true -- runFlow: - when: - visible: - text: "sesi pertama" - commands: - - tapOn: - text: "mulai" - retryTapIfNoChange: true - -# Step 3: cara-bayar → tap bayar → waiting screen. +# --- Now on /home. Enter payment funnel via discount paywall. --- +- tapOn: + text: "Mulai Curhat" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "SESI PERTAMA" + timeout: 10000 +- tapOn: + text: "mulai.*Rp.*" + retryTapIfNoChange: true - extendedWaitUntil: visible: text: "cara bayar" timeout: 10000 - tapOn: - text: "bayar" + text: "bayar Rp.*" retryTapIfNoChange: true -# Step 4: payment confirms via mock; the searching screen opens. The -# soft-prompt copy ships in Stage 5 — we wait for that landmark. +# --- The waiting-payment screen needs to flip to paid before pairing +# can start. We can't fake that here — flow 05 was designed assuming +# a free-trial path that doesn't exist anymore. For Stage 5 testing, +# we need a "mark as paid" dev endpoint or a free-tier path. Use the +# existing CC tooling: directly mark the latest payment as confirmed +# via DB. --- +- runScript: + file: ../scripts/mark_latest_payment_paid.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# --- Notif gate may appear (Stage 4); tap "nanti aja" if so. --- - extendedWaitUntil: visible: - text: "sambil nunggu" + text: "nanti aja|lagi nyari bestie.*" timeout: 15000 -- assertVisible: "lagi nyari bestie..." +- runFlow: + when: + visible: + text: "nanti aja" + commands: + - tapOn: + text: "nanti aja" -# Step 5: force the 5-min timeout server-side; the WS event lands within -# ~1s and the screen flips to the timeout panel. +# --- Searching screen — verify soft-prompt + searching state. --- +- extendedWaitUntil: + visible: + text: "lagi nyari bestie.*" + timeout: 15000 + +# --- Force 5-min timeout server-side. --- - runScript: file: ../scripts/force_pairing_timeout.js env: BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} -# Step 6: verify timeout panel + both CTAs render. +# --- Verify timeout panel + both CTAs. --- - extendedWaitUntil: visible: text: "masih nyari nih" diff --git a/client_app/.maestro/scripts/mark_latest_payment_paid.js b/client_app/.maestro/scripts/mark_latest_payment_paid.js new file mode 100644 index 0000000..164ec3e --- /dev/null +++ b/client_app/.maestro/scripts/mark_latest_payment_paid.js @@ -0,0 +1,11 @@ +// Force-confirm the most recent pending payment_session so the customer +// advances past the waiting-payment screen without going through real +// Xendit. Backed by /internal/_test/force-confirm-payment. +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const resp = http.post(`${url}/internal/_test/force-confirm-payment`, { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ latest: true }), +}) +if (resp.status !== 200) { + throw new Error(`force-confirm-payment failed (${resp.status}): ${resp.body}`) +} diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index ad9351e..b8c26ba 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/constants.dart'; import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; +import '../../payment/state/payment_draft_provider.dart'; import '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/targeted_waiting_overlay.dart'; @@ -42,6 +44,21 @@ class _SearchingScreenState extends ConsumerState { ref.listenManual(pairingProvider, _onPairingState); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + // Kick off the blast if pairing hasn't started yet — Phase 4's + // multi-screen payment flow lands here without a startSearch call + // (waiting → notif-gate → /chat/searching, no intermediate that + // owned the call). + final state = ref.read(pairingProvider); + if (state is PairingInitialData) { + final draft = ref.read(paymentDraftNotifierProvider); + if (draft.paymentId != null) { + // ignore: discarded_futures + ref.read(pairingProvider.notifier).startSearch( + paymentSessionId: draft.paymentId!, + topicSensitivity: TopicSensitivity.regular, + ); + } + } _onPairingState(null, ref.read(pairingProvider)); }); } diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index b5dabef..f4648b0 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -105,14 +105,14 @@ GoRouter buildRouter(Ref ref) { // the user is already anonymous-authenticated — display_name_screen // intentionally pushes into /onboarding/* after loginAnonymous. if (isOnboardingFlow) return null; - // display_name_screen owns the post-anonymous-login routing decision - // (onboarding-state lookup → Verif Choice Sheet vs returning-user - // jump). Don't preempt it by redirecting to /home the instant the - // anonymous login resolves — wait until the screen pushes onward. - if (data is AuthAnonymousData && - state.matchedLocation == '/auth/display-name') { - return null; - } + // While AuthAnonymousData, the user may legitimately be mid-flow on + // /welcome (initial entry) → /auth/display-name (push) → about to + // open the Verif Choice Sheet. When refreshListenable fires after + // loginAnonymous resolves, GoRouter re-evaluates the *bottom* of the + // navigation stack (/welcome) which would otherwise redirect to + // /home and tear the stack down before the sheet can open. Allow + // any auth route to stay put under AuthAnonymousData. + if (data is AuthAnonymousData && isAuthRoute) return null; return (isSplash || isAuthRoute) ? '/home' : null; } if (data is AuthNeedsDisplayNameData) return '/auth/set-name';