From 4680c36e347d9faf2a204421605b62f8acb3b727 Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Sun, 10 May 2026 16:19:22 +0800 Subject: [PATCH] OTP test infrastructure for Maestro flows Dev-only /internal/_test/peek-otp + /internal/_test/reset-phone endpoints gated by NODE_ENV !== 'production'. peek-otp reads the latest stub OTP out of an in-memory map populated by otp.service.js fazpassSendStub; reset-phone wipes otp_requests rows (and optionally the customers row) so flows can re-run without tripping cooldowns. JS + shell helpers under .maestro/scripts/ wrap the endpoints for use inside Maestro runScript steps. 01_smoke.yaml expanded from a launch-only sanity check to a full cold-start onboarding -> force-register -> OTP -> home walk. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/src/app.internal.js | 6 ++ backend/src/routes/internal/_test.routes.js | 41 ++++++++ backend/src/services/otp.service.js | 8 ++ client_app/.maestro/flows/01_smoke.yaml | 101 ++++++++++++++++++-- client_app/.maestro/scripts/peek_otp.js | 13 +++ client_app/.maestro/scripts/peek_otp.sh | 20 ++++ client_app/.maestro/scripts/reset_phone.js | 11 +++ client_app/.maestro/scripts/reset_phone.sh | 20 ++++ 8 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 backend/src/routes/internal/_test.routes.js create mode 100644 client_app/.maestro/scripts/peek_otp.js create mode 100755 client_app/.maestro/scripts/peek_otp.sh create mode 100644 client_app/.maestro/scripts/reset_phone.js create mode 100755 client_app/.maestro/scripts/reset_phone.sh diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js index dfc001c..aae5bfe 100644 --- a/backend/src/app.internal.js +++ b/backend/src/app.internal.js @@ -10,6 +10,7 @@ import { internalConfigRoutes } from './routes/internal/config.routes.js' import { sessionManagementRoutes } from './routes/internal/session.routes.js' import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js' import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js' +import { internalTestRoutes } from './routes/internal/_test.routes.js' import { errorHandler } from './plugins/error-handler.js' export const buildInternalApp = async () => { @@ -38,5 +39,10 @@ export const buildInternalApp = async () => { app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' }) app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' }) + // Dev/test-only — never registered in production builds. + if (process.env.NODE_ENV !== 'production') { + app.register(internalTestRoutes, { prefix: '/internal/_test' }) + } + return app } diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js new file mode 100644 index 0000000..b6cf391 --- /dev/null +++ b/backend/src/routes/internal/_test.routes.js @@ -0,0 +1,41 @@ +// Dev/test-only routes. Registration in app.internal.js is gated on +// NODE_ENV !== 'production' so these endpoints never exist in prod builds. +// +// Used by Maestro flows + curl harnesses to read state that the OTP stub +// keeps in memory (the stub-generated code per phone), without baking +// test phone numbers or fixed codes into production code paths. + +import { peekStubOtp } from '../../services/otp.service.js' +import { getDb } from '../../db/client.js' + +const sql = getDb() + +export const internalTestRoutes = async (fastify) => { + fastify.get('/peek-otp', async (request, reply) => { + const phone = request.query?.phone + if (!phone) { + return reply.code(400).send({ error: 'phone query param required' }) + } + const entry = peekStubOtp(phone) + if (!entry) { + return reply.code(404).send({ error: 'no_otp_for_phone', phone }) + } + return entry + }) + + // Wipe rate-limit + cooldown state for a phone so flows can re-run quickly. + // Deletes otp_requests rows for the phone and (optionally) the customer row + // so identity-upgrade flows start fresh. + fastify.post('/reset-phone', async (request, reply) => { + const phone = request.body?.phone + if (!phone) { + return reply.code(400).send({ error: 'phone required in body' }) + } + const dropCustomer = request.body?.drop_customer === true + await sql`DELETE FROM otp_requests WHERE phone = ${phone}` + if (dropCustomer) { + await sql`DELETE FROM customers WHERE phone = ${phone}` + } + return { ok: true, phone, dropped_customer: dropCustomer } + }) +} diff --git a/backend/src/services/otp.service.js b/backend/src/services/otp.service.js index 7d76934..09ef08a 100644 --- a/backend/src/services/otp.service.js +++ b/backend/src/services/otp.service.js @@ -26,9 +26,17 @@ const generate6DigitCode = () => { return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0') } +// Dev-only in-memory cache of latest stub OTP per phone, read by the +// /internal/_test/peek-otp endpoint to make Maestro flows deterministic +// without baking test phone numbers into production code paths. +const stubOtpByPhone = new Map() + +export const peekStubOtp = (phone) => stubOtpByPhone.get(phone) ?? null + const fazpassSendStub = async ({ phone, channel }) => { const reference = `stub_${crypto.randomUUID()}` const code = generate6DigitCode() + stubOtpByPhone.set(phone, { code, reference, channel, generated_at: new Date().toISOString() }) // Log the code so developers can read it during dev testing. // eslint-disable-next-line no-console console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`) diff --git a/client_app/.maestro/flows/01_smoke.yaml b/client_app/.maestro/flows/01_smoke.yaml index 0d5c53f..8fbbc97 100644 --- a/client_app/.maestro/flows/01_smoke.yaml +++ b/client_app/.maestro/flows/01_smoke.yaml @@ -1,14 +1,99 @@ -# Smoke test: launch the app and assert the home screen renders. -# Use this flow first to verify Maestro can talk to your device/emulator at all. +# Smoke test: cold-start onboarding, registers a new customer via the +# anonymity-disabled force-register path, lands on home screen. +# +# Exercises (in order): onboarding carousel -> welcome -> display name -> +# force-register (because anonymity_enabled=false in dev) -> OTP via peek +# endpoint -> home. # # Run: # maestro test client_app/.maestro/flows/01_smoke.yaml # -# Pre-req: client_app debug APK installed on the connected device, signed in as a customer. -appId: ${APP_ID_ANDROID} # Maestro uses APP_ID_IOS automatically when running against an iOS device +# Pre-req: client_app debug APK installed, backend reachable at +# BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the +# /internal/_test/peek-otp + /internal/_test/reset-phone routes register). +appId: com.halobestie.client.client_app +env: + TEST_PHONE: "+628155556677" + BACKEND_INTERNAL_URL: http://localhost:3001 --- +# Wipe any prior state for TEST_PHONE so repeated runs don't trip cooldowns +# or hit IDENTITY_CONFLICT on a previously-claimed customer row. +- runScript: + file: ../scripts/reset_phone.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} - launchApp: - clearState: false # keep existing auth — set to true to test cold-start onboarding -- assertVisible: - text: "Mulai Curhat" - timeout: 10000 # 10s — give Riverpod time to hydrate the home screen + clearState: true +- extendedWaitUntil: + visible: + text: "Mulai" + timeout: 15000 # onboarding carousel auto-advances; "Mulai" appears on slide 3 +- tapOn: + text: "Mulai" + retryTapIfNoChange: true +- 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 +# Force-register kicks in (anonymity_enabled=false in dev DB) +- 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 +# Pull the stub-generated OTP code from the in-memory map on the backend +- runScript: + file: ../scripts/peek_otp.js + env: + TEST_PHONE: ${TEST_PHONE} + BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL} +# inputText fills the autofocused first box; Flutter's onChanged advances +# focus per char, so all 6 digits land in the right boxes and auto-submit. +- inputText: ${output.OTP} +# Post-OTP, force-register flow lands on /auth/set-name (anonymous display +# name doesn't carry to the upgraded row). Wait for OTP screen to fade, +# then re-fill display name and continue to home. +- extendedWaitUntil: + notVisible: + text: "Masukkan OTP" + timeout: 15000 +- extendedWaitUntil: + visible: + text: "Nama panggilan" + timeout: 10000 +- tapOn: + text: "Nama panggilan" +- inputText: "Maestro" +- hideKeyboard +- tapOn: + text: "Lanjut" + retryTapIfNoChange: true +- extendedWaitUntil: + visible: + text: "Mulai Curhat" + timeout: 20000 diff --git a/client_app/.maestro/scripts/peek_otp.js b/client_app/.maestro/scripts/peek_otp.js new file mode 100644 index 0000000..85ee989 --- /dev/null +++ b/client_app/.maestro/scripts/peek_otp.js @@ -0,0 +1,13 @@ +// Read the latest stub-generated OTP code for TEST_PHONE from the +// backend's dev-only /internal/_test/peek-otp endpoint. +// +// Writes the 6-digit code to output.OTP so the calling flow can use ${output.OTP}. +const phone = TEST_PHONE +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const encoded = encodeURIComponent(phone) +const resp = http.get(`${url}/internal/_test/peek-otp?phone=${encoded}`) +if (resp.status !== 200) { + throw new Error(`peek-otp failed (${resp.status}): ${resp.body}`) +} +const data = json(resp.body) +output.OTP = data.code diff --git a/client_app/.maestro/scripts/peek_otp.sh b/client_app/.maestro/scripts/peek_otp.sh new file mode 100755 index 0000000..2bff6bb --- /dev/null +++ b/client_app/.maestro/scripts/peek_otp.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Read the latest stub-generated OTP code for ${TEST_PHONE} from the +# backend's dev-only /internal/_test/peek-otp endpoint. +# +# Echoes the 6-digit code to stdout. Maestro captures the last line of +# stdout into the variable named by the calling runScript step. +set -euo pipefail + +phone="${TEST_PHONE:-}" +url="${BACKEND_INTERNAL_URL:-http://localhost:3001}" + +if [[ -z "$phone" ]]; then + echo "TEST_PHONE env var required" >&2 + exit 1 +fi + +# url-encode the leading + +encoded_phone="$(printf %s "$phone" | sed 's/+/%2B/')" +resp="$(curl -fsS "${url}/internal/_test/peek-otp?phone=${encoded_phone}")" +echo "$resp" | jq -r .code diff --git a/client_app/.maestro/scripts/reset_phone.js b/client_app/.maestro/scripts/reset_phone.js new file mode 100644 index 0000000..9252a16 --- /dev/null +++ b/client_app/.maestro/scripts/reset_phone.js @@ -0,0 +1,11 @@ +// Wipe otp_requests rows + customer row for TEST_PHONE so repeated runs +// don't trip the 60s cooldown or hit IDENTITY_CONFLICT. +const phone = TEST_PHONE +const url = BACKEND_INTERNAL_URL || 'http://localhost:3001' +const resp = http.post(`${url}/internal/_test/reset-phone`, { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, drop_customer: true }), +}) +if (resp.status !== 200) { + throw new Error(`reset-phone failed (${resp.status}): ${resp.body}`) +} diff --git a/client_app/.maestro/scripts/reset_phone.sh b/client_app/.maestro/scripts/reset_phone.sh new file mode 100755 index 0000000..eb0edf6 --- /dev/null +++ b/client_app/.maestro/scripts/reset_phone.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Wipe otp_requests rows + (optionally) customer row for ${TEST_PHONE} so +# repeated test runs don't trip the 60s cooldown or hit IDENTITY_CONFLICT. +# +# Runs against backend's dev-only /internal/_test/reset-phone endpoint. +set -euo pipefail + +phone="${TEST_PHONE:-}" +url="${BACKEND_INTERNAL_URL:-http://localhost:3001}" +drop_customer="${DROP_CUSTOMER:-true}" + +if [[ -z "$phone" ]]; then + echo "TEST_PHONE env var required" >&2 + exit 1 +fi + +curl -fsS -X POST "${url}/internal/_test/reset-phone" \ + -H "Content-Type: application/json" \ + -d "{\"phone\":\"${phone}\",\"drop_customer\":${drop_customer}}" >/dev/null +echo "reset complete: ${phone}"