Phase 4 Stage 10 Maestro: 09_chat_tab.yaml + seed-pending-payment endpoint

Closes the Stage 10 acceptance criterion §10.11 #13 (Maestro coverage).

- New dev-only `POST /internal/_test/seed-pending-payment` — inserts a
  payment_sessions row in `pending` status with expires_at 20m out, so
  the Pembayaran sub-tab has a deterministic row to render. Body
  accepts { phone, isExtension?, amount?, durationMinutes?, mode? }.
  Gated on NODE_ENV != 'production' like the other test routes.

- New Maestro helper script `seed_pending_payment.js` mirrors the
  existing seed_history_session pattern.

- New flow `09_chat_tab.yaml`:
    cold-start onboarding → home (returning view) →
    seed completed session + seed pending payment →
    tap "💬 chat" bottom-nav → lands on /chat/aktif via redirect →
    assert "aktif" / "pembayaran" / "selesai" pills + empty-state copy →
    tap pembayaran → assert "menunggu pembayaran sesi" + "bayar Rp..." →
    tap selesai → assert "X menit" duration row → tap row → assert
    "Transkrip Chat" appbar → back → still on /chat/selesai.

  Maestro parsed the YAML cleanly and started executing against the
  device; full run requires backend + online mitra in dev DB (same
  pre-reqs as flows 03/05/06/08).

- TECH_DEBT entry: Stage 10 retired the standalone bestie-history list
  screen, which means (a) the "curhat lagi" targeted-payment entry
  point has no UI affordance anywhere in the app — its plumbing in
  payment_notifier / payment_screen is now orphaned, and (b) the
  Stage 8 flow `08_returning_targeted.yaml` will fail at
  `assertVisible: "Riwayat Chat"` because it expects the deleted
  screen. Three fix paths listed in the entry for product to pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:24:50 +08:00
parent e3ea1d793e
commit 1908e98012
4 changed files with 328 additions and 0 deletions

105
TECH_DEBT.md Normal file
View File

