diff --git a/backend/src/plugins/websocket.js b/backend/src/plugins/websocket.js index da319bc..978a38d 100644 --- a/backend/src/plugins/websocket.js +++ b/backend/src/plugins/websocket.js @@ -23,6 +23,20 @@ export const getSessionConnections = (sessionId) => { return sessionConnections.get(sessionId) || {} } +// Test-only: expose the in-memory connection map state for a session so +// Maestro flows can assert that backgrounding the customer/mitra app closed +// its WebSocket (which is what gates the FCM fallback in chat.service.js). +// Returns booleans per role based on `socket.readyState === 1`. +export const inspectSessionWsState = (sessionId) => { + const conns = sessionConnections.get(sessionId) || {} + const customerSock = conns[UserType.CUSTOMER] + const mitraSock = conns[UserType.MITRA] + return { + customer_connected: !!(customerSock && customerSock.readyState === 1), + mitra_connected: !!(mitraSock && mitraSock.readyState === 1), + } +} + const sendToSocket = (socket, data) => { if (socket && socket.readyState === 1) { socket.send(JSON.stringify(data)) diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index 8682734..4b6459c 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -8,8 +8,11 @@ import { peekStubOtp } from '../../services/otp.service.js' import { acceptPairingRequest, expirePairingRequest, expireTargetedPairingRequest, getPendingRequestsForMitra } from '../../services/pairing.service.js' import { startSessionTimer, _resetThreeMinFiredForTest, _broadcastTimerResyncForTest } from '../../services/session-timer.service.js' +import { inspectSessionWsState } from '../../plugins/websocket.js' +import { sendMessage } from '../../services/chat.service.js' +import { sendPushNotification } from '../../services/notification.service.js' import { getDb } from '../../db/client.js' -import { PairingFailureCause, SessionStatus } from '../../constants.js' +import { PairingFailureCause, SessionStatus, UserType } from '../../constants.js' const sql = getDb() @@ -460,4 +463,127 @@ export const internalTestRoutes = async (fastify) => { const session = await acceptPairingRequest(latest.session_id, mitraId) return { ok: true, session_id: latest.session_id, session } }) + + // Test-only: read the in-memory websocket connection state for a session. + // Used by Maestro flows asserting that backgrounding the customer/mitra + // app closed its WebSocket (which is what gates FCM fallback per + // chat.service.js:51). Returns booleans per role. + fastify.get('/ws-connection-state', async (request, reply) => { + const sessionId = request.query?.session_id + if (!sessionId) { + return reply.code(400).send({ error: 'session_id query param required' }) + } + return inspectSessionWsState(sessionId) + }) + + // Test-only: emulate an FCM push to a specific customer without going + // through the chat-session/WS-fallback path. Useful for poking the + // device's local-notification handler in isolation (e.g. verifying the + // `chat_messages` channel renders, FCM token validity, etc). + // + // Body: { customer_id?, latest_customer?, content? } + // - customer_id: target a specific customers.id. + // - latest_customer: true → pick the most-recently-created customer + // that has an fcm_token (handy when the maestro flow just signed in + // anonymously and you don't have the UUID). + // - content: optional override for the notification body text. + fastify.post('/send-fcm-chat-message', async (request, reply) => { + const { customer_id: customerId, latest_customer: latest, content } = + request.body ?? {} + let targetId = customerId + if (!targetId && latest === true) { + const [row] = await sql` + SELECT id FROM customers + WHERE fcm_token IS NOT NULL + ORDER BY created_at DESC + LIMIT 1 + ` + if (!row) { + return reply.code(404).send({ error: 'no_customer_with_fcm_token' }) + } + targetId = row.id + } + if (!targetId) { + return reply.code(400).send({ + error: 'customer_id or latest_customer:true required in body', + }) + } + const body = content || 'Pesan baru dari bestie · ketuk buat balas' + const ok = await sendPushNotification(UserType.CUSTOMER, targetId, { + title: 'Pesan baru dari Bestie', + body, + data: { type: 'chat_message', session_id: 'test-emulated' }, + }) + return { ok, customer_id: targetId, body } + }) + + // Test-only: send a chat message AS the customer of a paired session. + // Mirrors send-chat-message-as-mitra but with senderType=CUSTOMER — + // useful for asserting the mitra-side FCM fallback when the mitra app is + // backgrounded. Returns the dispatch transport so the caller can assert + // delivered_via=fcm. + // + // Body: { session_id, content } + fastify.post('/send-chat-message-as-customer', async (request, reply) => { + const { session_id: sessionId, content } = request.body ?? {} + if (!sessionId || !content) { + return reply.code(400).send({ error: 'session_id and content required in body' }) + } + const [session] = await sql` + SELECT customer_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1 + ` + if (!session?.customer_id) { + return reply.code(404).send({ error: 'no_session_or_customer', session_id: sessionId }) + } + // Recipient here is the mitra — inspect its WS state before dispatch so + // we can answer "websocket" vs "fcm" honestly. + const wsBefore = inspectSessionWsState(sessionId) + const message = await sendMessage({ + sessionId, + senderType: UserType.CUSTOMER, + senderId: session.customer_id, + content, + }) + return { + ok: true, + message_id: message.id, + delivered_via: wsBefore.mitra_connected ? 'websocket' : 'fcm', + } + }) + + // Test-only: send a chat message AS the mitra of a paired session, using + // the real chat.service.sendMessage code path. Returns which transport + // actually carried the message — useful for asserting the WS-vs-FCM + // fallback (e.g. Maestro backgrounds the customer app, calls this, and + // expects `delivered_via: "fcm"`). + // + // Body: { session_id, content } + fastify.post('/send-chat-message-as-mitra', async (request, reply) => { + const { session_id: sessionId, content } = request.body ?? {} + if (!sessionId || !content) { + return reply.code(400).send({ error: 'session_id and content required in body' }) + } + const [session] = await sql` + SELECT mitra_id FROM chat_sessions WHERE id = ${sessionId} LIMIT 1 + ` + if (!session?.mitra_id) { + return reply.code(404).send({ error: 'no_session_or_mitra', session_id: sessionId }) + } + // Snapshot WS state BEFORE the send so we can answer "which path?" + // honestly: sendMessage tries WS first, falls back to FCM only when WS + // returned false. We inspect customer_connected here because the mitra + // is the sender — recipient is the customer. + const wsBefore = inspectSessionWsState(sessionId) + const message = await sendMessage({ + sessionId, + senderType: UserType.MITRA, + senderId: session.mitra_id, + content, + }) + return { + ok: true, + message_id: message.id, + delivered_via: wsBefore.customer_connected ? 'websocket' : 'fcm', + } + }) } diff --git a/client_app/.maestro/flows/README_section_05.md b/client_app/.maestro/flows/README_section_05.md new file mode 100644 index 0000000..dba8393 --- /dev/null +++ b/client_app/.maestro/flows/README_section_05.md @@ -0,0 +1,93 @@ +# §5 — Chat Room test plan + +Spec: [requirement/flow_customer.mermaid.md §5](../../../requirement/flow_customer.mermaid.md) +plus the user-stated message-delivery contract: + +> 1. WebSocket when app is foreground / WS connected. +> 2. FCM when app is background / WS disconnected. + +The lifecycle observers that enforce (2) live at: +- Customer side: `client_app/lib/main.dart::_AppState.didChangeAppLifecycleState` +- Mitra side: `mitra_app/lib/main.dart::_AppState.didChangeAppLifecycleState` + + `mitra_app/lib/core/chat/mitra_chat_notifier.dart` (`_connectedSessionId` + + getter for the observer to read on `paused`). + +A 15s `Timer.periodic` in `active_session_notifier.dart` would otherwise +re-open the customer WS right after the observer closed it; the +`_appPaused` gate in customer main.dart suppresses that race. + +## Implemented + +| File | Direction | Expected dispatch | +|---|---|---| +| `ts-customer-05-01-background_disconnects_ws_so_messages_fall_back_to_fcm.yaml` | mitra → customer, customer is backgrounded | `delivered_via=fcm` | +| `ts-customer-05-02-customer_message_to_backgrounded_mitra_falls_back_to_fcm.yaml` | customer → mitra, mitra is backgrounded | `delivered_via=fcm` | + +Both flows drive the customer device only (Maestro can only target one +`--udid` per run). The mitra side is verified server-side via +[`/internal/_test/ws-connection-state`](../../../backend/src/routes/internal/_test.routes.js) +and +[`/internal/_test/send-chat-message-as-customer`](../../../backend/src/routes/internal/_test.routes.js) +(introduced 2026-05-18 with the lifecycle fix). + +## Helper scripts (under `../scripts/`) + +| Script | Purpose | +|---|---| +| `assert_ws_state.js` | Reads `/internal/_test/ws-connection-state?session_id=…`; throws on mismatch with `EXPECTED_CUSTOMER_CONNECTED` / `EXPECTED_MITRA_CONNECTED`. | +| `assert_delivered_via.js` | Sends a chat message via the test endpoint matching `SENDER` (`mitra` default, or `customer`); asserts the response's `delivered_via` equals `EXPECTED_DELIVERED_VIA`. | +| `inspect_ws_state.js` | Same data as `assert_ws_state.js` but exports the booleans into `output.CUSTOMER_CONNECTED` / `output.MITRA_CONNECTED` for downstream conditions without throwing. | +| `send_chat_message_as_mitra.js` | Mitra→customer one-shot send. Captures `output.MESSAGE_ID` + `output.DELIVERED_VIA`. | + +## Pre-reqs for both flows + +- Backend reachable; `NODE_ENV != 'production'`. +- ≥1 mitra online (the `seed_history_session.js` helper picks the + most-recently-online mitra to act as the eventual acceptor). +- For 05-01: customer device is the one Maestro drives; `pressKey: HOME` + inside the flow backgrounds it. No mitra-app interaction required. +- For 05-02: **the mitra app on `emulator-5556` must be backgrounded or + force-stopped before the flow runs**. The `assert_ws_state.js` guard + fails loudly if mitra is foreground (proving WS-over-FCM precedence — + also a correct test outcome, just the wrong side of the matrix). To + ensure FCM actually arrives on the device, the mitra account on + `emulator-5556` must have a `fcm_token` in `mitras.fcm_token` (i.e., + the app has signed in and called `/api/shared/device-token` at some + point) — verified by `seed_history_session.js`'s output mitra ID and + the `MITRA_NAME_RE` it captures. + +## Manual mitra-side verification + +After 05-02 passes, optionally inspect the mitra device's notification +shade to confirm the FCM tray notification rendered: + +```bash +export ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 +adb -s emulator-5556 shell cmd statusbar expand-notifications +adb -s emulator-5556 exec-out screencap -p > /tmp/mitra_shade.png +adb -s emulator-5556 shell cmd statusbar collapse +``` + +Expect to see a `Pesan baru dari Customer` notification within ~1s on an +API 28+ AVD (older AVDs queue FCM for 5-30 min — see the per-AVD +Play-Services notes in the project memory). + +## Not yet automated + +**Mitra-side lifecycle observer** end-to-end. To verify that the mitra +app's `didChangeAppLifecycleState(paused)` actually closes its chat WS, +the test would need to drive the mitra emulator (Maestro on +`--udid emulator-5556`) through login → chat-screen → `pressKey: HOME` → +assert backend WS state. This requires: + +1. Pre-seeding an active chat session for the currently-signed-in mitra + (no existing test endpoint for "seed customer + pair + active + session"; the existing scripts assume the customer app drives it). +2. Driving the mitra OTP login + chat-request acceptance UI from + Maestro. + +Both are feasible but out of scope for the current iteration. The +mitra-side fix is covered indirectly: ts-customer-05-02 asserts the +backend correctly dispatches via FCM whenever the mitra WS is closed, +and the mitra app's lifecycle observer was verified manually +(2026-05-18) to be the mechanism that closes that WS. diff --git a/client_app/.maestro/flows/ts-customer-01-01-notif_denied_shows_home_banner.yaml b/client_app/.maestro/flows/ts-customer-01-01-notif_denied_shows_home_banner.yaml new file mode 100644 index 0000000..eae043a --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-01-01-notif_denied_shows_home_banner.yaml @@ -0,0 +1,61 @@ +# ts-customer-01-01 — When OS-level notification permission is denied, +# SHome1st renders the amber "notifikasi off" banner above the +# 'aku mau curhat' CTA. +# Spec ref: requirement/flow_customer.mermaid.md §1 — `NotifCheck → no → +# HomeBanner` (line 24-26 of the mermaid). +# +# Banner widget: home_screen.dart::_NotifDeniedBanner. Gating provider: +# notifPermissionStatusProvider (core/notifications/notif_permission.dart). +# The provider reads `permission_handler::Permission.notification.status` +# which, on Android 13+, reflects the runtime POST_NOTIFICATIONS state and +# on older Android reflects the app's notification-enabled toggle. +# +# Pre-requisite (manual setup): +# The test only exercises the "denied" branch. Disable notifications for +# the customer app BEFORE invoking maestro: +# - Android 13+: maestro's `permissions: { notifications: deny }` below +# handles this (POST_NOTIFICATIONS is a runtime permission). +# - Android 7-12 (incl. the API-24 dev AVD): the runtime permission +# doesn't exist, so the maestro `permissions:` block is a no-op. +# Instead, toggle Settings → Apps → Halo Bestie → Notifications → +# Off manually. This sets the system `NotificationManagerCompat` +# state which the app's `notif_permission.dart::readStatus` +# pre-check picks up. `clearState: true` below is safe — `pm clear` +# wipes app data but not the system-level notification toggle. +# The backend FCM path still fires regardless of this branch (the OS +# simply suppresses the notification); the banner is the only +# user-visible signal that they're missing alerts, which is what we +# assert below. +appId: com.halobestie.client.client_app +env: + TEST_PHONE: "+6281234567890" + 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: true + permissions: + notifications: deny +- extendedWaitUntil: + visible: + text: "Mulai" + timeout: 15000 +- tapOn: + text: "Mulai" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "(?s).*aku mau curhat.*" + timeout: 30000 + +# §1 NotifCheck=no → SHome1st banner. Text is the unique copy from +# _NotifDeniedBanner; the icon is decorative-only and not part of the +# a11y label. +- assertVisible: "(?s).*notifikasi off.*" +- assertVisible: "(?s).*kelewat chat dari bestie.*" +- assertVisible: "(?s).*nyalain.*" +- takeScreenshot: /tmp/notif_banner_design diff --git a/client_app/.maestro/flows/ts-customer-05-01-background_disconnects_ws_so_messages_fall_back_to_fcm.yaml b/client_app/.maestro/flows/ts-customer-05-01-background_disconnects_ws_so_messages_fall_back_to_fcm.yaml new file mode 100644 index 0000000..8d093a0 --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-05-01-background_disconnects_ws_so_messages_fall_back_to_fcm.yaml @@ -0,0 +1,151 @@ +# ts-customer-05-01 — When the customer app is backgrounded, the chat +# WebSocket must close so backend `sendMessage` falls back to FCM. +# +# Spec ref: requirement/flow_customer.mermaid.md §5 (Chat Room) + +# user-stated path: +# 1. WebSocket when app is foreground / WS connected. +# 2. FCM when app is background / WS disconnected. +# +# Fix under test: client_app/lib/main.dart `_AppState.didChangeAppLifecycleState` +# closes the chat WS on AppLifecycleState.paused / detached. Before the +# fix, Android kept the TCP socket alive after the Dart isolate paused, so +# backend's `socket.readyState === 1` check returned true and FCM never +# fired — the customer received no alert in background. +# +# Verification approach: +# 1. Drive customer through pairing → active chat (same harness as +# ts-customer-04-04). +# 2. Assert customer-side WS is connected (backend state check). +# 3. Press HOME to background → wait briefly for the lifecycle observer +# to fire `disconnect()` and the socket-close to round-trip. +# 4. Assert customer-side WS is now closed. +# 5. Send a message AS mitra via the real `sendMessage` code path and +# assert it was delivered via FCM (recipient.customer WS was down at +# dispatch time). +# +# Pre-reqs: +# - Backend reachable; NODE_ENV != 'production'. +# - ≥1 mitra online (the seeded mitra acts as the blast acceptor and the +# subsequent message sender). +appId: com.halobestie.client.client_app +env: + TEST_PHONE: "+6281234567890" + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +# ── Setup: pair customer with a mitra, land in active chat ────────────── +- runScript: + file: ../scripts/reset_phone.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- launchApp: + clearState: true +- runFlow: ../subflows/onboarding_returning_user.yaml + +# Seed history so the SHomeReturning view renders the choice sheet (the +# seeded mitra also becomes the blast acceptor). +- runScript: + file: ../scripts/seed_history_session.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- swipe: + start: "50%, 30%" + end: "50%, 80%" +- extendedWaitUntil: + visible: + text: "(?s).*curhatan sebelumnya.*" + timeout: 10000 + +# "curhat sama bestie baru" → choice sheet → "bestie baru" → method-pick +- tapOn: + text: "(?s).*curhat sama bestie baru.*" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "(?s).*mau curhat sama siapa.*" + timeout: 5000 +- tapOn: "(?s).*cari bestie baru yang siap dengerin.*" + +# Payment chain → confirm → pairing accepted. +- extendedWaitUntil: + visible: + text: "(?s).*pilih cara curhat.*" + timeout: 10000 +- tapOn: + text: "(?s).*tulis dan baca dengan tenang.*" +- extendedWaitUntil: + visible: + text: "(?s).*pilih durasi.*" + timeout: 10000 +- tapOn: "(?s).*5 menit.*" +- tapOn: "(?s).*bayar Rp.*" +- extendedWaitUntil: + visible: + text: "(?s).*cara bayar.*" + timeout: 10000 +- tapOn: + text: "(?s).*bayar Rp.*" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "scan QRIS untuk bayar" + timeout: 10000 +- runScript: + file: ../scripts/mark_latest_payment_paid.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- runFlow: + when: + visible: + text: "(?s).*biar nggak ketinggalan.*" + commands: + - tapOn: + text: "(?s).*nanti aja.*" +- extendedWaitUntil: + visible: + text: "(?s).*lagi nyari bestie.*" + timeout: 20000 +- runScript: + file: ../scripts/mitra_accept_latest_internal.js + env: + MITRA_ID: ${output.MITRA_ID} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- extendedWaitUntil: + visible: + text: "(?s).*online.*" + timeout: 20000 + +# ── Assertion 1: WS is connected when chat screen is in foreground ────── +- runScript: + file: ../scripts/assert_ws_state.js + env: + SESSION_ID: ${output.ACCEPTED_SESSION_ID} + EXPECTED_CUSTOMER_CONNECTED: "true" + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# ── Background the app: customer's WS should close ────────────────────── +- pressKey: HOME + +# Give the lifecycle observer + socket-close round-trip a moment. The +# disconnect() call posts a close frame; the backend reacts on +# `socket.on('close')`. ~1s is usually sufficient on the dev backend. +- waitForAnimationToEnd: + timeout: 3000 + +# ── Assertion 2: backend now reports customer_connected=false ─────────── +- runScript: + file: ../scripts/assert_ws_state.js + env: + SESSION_ID: ${output.ACCEPTED_SESSION_ID} + EXPECTED_CUSTOMER_CONNECTED: "false" + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# ── Assertion 3: a message sent now is delivered via FCM, not WebSocket ─ +- runScript: + file: ../scripts/assert_delivered_via.js + env: + SESSION_ID: ${output.ACCEPTED_SESSION_ID} + CONTENT: "halo, balasan dari mitra sementara kamu di background" + EXPECTED_DELIVERED_VIA: "fcm" + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} diff --git a/client_app/.maestro/flows/ts-customer-05-02-customer_message_to_backgrounded_mitra_falls_back_to_fcm.yaml b/client_app/.maestro/flows/ts-customer-05-02-customer_message_to_backgrounded_mitra_falls_back_to_fcm.yaml new file mode 100644 index 0000000..3e656ba --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-05-02-customer_message_to_backgrounded_mitra_falls_back_to_fcm.yaml @@ -0,0 +1,144 @@ +# ts-customer-05-02 — Symmetric to 05-01: customer sends a message and the +# mitra is in background → backend `sendMessage` falls back to FCM. +# +# Spec ref: requirement/flow_customer.mermaid.md §5 (Chat Room) + +# the user-stated path: +# 1. WebSocket when app is foreground / WS connected. +# 2. FCM when app is background / WS disconnected. +# +# Fix under test: mitra_app/lib/main.dart `_AppState.didChangeAppLifecycleState` +# closes the chat WS on paused/detached (and `mitra_chat_notifier.dart` +# exposes `connectedSessionId` + clears it on disconnect). Without this, +# Android keeps the TCP socket alive after the Dart isolate pauses, so +# the backend believes the mitra is online and never fires FCM — the +# mitra misses customer messages while the app is in background. +# +# Approach (Maestro can only drive one device per flow; here we drive the +# customer, and the mitra side is verified server-side): +# 1. Drive customer to active chat (same harness as ts-customer-04-04 / +# ts-customer-05-01). The acceptor is whichever mitra the +# seed_history_session.js helper picks (most-recently-online → on +# our setup that is the signed-in mitra on emulator-5556). +# 2. Assert backend says mitra_connected=false. This is true when: +# (a) the mitra app is force-stopped / not running, OR +# (b) the mitra app is on the chat screen AND backgrounded — the +# lifecycle observer should have closed the WS. +# Either condition fulfils the precondition for FCM fallback. +# 3. Send a message AS customer via /internal/_test/send-chat-message-as-customer +# and assert delivered_via="fcm". +# +# Pre-reqs (HARD): +# - Backend reachable; NODE_ENV != 'production'. +# - emulator-5556 mitra app: either force-stopped before the test, or +# signed in with TestMitra-1501 (the seed_history_session pick) and +# currently backgrounded. If the mitra is foreground in the chat +# screen, mitra_connected=true and this test asserts will fail — +# which is the correct failure mode (proves WS-over-FCM precedence). +# - The currently-signed-in mitra must have a `fcm_token` row in +# `mitras.fcm_token`; otherwise the FCM dispatch succeeds at the +# backend code path but never reaches a device. +appId: com.halobestie.client.client_app +env: + TEST_PHONE: "+6281234567890" + BACKEND_INTERNAL_URL: http://localhost:3001 +--- +# ── Setup: drive customer through onboarding → pair via blast ─────────── +- runScript: + file: ../scripts/reset_phone.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- launchApp: + clearState: true +- runFlow: ../subflows/onboarding_returning_user.yaml + +- runScript: + file: ../scripts/seed_history_session.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- swipe: + start: "50%, 30%" + end: "50%, 80%" +- extendedWaitUntil: + visible: + text: "(?s).*curhatan sebelumnya.*" + timeout: 10000 + +- tapOn: + text: "(?s).*curhat sama bestie baru.*" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "(?s).*mau curhat sama siapa.*" + timeout: 5000 +- tapOn: "(?s).*cari bestie baru yang siap dengerin.*" + +- extendedWaitUntil: + visible: + text: "(?s).*pilih cara curhat.*" + timeout: 10000 +- tapOn: + text: "(?s).*tulis dan baca dengan tenang.*" +- extendedWaitUntil: + visible: + text: "(?s).*pilih durasi.*" + timeout: 10000 +- tapOn: "(?s).*5 menit.*" +- tapOn: "(?s).*bayar Rp.*" +- extendedWaitUntil: + visible: + text: "(?s).*cara bayar.*" + timeout: 10000 +- tapOn: + text: "(?s).*bayar Rp.*" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "scan QRIS untuk bayar" + timeout: 10000 +- runScript: + file: ../scripts/mark_latest_payment_paid.js + env: + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- runFlow: + when: + visible: + text: "(?s).*biar nggak ketinggalan.*" + commands: + - tapOn: + text: "(?s).*nanti aja.*" +- extendedWaitUntil: + visible: + text: "(?s).*lagi nyari bestie.*" + timeout: 20000 +- runScript: + file: ../scripts/mitra_accept_latest_internal.js + env: + MITRA_ID: ${output.MITRA_ID} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +- extendedWaitUntil: + visible: + text: "(?s).*online.*" + timeout: 20000 + +# ── Assertion 1: backend says mitra is NOT connected ──────────────────── +# Precondition guard: if the mitra app is foreground, this assert fails +# loud — which is what we want (it proves WS would win over FCM). +- runScript: + file: ../scripts/assert_ws_state.js + env: + SESSION_ID: ${output.ACCEPTED_SESSION_ID} + EXPECTED_CUSTOMER_CONNECTED: "true" + EXPECTED_MITRA_CONNECTED: "false" + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} + +# ── Assertion 2: a customer-sent message is delivered via FCM ─────────── +- runScript: + file: ../scripts/assert_delivered_via.js + env: + SESSION_ID: ${output.ACCEPTED_SESSION_ID} + CONTENT: "halo bestie, ini pesan saat kamu di background" + EXPECTED_DELIVERED_VIA: "fcm" + SENDER: "customer" + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} diff --git a/client_app/.maestro/scripts/assert_delivered_via.js b/client_app/.maestro/scripts/assert_delivered_via.js new file mode 100644 index 0000000..7e03749 --- /dev/null +++ b/client_app/.maestro/scripts/assert_delivered_via.js @@ -0,0 +1,40 @@ +// Send a chat message AS the given side and assert the delivery transport +// matches EXPECTED_DELIVERED_VIA. Throws on mismatch. +// +// Env: +// SESSION_ID (required) +// CONTENT (required) +// EXPECTED_DELIVERED_VIA (required, "websocket" or "fcm") +// SENDER (optional, "mitra" (default) or "customer") +// BACKEND_INTERNAL_URL (optional) +const sessionId = SESSION_ID +const content = CONTENT +const expected = EXPECTED_DELIVERED_VIA +const sender = typeof SENDER !== 'undefined' && SENDER ? SENDER : 'mitra' +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +if (!sessionId) throw new Error('SESSION_ID env not set') +if (!content) throw new Error('CONTENT env not set') +if (expected !== 'websocket' && expected !== 'fcm') { + throw new Error('EXPECTED_DELIVERED_VIA must be "websocket" or "fcm"') +} +if (sender !== 'mitra' && sender !== 'customer') { + throw new Error('SENDER must be "mitra" or "customer"') +} + +const endpoint = + sender === 'mitra' + ? '/internal/_test/send-chat-message-as-mitra' + : '/internal/_test/send-chat-message-as-customer' +const resp = http.post(`${url}${endpoint}`, { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, content }), +}) +if (resp.status !== 200) { + throw new Error(`send-chat-message-as-mitra failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +if (data.delivered_via !== expected) { + throw new Error(`delivered_via mismatch: expected=${expected}, got=${data.delivered_via}`) +} +output.MESSAGE_ID = data.message_id +output.DELIVERED_VIA = data.delivered_via diff --git a/client_app/.maestro/scripts/assert_ws_state.js b/client_app/.maestro/scripts/assert_ws_state.js new file mode 100644 index 0000000..796bb4b --- /dev/null +++ b/client_app/.maestro/scripts/assert_ws_state.js @@ -0,0 +1,35 @@ +// Assert backend's in-memory WS connection state for a session matches +// expectations. Throws (fails the maestro flow) on mismatch. +// +// Env: +// SESSION_ID (required) +// EXPECTED_CUSTOMER_CONNECTED (required, "true" or "false") +// EXPECTED_MITRA_CONNECTED (optional, "true" or "false") +// BACKEND_INTERNAL_URL (optional, default http://localhost:3001) +const sessionId = SESSION_ID +const expCust = EXPECTED_CUSTOMER_CONNECTED +const expMitra = typeof EXPECTED_MITRA_CONNECTED !== 'undefined' ? EXPECTED_MITRA_CONNECTED : null +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +if (!sessionId) throw new Error('SESSION_ID env not set') +if (expCust !== 'true' && expCust !== 'false') { + throw new Error('EXPECTED_CUSTOMER_CONNECTED must be "true" or "false"') +} + +const resp = http.get(`${url}/internal/_test/ws-connection-state?session_id=${sessionId}`) +if (resp.status !== 200) { + throw new Error(`ws-connection-state failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) + +const gotCust = String(data.customer_connected) +if (gotCust !== expCust) { + throw new Error(`customer_connected mismatch: expected=${expCust}, got=${gotCust}`) +} +if (expMitra !== null) { + const gotMitra = String(data.mitra_connected) + if (gotMitra !== expMitra) { + throw new Error(`mitra_connected mismatch: expected=${expMitra}, got=${gotMitra}`) + } +} +output.CUSTOMER_CONNECTED = gotCust +output.MITRA_CONNECTED = String(data.mitra_connected) diff --git a/client_app/.maestro/scripts/inspect_ws_state.js b/client_app/.maestro/scripts/inspect_ws_state.js new file mode 100644 index 0000000..dbb9726 --- /dev/null +++ b/client_app/.maestro/scripts/inspect_ws_state.js @@ -0,0 +1,19 @@ +// Reads the in-memory websocket connection state for a given session via +// the dev-only /internal/_test/ws-connection-state endpoint. Used to assert +// that backgrounding the customer app closes its WS — the gate that flips +// chat.service.sendMessage to the FCM fallback path. +// +// Env: +// SESSION_ID (required) +// BACKEND_INTERNAL_URL (optional, default http://localhost:3001) +const sessionId = SESSION_ID +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +if (!sessionId) throw new Error('SESSION_ID env not set') + +const resp = http.get(`${url}/internal/_test/ws-connection-state?session_id=${sessionId}`) +if (resp.status !== 200) { + throw new Error(`ws-connection-state failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.CUSTOMER_CONNECTED = String(data.customer_connected) +output.MITRA_CONNECTED = String(data.mitra_connected) diff --git a/client_app/.maestro/scripts/send_chat_message_as_mitra.js b/client_app/.maestro/scripts/send_chat_message_as_mitra.js new file mode 100644 index 0000000..94a80ca --- /dev/null +++ b/client_app/.maestro/scripts/send_chat_message_as_mitra.js @@ -0,0 +1,26 @@ +// Send a chat message AS the mitra of the given session via the dev-only +// /internal/_test/send-chat-message-as-mitra endpoint. Goes through the +// real chat.service.sendMessage code path. Returns which transport +// carried the message — `websocket` if the customer's WS was alive at +// dispatch time, `fcm` otherwise. +// +// Env: +// SESSION_ID (required) +// CONTENT (required, message body) +// BACKEND_INTERNAL_URL (optional, default http://localhost:3001) +const sessionId = SESSION_ID +const content = CONTENT +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +if (!sessionId) throw new Error('SESSION_ID env not set') +if (!content) throw new Error('CONTENT env not set') + +const resp = http.post(`${url}/internal/_test/send-chat-message-as-mitra`, { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, content }), +}) +if (resp.status !== 200) { + throw new Error(`send-chat-message-as-mitra failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.MESSAGE_ID = data.message_id +output.DELIVERED_VIA = data.delivered_via diff --git a/client_app/lib/core/notifications/notif_permission.dart b/client_app/lib/core/notifications/notif_permission.dart index e33db5d..75a7dba 100644 --- a/client_app/lib/core/notifications/notif_permission.dart +++ b/client_app/lib/core/notifications/notif_permission.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:permission_handler/permission_handler.dart' as ph; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -14,11 +16,29 @@ enum NotifPermStatus { notDetermined, granted, denied } /// - iOS uses Firebase Messaging (which surfaces the system UNNotification /// authorization status). /// - Android 13+ uses `permission_handler` for `Permission.notification` -/// (POST_NOTIFICATIONS runtime). Older Android always reports granted. +/// (POST_NOTIFICATIONS runtime). +/// - Android <13: POST_NOTIFICATIONS doesn't exist as a runtime permission, +/// so `permission_handler` returns `granted` regardless of the user's +/// Settings → Apps → Notifications toggle. We add a +/// `NotificationManagerCompat.areNotificationsEnabled()` pre-check via +/// `flutter_local_notifications` to honour that user-facing toggle on +/// older Android. class NotifPermission { const NotifPermission(); Future readStatus() async { + // Older Android: permission_handler can't see the + // Settings → Apps → Notifications toggle, so we ask the local-notifs + // plugin (backed by NotificationManagerCompat.areNotificationsEnabled, + // which works from API 19). If disabled there, surface it as `denied` + // so the SHome1st banner reflects reality on API 24-32. + if (Platform.isAndroid) { + final androidImpl = FlutterLocalNotificationsPlugin() + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + final enabled = await androidImpl?.areNotificationsEnabled(); + if (enabled == false) return NotifPermStatus.denied; + } final phStatus = await ph.Permission.notification.status; return _mapPh(phStatus); } diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index 0a15456..9202894 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -50,9 +50,22 @@ class _SearchingScreenState extends ConsumerState { // "Curhat lagi" flow stamped the targeted mitra onto the draft before // payment, so we fire the targeted request and bounce to the dedicated // wait overlay; everything else is a general blast. - final state = ref.read(pairingProvider); + // + // Carry-over guard: if a previous chat session ended, pairingProvider + // retains its terminal state (PairingActiveData with the *old* + // sessionId, PairingFailed, PairingCancelled, etc). Without resetting + // here, the `state is PairingInitialData` branch wouldn't fire and + // `_onPairingState` below would re-emit a stale PairingActiveData → + // /chat/session/, dropping the customer on a chat + // screen for a `completed` session ("Sesi sudah berakhir"). Reset to + // Initial whenever we have a fresh payment to consume. + final draft = ref.read(paymentDraftNotifierProvider); + var state = ref.read(pairingProvider); + if (state is! PairingInitialData && draft.paymentId != null) { + ref.read(pairingProvider.notifier).reset(); + state = ref.read(pairingProvider); + } if (state is PairingInitialData) { - final draft = ref.read(paymentDraftNotifierProvider); if (draft.paymentId != null) { if (draft.targetedMitraId != null) { // ignore: discarded_futures diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 1d9d33c..3e5b839 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -40,13 +40,22 @@ class App extends ConsumerStatefulWidget { ConsumerState createState() => _AppState(); } -class _AppState extends ConsumerState { +class _AppState extends ConsumerState with WidgetsBindingObserver { bool _fcmRegistered = false; bool _authProvidersPreloaded = false; + // Tracks whether the OS has paused/detached this isolate. The + // activeSessionProvider runs a 15s poll (see active_session_notifier.dart); + // each tick fires the listener below, which would otherwise re-open the + // chat WebSocket immediately after didChangeAppLifecycleState closed it + // — defeating the WS→FCM fallback. We gate the reconnect on this flag so + // the WS stays closed while the app is backgrounded, even as polling + // continues. + bool _appPaused = false; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); // Phase 4: preload server-driven auth-provider gating once on cold start. // Cached via @Riverpod(keepAlive: true) — subsequent reads are instant. WidgetsBinding.instance.addPostFrameCallback((_) { @@ -56,6 +65,45 @@ class _AppState extends ConsumerState { }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Background → close the chat WebSocket so backend `sendMessage` falls + // back to FCM (chat.service.js:51 — `if (!delivered) sendPushNotification`). + // Without this, Android keeps the TCP socket alive after the Dart + // isolate is paused, so the backend believes the customer is online and + // never fires the push — the user sees no alert until they reopen the + // app. See flow_customer.mermaid.md §5 (chat room) and the + // implementation note in main.dart's activeSession listener. + // + // Foreground → re-establish the WS for the current active session, if + // any. activeSessionProvider's last cached snapshot drives the target + // session id; the chat notifier's `connectIfNotConnected` is a no-op + // when the same session is already wired up. + final notifier = ref.read(chatProvider.notifier); + if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _appPaused = true; + if (notifier.connectedSessionId != null) { + notifier.disconnect(); + } + } else if (state == AppLifecycleState.resumed) { + _appPaused = false; + final snapshot = ref.read(activeSessionProvider).valueOrNull; + final sessionId = snapshot?.sessionId; + if (sessionId != null && + (snapshot?.hasSession ?? false) && + notifier.connectedSessionId != sessionId) { + notifier.connectIfNotConnected(sessionId); + } + } + } + void _registerFcmToken() { if (_fcmRegistered) return; _fcmRegistered = true; @@ -88,6 +136,12 @@ class _AppState extends ConsumerState { // active session, regardless of which screen is mounted. The chat screen // only joins this connection — it doesn't own it. FCM remains the // background-only fallback. + // + // Gate on `_appPaused`: activeSessionProvider runs a 15s poll that fires + // this listener on every tick. If we reconnect while the app is + // backgrounded, we undo the disconnect that didChangeAppLifecycleState + // just performed and the FCM fallback never triggers for messages that + // arrive during background. ref.listen(activeSessionProvider, (prev, next) { final snapshot = next.valueOrNull; final notifier = ref.read(chatProvider.notifier); @@ -97,6 +151,7 @@ class _AppState extends ConsumerState { } return; } + if (_appPaused) return; final sessionId = snapshot.sessionId; if (sessionId != null && notifier.connectedSessionId != sessionId) { notifier.connectIfNotConnected(sessionId); diff --git a/mitra_app/lib/core/chat/mitra_chat_notifier.dart b/mitra_app/lib/core/chat/mitra_chat_notifier.dart index c6afc42..2afe835 100644 --- a/mitra_app/lib/core/chat/mitra_chat_notifier.dart +++ b/mitra_app/lib/core/chat/mitra_chat_notifier.dart @@ -143,6 +143,13 @@ class MitraChat extends _$MitraChat { WebSocketChannel? _channel; StreamSubscription? _wsSubscription; Timer? _typingTimer; + // Survives `disconnect()` so a later `didChangeAppLifecycleState(resumed)` + // can re-issue `connect(sessionId)` with the right session — disconnect() + // resets `state` to MitraChatInitialData, which is otherwise the only + // record of which chat we were attached to. + String? _connectedSessionId; + + String? get connectedSessionId => _connectedSessionId; ApiClient get _apiClient => ref.read(apiClientProvider); @@ -150,6 +157,7 @@ class MitraChat extends _$MitraChat { MitraChatData build() => const MitraChatInitialData(); Future connect(String sessionId) async { + _connectedSessionId = sessionId; state = const MitraChatConnectingData(); try { final sessionInfo = await _apiClient.get('/api/shared/chat/$sessionId/info'); @@ -222,6 +230,7 @@ class MitraChat extends _$MitraChat { void disconnect() { _cleanup(); + _connectedSessionId = null; // State reset is deferred to post-frame: disconnect() is called from // mitra_chat_screen's deactivate() during back-nav, and a synchronous // `state =` here notifies watchers while the chat screen's widget tree is diff --git a/mitra_app/lib/main.dart b/mitra_app/lib/main.dart index ee99489..bb0fd14 100644 --- a/mitra_app/lib/main.dart +++ b/mitra_app/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/api/api_client_provider.dart'; import 'core/auth/auth_notifier.dart'; +import 'core/chat/mitra_chat_notifier.dart'; import 'core/status/status_notifier.dart'; import 'core/chat/chat_request_notifier.dart'; import 'core/chat/widgets/chat_request_overlay.dart'; @@ -30,6 +31,13 @@ class App extends ConsumerStatefulWidget { class _AppState extends ConsumerState with WidgetsBindingObserver { bool _fcmRegistered = false; + // Session the chat WS was on at the moment we backgrounded. Restored on + // resume so a backgrounded mitra reconnects to the same chat once they + // foreground the app. Mirrors the customer-app fix (main.dart on the + // client side) — backend's sendMessage checks recipient WS readyState + // before falling back to FCM, so leaving the WS open while paused makes + // FCM never fire and the mitra misses customer messages in background. + String? _pausedChatSessionId; @override void initState() { @@ -47,8 +55,24 @@ class _AppState extends ConsumerState with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { ref.read(onlineStatusProvider.notifier).onAppPaused(); + // Close the chat WS so backend `sendMessage` falls back to FCM when + // the customer sends a message. Stash the active session_id so we + // can rejoin it on resume. + final chatNotifier = ref.read(mitraChatProvider.notifier); + final sid = chatNotifier.connectedSessionId; + if (sid != null) { + _pausedChatSessionId = sid; + chatNotifier.disconnect(); + } } else if (state == AppLifecycleState.resumed) { ref.read(onlineStatusProvider.notifier).onAppResumed(); + // Reconnect to the chat we backgrounded out of, if any. + final saved = _pausedChatSessionId; + _pausedChatSessionId = null; + if (saved != null) { + // ignore: discarded_futures + ref.read(mitraChatProvider.notifier).connect(saved); + } } }