Phase 4 Stage 9: real-device sweep, 4 flows green + 2 shipping bugs fixed

Stage 9 sweep on Client_Phone AVD + physical mitra phone:
- 01_smoke 
- 02_onboarding_verified 
- 03_onboarding_anon 
- 04_payment_expired 
- 05_searching_timeout: in progress when wrap-up began
- 06–08: not yet attempted

## Real shipping bugs fixed (would have hit prod)

1. **Router carve-out too narrow** (router.dart). The AuthAnonymousData
   carve-out only protected /auth/display-name. On refreshListenable
   notify after loginAnonymous resolves, GoRouter re-evaluates the
   *bottom* of the navigation stack (/welcome — also an auth route),
   and the AuthAnonymousData fallback redirected to /home, tearing down
   the verif sheet before it could open. Loosened to allow any auth
   route under AuthAnonymousData.

2. **Phase 4 multi-screen payment never called startSearch**
   (searching_screen.dart). The legacy single-screen /payment did
   `pairing.startSearch()` on confirm. The Phase 4 flow is
   waiting → notif-gate → /chat/searching with no intermediate that
   owned the call — customers would land on the searching screen with
   no pairing in flight and never get matched. Added the kickoff to
   searching_screen::initState when state is PairingInitialData and
   paymentDraft.paymentId is set.

## Test infrastructure

- Self-contained Maestro flows 04 + 05 with inline verified-onboarding
  prelude, distinct test phones per flow, robust waits.
- 02 + 03 fixed: malformed `extendedWaitUntil` (visible: + notVisible:
  true → Maestro parsed as compound predicate); now use proper
  notVisible: block.
- New dev-only POST /internal/_test/force-confirm-payment so flows can
  advance past the waiting-payment screen without going through Xendit.
- /internal/_test/reset-phone now cascades through chat_messages →
  chat_sessions → payment_sessions → auth_sessions before deleting the
  customer row (FK 23503 was blocking re-runs).
- /internal/_test/force-pairing-timeout now accepts both
  `searching` and `pending_acceptance` states (mitra-online dev means
  the chat_session transitions through searching very quickly).
- mark_latest_payment_paid.js helper script for Stage 5+ flows.

## Maestro YAML quirks documented in flows

- text: matches anchored regex against the FULL content-desc — need .*
  wildcards for substring, e.g. "mulai.*Rp.*" not "mulai".
- The middot `·` and other special unicode break naive matching;
  always use .* anchors when the source string contains them.
- runFlow `when:` evaluates immediately; pair with waitForAnimationToEnd
  or a preceding extendedWaitUntil before branching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 22:11:05 +08:00
parent ccc52a5c3c
commit 770f61074c
8 changed files with 371 additions and 123 deletions

View File

@@ -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
`