@@ -0,0 +1,105 @@
# Tech Debt
Running list of known shortcuts, deferred hardening, and "good enough for now"
decisions that need follow-up before they bite us in production.
Format: `[date]` short title, then enough context for someone (or future-you)
to act on it without re-deriving the discussion.
---
## Backend
### `[2026-05-11]` Public `GET /api/public/bestie/available` needs rate limiting before prod
**File:** `backend/src/routes/public/public.bestie-availability.routes.js`
**Decision:** The endpoint was made unauthenticated by business requirement —
SHome1st renders before any JWT exists, and the CTA must reflect global mitra
availability so users see whether bestie is online before committing to
onboarding. Response is intentionally a single boolean (no count, no IDs).
**Why it's debt:** No auth + no rate limit. The 10s in-memory cache bounds DB
load, but a single attacker can still hammer the endpoint to:
- run sustained traffic against the public listener (DoS surface)
- scrape `available` over time to infer mitra online/offline patterns (weak
information leak — only "is anyone online", but still a signal)
**Mitigation before prod:**
- Per-IP rate limit (suggested: ~30 req/min/IP, headroom over the legitimate
5s client poll cadence = 12 req/min/IP).
- Implement via `@fastify/rate-limit` plugin so other public endpoints can
share the policy as we add them under `/api/public/*`.
- Verify Cloud Run / NLB preserves real client IP and that
`request.ip` reflects it (Fastify already has `trustProxy: true`).
**Not required:** auth, captcha, or removing the count from
`/api/client/mitra-availability` (that route stays authed for CC/debug).
---
## Client app
### `[2026-05-11]` Social-login (Google / Apple) has no entry point after S3a rewrite
**Files:** `client_app/lib/features/auth/screens/register_screen.dart` (no longer renders them); `client_app/lib/core/auth/auth_providers_provider.dart` (still wired).
**Decision:** `RegisterScreen` was rewritten to match Figma `S3Phone` 1:1
(step-dots + name greeting + privacy card + tanpa-verif ghost link). Figma
S3a shows no Google/Apple buttons, so they were removed from this screen.
**Why it's debt:** Google/Apple buttons used to render here when the
`authProvidersProvider` flags were enabled. Today both flags are `false`
(creds pending — see `Phase 3.4 Status` memory), so nothing visible is
missing. But the moment `/api/shared/auth-providers` flips either flag,
the buttons have nowhere to live.
**Fix-when-creds-arrive:**
- Decide where Google/Apple buttons belong (likely a dedicated login screen
reachable from the SHome1st "masuk →" banner), or whether to bring them
back to S3a as Figma-friendly tiles above the phone input.
- `loginGoogle` / `loginApple` on `authProvider` are still intact, so the
wiring is one button widget away.
### `[2026-05-12]` Stage 10 — "curhat lagi" entry point lost; Stage 8 Maestro flow broken
**Files:** `client_app/lib/features/home/widgets/bestie_choice_sheet.dart` (line ~54, now routes to `/chat`); `client_app/.maestro/flows/08_returning_targeted.yaml` (asserts "Riwayat Chat" + "curhat lagi" which no longer render).
**Decision:** Stage 10 retired `chat_history_screen.dart` and re-pointed the BestieChoiceSheet "bestie yang udah kenal" CTA at `/chat` (which redirects to `/chat/aktif`). The Selesai sub-tab matches Figma `SChatList` — transcript-only, no per-row "curhat lagi" button.
**Why it's debt:** The "curhat lagi" targeted-payment entry point lived only on the deleted history screen (line 213 `label: 'curhat lagi'``context.push('/payment', extra: { 'targetedMitraId': ... })`). After Stage 10 there is **no** UI affordance to start a targeted payment against a known mitra from the customer app — only the general "Mulai Curhat → blast" path. The targeted-payment plumbing in `payment_notifier.dart` / `payment_screen.dart` is now orphaned (still wired, no caller).
Side-effect: `08_returning_targeted.yaml` (Stage 8) expects to navigate from BestieChoiceSheet → bestie-history list → "curhat lagi" tap → targeted payment. The middle screen is gone; the flow will fail at `assertVisible: "Riwayat Chat"`.
**Fix options (pick one with product):**
- **A.** Add a "curhat lagi" secondary CTA on Selesai rows (deviates from Figma SChatList but restores the feature). Update `08_returning_targeted.yaml` to navigate `/chat/selesai → tap row's curhat-lagi → targeted payment`.
- **B.** Keep Selesai as transcript-only per Figma; reintroduce a "pick a past bestie" picker reachable from BestieChoiceSheet (essentially restore `chat_history_screen.dart` under a new name + route, kept separate from the Chat tab).
- **C.** Drop the "curhat lagi" feature entirely. Update mermaid + BestieChoiceSheet copy to remove the "bestie yang udah kenal" branch. Delete orphaned targeted-payment plumbing.
Until decided, `08_returning_targeted.yaml` should be marked `.skip` or the Stage 8 flow rewritten against the new home → SHomeReturning history list (which DOES still render bestie rows via `bestieHistoryProvider`, but those rows tap-through to transcripts only — same "no curhat lagi" gap).
### `[2026-05-12]` S5 ESP screen retired from spec — code still ships it
**Files:** `client_app/lib/features/onboarding/` (S5ESP screen + nav wiring);
`screens/onboarding.jsx::S5ESP` (Figma reference still in handoff); any
`espSelectionProvider` / `espSkippedProvider` Riverpod state.
**Decision:** Business removed the ESP multi-select step from the customer
flow on 2026-05-12. Both verified and anonymous branches now go from
`VerifChoiceSheet` straight to the `usp_seen?` gate. See
`requirement/flow_customer.mermaid.md` §2.
**Why it's debt:** Stage 2 (commit `2645bcd`) shipped the ESP screen and its
state providers. The screen is still reachable in the current build. The
mermaid spec is the source of truth — the code has drifted behind by one
business decision.
**Fix:**
- Delete the ESP screen widget and its route registration.
- Remove `espSelectionProvider` / `espSkippedProvider` and any nav step that
routes through ESP.
- Wire `VerifChoiceSheet → USPGate → (USP screen | skip → next)` directly.
- Drop the "ESP is decorative only" memory (it's now superseded by removal).
- Keep `screens/onboarding.jsx::S5ESP` in the Figma handoff folder — it's
history, not active design.

View File

@@ -245,4 +245,45 @@ export const internalTestRoutes = async (fastify) => {
` `
return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name } return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name }
}) })
// Seed a payment_sessions row in `pending` status for the customer linked
// to `phone`, with expires_at safely in the future. Used by Maestro Stage
// 10 flow (09_chat_tab.yaml) to populate the Pembayaran sub-tab without
// walking the multi-screen S6 paywall → method → duration → method flow.
//
// Body: { phone, isExtension?, amount?, durationMinutes?, mode? }
// - isExtension: defaults false (initial-session payment)
// - amount: defaults 5000 IDR
// - durationMinutes: defaults 15
// - mode: 'chat' (default) | 'call'
fastify.post('/seed-pending-payment', async (request, reply) => {
const phone = request.body?.phone
if (!phone) {
return reply.code(400).send({ error: 'phone required in body' })
}
const isExtension = request.body?.isExtension === true
const amount = Number.isFinite(request.body?.amount) ? request.body.amount : 5000
const durationMinutes = Number.isFinite(request.body?.durationMinutes)
? request.body.durationMinutes
: 15
const mode = request.body?.mode === 'call' ? 'call' : 'chat'
const [customer] = await sql`
SELECT id FROM customers WHERE phone = ${phone} LIMIT 1
`
if (!customer) {
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
}
const [row] = await sql`
INSERT INTO payment_sessions (
customer_id, amount, duration_minutes, is_first_session_discount,
is_extension, status, mode, expires_at
) VALUES (
${customer.id}, ${amount}, ${durationMinutes}, false,
${isExtension}, 'pending', ${mode}, NOW() + INTERVAL '20 minutes'
)
RETURNING id, customer_id, amount, is_extension, status, expires_at
`
return { ok: true, payment_id: row.id, ...row }
})
} }

