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:
64
mitra_app/.maestro/scripts/customer_blast_now.js
Normal file
64
mitra_app/.maestro/scripts/customer_blast_now.js
Normal 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)
|
||||
@@ -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."
|
||||
|
||||
31
mitra_app/.maestro/scripts/customer_cancel_latest_blast.js
Normal file
31
mitra_app/.maestro/scripts/customer_cancel_latest_blast.js
Normal 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)
|
||||
33
mitra_app/.maestro/scripts/customer_cancel_latest_blast.sh
Executable file
33
mitra_app/.maestro/scripts/customer_cancel_latest_blast.sh
Executable 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."
|
||||
22
mitra_app/.maestro/scripts/delete_mitra_status_row.js
Normal file
22
mitra_app/.maestro/scripts/delete_mitra_status_row.js
Normal 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))
|
||||
31
mitra_app/.maestro/scripts/force_mitra_offline.js
Normal file
31
mitra_app/.maestro/scripts/force_mitra_offline.js
Normal 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}`)
|
||||
}
|
||||
17
mitra_app/.maestro/scripts/force_mitra_online.js
Normal file
17
mitra_app/.maestro/scripts/force_mitra_online.js
Normal 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))
|
||||
24
mitra_app/.maestro/scripts/force_pairing_timeout.js
Normal file
24
mitra_app/.maestro/scripts/force_pairing_timeout.js
Normal 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
|
||||
23
mitra_app/.maestro/scripts/force_session_expires_at.js
Normal file
23
mitra_app/.maestro/scripts/force_session_expires_at.js
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user