Mitra Bestie §1–§3: shell + Undangan + popup + chat polish

Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 11:14:30 +08:00
parent fcb8eaa505
commit fbc94daac7
59 changed files with 5039 additions and 687 deletions

View File

@@ -0,0 +1,64 @@
// Seed a confirmed payment_session for the test customer and fire a general blast.
// Used by Maestro flows that drive the mitra side and need a customer's
// request to arrive without running a second app.
//
// Required env: BACKEND_URL, TEST_CUSTOMER_JWT (from .maestro/config.yaml)
//
// Replaces customer_blast_now.sh (Maestro's runScript only supports JS, not shell).
const backend = BACKEND_URL || 'http://localhost:3000'
const internal = BACKEND_INTERNAL_URL || 'http://localhost:3001'
if (!TEST_CUSTOMER_JWT || TEST_CUSTOMER_JWT.startsWith('REPLACE')) {
// Test customer creds aren't set — create an anonymous customer instead so the
// suite still works on a fresh dev machine.
const auth = http.post(`${backend}/api/shared/auth/anonymous`, {
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
if (auth.status !== 201 && auth.status !== 200) {
throw new Error(`anonymous auth failed (${auth.status}): ${auth.body}`)
}
const ad = json(auth.body)
output.TEST_CUSTOMER_JWT = ad.data.access_token
// give the anonymous customer a display name so the chat-request endpoint accepts
http.post(`${backend}/api/client/auth/profile`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ad.data.access_token}` },
body: JSON.stringify({ display_name: 'BlastTester' }),
method: 'PATCH',
})
}
const token = output.TEST_CUSTOMER_JWT || TEST_CUSTOMER_JWT
// Step 1: create a payment session (5 min chat)
const psResp = http.post(`${backend}/api/client/payment-sessions`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ duration_minutes: 5, mode: 'chat' }),
})
if (psResp.status !== 200 && psResp.status !== 201) {
throw new Error(`create-payment failed (${psResp.status}): ${psResp.body}`)
}
const ps = json(psResp.body)
output.PAYMENT_SESSION_ID = ps.data.id
// Step 2: force-confirm via internal test endpoint (skip real Xendit)
const confirmResp = http.post(`${internal}/internal/_test/force-confirm-payment`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latest: true }),
})
if (confirmResp.status !== 200) {
throw new Error(`force-confirm-payment failed (${confirmResp.status}): ${confirmResp.body}`)
}
// Step 3: fire the chat request (general blast)
const brResp = http.post(`${backend}/api/client/chat/request`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ payment_session_id: ps.data.id, topic_sensitivity: 'regular' }),
})
if (brResp.status !== 200 && brResp.status !== 201) {
throw new Error(`fire-blast failed (${brResp.status}): ${brResp.body}`)
}
const br = json(brResp.body)
output.SESSION_ID = br.data.id
console.log('blast fired — session_id:', br.data.id)

View File

@@ -33,4 +33,8 @@ curl -fsSL -X POST "$BACKEND_URL/api/client/chat-requests" \
-H "Content-Type: application/json" \
-d "{\"payment_session_id\":\"$payment_session_id\",\"topic_sensitivity\":\"regular\"}" > /dev/null
# Persist payment_session_id so follow-up scripts (e.g. customer_cancel_latest_blast.sh)
# can read it without a peek-payment endpoint. /tmp lifetime is sufficient
# for one test run.
echo "$payment_session_id" > /tmp/halobestie_last_blast_payment_session_id
echo "OK — blast fired. Mitra should receive the WS event within ~1s."

View File

@@ -0,0 +1,31 @@
// Cancel the test customer's most-recent in-flight blast (pairing request).
// Used by ts-mitra-3-07-popup_cancelled_by_customer.yaml to drive the
// "Permintaan dibatalkan oleh klien" stale-card UX on the mitra side
// without waiting on any timer.
//
// Reads PAYMENT_SESSION_ID and TEST_CUSTOMER_JWT from `output` — both set by
// customer_blast_now.js, so callers must run customer_blast_now.js first
// in the same flow.
//
// Hits POST /api/client/chat-requests/cancel. The pairing service emits
// `chat_request_closed reason=cancelled_by_customer` to every mitra that
// received the blast (pairing.service.js:585-592).
const backend = BACKEND_URL || 'http://localhost:3000'
if (!output.PAYMENT_SESSION_ID) {
throw new Error('PAYMENT_SESSION_ID missing — run customer_blast_now.js first')
}
const token = output.TEST_CUSTOMER_JWT || TEST_CUSTOMER_JWT
if (!token || token.startsWith('REPLACE')) {
throw new Error('TEST_CUSTOMER_JWT missing — customer_blast_now.js should have set output.TEST_CUSTOMER_JWT')
}
const resp = http.post(`${backend}/api/client/chat/chat-requests/cancel`, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ payment_session_id: output.PAYMENT_SESSION_ID }),
})
if (resp.status !== 200 && resp.status !== 201 && resp.status !== 204) {
throw new Error(`cancel-pairing failed (${resp.status}): ${resp.body}`)
}
console.log('cancel fired for payment_session_id:', output.PAYMENT_SESSION_ID)

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Cancel the test customer's most-recent in-flight blast (pairing request).
# Used by ts-mitra-3-07-popup_cancelled_by_customer.yaml to drive the
# "Permintaan dibatalkan oleh klien" stale-card UX on the mitra side
# without waiting on any timer.
#
# The payment_session_id is sourced from /tmp/halobestie_last_blast_payment_session_id
# which customer_blast_now.sh writes on every blast. Callers must run
# customer_blast_now.sh first.
#
# Hits POST /api/client/chat-requests/cancel with TEST_CUSTOMER_JWT. The
# pairing service emits `chat_request_closed reason=cancelled_by_customer`
# to every mitra that received the blast (pairing.service.js:585-592).
set -euo pipefail
: "${BACKEND_URL:?BACKEND_URL must be set in .maestro/config.yaml}"
: "${TEST_CUSTOMER_JWT:?TEST_CUSTOMER_JWT must be set in .maestro/config.yaml}"
PAYMENT_SESSION_ID_FILE="/tmp/halobestie_last_blast_payment_session_id"
if [[ ! -s "$PAYMENT_SESSION_ID_FILE" ]]; then
echo "ERROR: $PAYMENT_SESSION_ID_FILE missing or empty — run customer_blast_now.sh first"
exit 2
fi
PAYMENT_SESSION_ID=$(cat "$PAYMENT_SESSION_ID_FILE")
echo "Cancelling pairing for payment_session_id=$PAYMENT_SESSION_ID..."
curl -fsSL -X POST "$BACKEND_URL/api/client/chat-requests/cancel" \
-H "Authorization: Bearer $TEST_CUSTOMER_JWT" \
-H "Content-Type: application/json" \
-d "{\"payment_session_id\":\"$PAYMENT_SESSION_ID\"}" > /dev/null
echo "OK — cancel fired. Mitra should see chat_request_closed within ~1s."

View File

@@ -0,0 +1,22 @@
// Delete the mitra_online_status row for a specific mitra. Used by the
// "freshly created user, no online record" scenario (ts-mitra-1-01a) to
// simulate the natural pre-app-launch state.
//
// After this script runs, the next /api/mitra/status call from the app
// will hit ensureStatusRow() in mitra-status.service.js, which INSERTs a
// fresh row with the DB default is_online=false. So home will render
// BestieHomeOffline.
//
// Required env: MITRA_ID
// Optional env: BACKEND_INTERNAL_URL (defaults to localhost:3001)
const url = (BACKEND_INTERNAL_URL || 'http://localhost:3001') + '/internal/_test/delete-mitra-status-row'
const resp = http.post(url, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mitra_id: MITRA_ID }),
})
if (resp.status !== 200) {
throw new Error(`delete-mitra-status-row failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
console.log('delete_mitra_status_row:', JSON.stringify(data))

View File

@@ -0,0 +1,31 @@
// Force a specific mitra OFFLINE via the dev-only
// /internal/_test/force-mitra-offline endpoint. Used by Maestro flows that
// need the home screen to render its OFFLINE variant on app launch (e.g.
// ts-mitra-1-02-home_offline_renders.yaml) without driving the toggle from
// the UI.
//
// Reads MITRA_ID from env (typically `${output.MITRA_ID}` from a prior
// seed_mitra.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_mitra.js')
}
// Ensure a status row exists first — force-mitra-offline 404s if there's no
// row yet (a freshly-seeded mitra has none until they sign in once). Cheapest
// way is reset-all-mitras-online: idempotent, upserts every mitra to online,
// then we flip the one we care about offline immediately below.
const ensureOnline = http.post(`${url}/internal/_test/reset-all-mitras-online`, {
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
if (ensureOnline.status !== 200) {
throw new Error(`reset-all-mitras-online failed (${ensureOnline.status}): ${ensureOnline.body}`)
}
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 specific mitra ONLINE in mitra_online_status.
// Used by scenario flows that need to simulate "mitra was ONLINE before
// they last logged out" so the next login lands on BestieHome (online variant).
//
// Required env: MITRA_ID (typically from output.MITRA_ID after seed_mitra)
// Optional env: BACKEND_INTERNAL_URL (defaults to localhost:3001)
const url = (BACKEND_INTERNAL_URL || 'http://localhost:3001') + '/internal/_test/force-mitra-online'
const resp = http.post(url, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mitra_id: MITRA_ID }),
})
if (resp.status !== 200) {
throw new Error(`force-mitra-online failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
console.log('force_mitra_online:', JSON.stringify(data))

View File

@@ -0,0 +1,24 @@
// Force-expire the most-recent SEARCHING / PENDING_ACCEPTANCE chat_session
// by hitting the dev-only /internal/_test/force-pairing-timeout endpoint.
// Used by ts-mitra-3-06-popup_expires_after_30s.yaml to surface the
// "Permintaan kedaluwarsa" stale-card UX without waiting the real 30s.
//
// Backend (backend/src/routes/internal/_test.routes.js::force-pairing-timeout):
// - Locates the latest SEARCHING or PENDING_ACCEPTANCE chat_session
// - For blast pairings: expirePairingRequest(target, NO_MITRA_AVAILABLE)
// - For targeted pairings: expireTargetedPairingRequest(target)
// - Either path broadcasts `chat_request_closed` with reason=expired so the
// mitra-app's chat_request_notifier flips into ChatRequestStaleData(expired)
//
// Writes the pairing kind + session id to output so callers can branch.
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
const resp = http.post(`${url}/internal/_test/force-pairing-timeout`, {
body: JSON.stringify({ latest: true }),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`force-pairing-timeout failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.SESSION_ID = data.session_id
output.PAIRING_KIND = data.kind

View File

@@ -0,0 +1,23 @@
// Force-set the expires_at of the most-recent ACTIVE chat_session by hitting
// the dev-only /internal/_test/force-session-expires-at endpoint. Used by the
// Stage 3 mitra maestro flow (ts-mitra-3-04-session_ended_banner.yaml) to
// drive the red "Durasi sesi habis" banner and "SELESAI" timer pill without
// waiting in real time.
//
// Reads BACKEND_INTERNAL_URL + SECONDS_FROM_NOW from env. Backend re-runs
// startSessionTimer with the new schedule AND broadcasts a timer resync so
// the chat screen flips into the sessionExpired UI within ~1s.
const url = BACKEND_INTERNAL_URL || 'http://localhost:3001'
// Default to a negative offset so the session is immediately expired — the
// mitra flow wants the ended banner / SELESAI pill, not the 3-min warning.
const seconds = parseInt(SECONDS_FROM_NOW || '-1', 10)
const resp = http.post(`${url}/internal/_test/force-session-expires-at`, {
body: JSON.stringify({ latest: true, seconds_from_now: seconds }),
headers: { 'Content-Type': 'application/json' },
})
if (resp.status !== 200) {
throw new Error(`force-session-expires-at failed (${resp.status}): ${resp.body}`)
}
const data = json(resp.body)
output.SESSION_ID = data.session_id
output.EXPIRES_AT = data.expires_at

View File

@@ -15,3 +15,8 @@ const resp = http.post(`${url}/internal/_test/seed-mitra`, {
if (resp.status !== 200) {
throw new Error(`seed-mitra failed (${resp.status}): ${resp.body}`)
}
// Expose the upserted mitra row id so downstream scripts that need it (e.g.
// force_mitra_offline.js) can pick it up via ${output.MITRA_ID} without an
// extra lookup. Older flows that don't read it are unaffected.
const data = json(resp.body)
output.MITRA_ID = data.id