View File

@@ -0,0 +1,161 @@
# Stage 10 acceptance: Chat tab (3 sub-tabs).
#
# Flow:
# 1. Cold-start onboarding (abbreviated; mirrors 01_smoke) → home with a
# phone-verified customer so the dev seed endpoints can find them.
# 2. Seed a completed chat_sessions row → Selesai sub-tab has data.
# 3. Seed a pending payment_sessions row → Pembayaran sub-tab has data
# AND the bottom-nav chat tab should render a red dot (visual; not
# asserted because Maestro can't reliably introspect small pixel state).
# 4. Tap the "💬 chat" bottom-nav icon → /chat redirects to /chat/aktif.
# 5. Aktif sub-tab: no active session, so empty state copy shows.
# 6. Tap "pembayaran" pill → row with preview "menunggu pembayaran sesi"
# and the "bayar Rp..." chip.
# 7. Tap "selesai" pill → row with seeded mitra name. Tap the row →
# transcript screen opens.
# 8. Back → still on /chat/selesai (URL is source of truth for the sub-tab).
#
# Pre-req: client_app debug APK installed, backend reachable on
# BACKEND_INTERNAL_URL with NODE_ENV != 'production' so /internal/_test/*
# routes are registered, AND at least one mitra is online in the dev DB.
#
# Run:
# maestro test client_app/.maestro/flows/09_chat_tab.yaml
appId: com.halobestie.client.client_app
env:
TEST_PHONE: "+628155556678"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# Wipe prior state for TEST_PHONE so the run is hermetic.
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
# Onboarding carousel → splash → home (1st time view).
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Verif choice sheet → "Lanjut sebagai Tamu" → name → phone OTP
- extendedWaitUntil:
visible:
text: "Lanjut sebagai Tamu"
timeout: 10000
- 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 Akun"
timeout: 15000
- 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
# Returning users land on SHomeReturning with "Mulai Curhat".
- extendedWaitUntil:
visible:
text: "Mulai Curhat"
timeout: 20000
# Seed a completed session → Selesai sub-tab populated.
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Seed a pending payment → Pembayaran sub-tab populated + red dot eligibility.
- runScript:
file: ../scripts/seed_pending_payment.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Tap "💬 chat" in the bottom nav → /chat → redirected to /chat/aktif.
- tapOn:
text: "chat"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "aktif"
timeout: 10000
# Sub-tab pills visible; the heading "chat" duplicates with the nav label
# so assert what's unique to the chat-tab shell.
- assertVisible: "aktif"
- assertVisible: "pembayaran"
- assertVisible: "selesai"
# Aktif default body: empty state since no active session was seeded.
- assertVisible: "belum ada chat di sini"
# Pembayaran sub-tab → seeded pending initial payment is visible.
- tapOn:
text: "pembayaran"
- extendedWaitUntil:
visible:
text: "menunggu pembayaran sesi"
timeout: 5000
- assertVisible:
text: "bayar Rp.*"
# Selesai sub-tab → seeded completed session is visible.
- tapOn:
text: "selesai"
- extendedWaitUntil:
visible:
text: ".* menit"
timeout: 5000
# Tap the row → opens the read-only transcript screen.
- tapOn:
text: ".* menit"
- extendedWaitUntil:
visible:
text: "Transkrip Chat"
timeout: 10000
# Back returns us to /chat/selesai (URL preserves sub-tab state).
- back
- extendedWaitUntil:
visible:
text: "selesai"
timeout: 5000
- assertVisible: "selesai"

View File

@@ -0,0 +1,21 @@
// Seed a `pending` payment_sessions row for TEST_PHONE so the Pembayaran
// sub-tab has a row to render without walking the multi-screen S6 paywall →
// method-pick → duration-pick → method flow.
//
// Hits the dev-only /internal/_test/seed-pending-payment endpoint.
const phone = TEST_PHONE
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const body = {
phone,
isExtension: PENDING_KIND === 'extension',
amount: PENDING_AMOUNT ? Number(PENDING_AMOUNT) : 5000,
}
const resp = http.post(`${url}/internal/_test/seed-pending-payment`, {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`seed-pending-payment failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.PAYMENT_ID = data.payment_id