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) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { internalConfigRoutes } from './routes/internal/config.routes.js'
|
|||||||
import { sessionManagementRoutes } from './routes/internal/session.routes.js'
|
import { sessionManagementRoutes } from './routes/internal/session.routes.js'
|
||||||
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
||||||
import { failedPairingsRoutes } from './routes/internal/failed-pairings.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'
|
import { errorHandler } from './plugins/error-handler.js'
|
||||||
|
|
||||||
export const buildInternalApp = async () => {
|
export const buildInternalApp = async () => {
|
||||||
@@ -38,5 +39,10 @@ export const buildInternalApp = async () => {
|
|||||||
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
||||||
app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' })
|
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
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/src/routes/internal/_test.routes.js
Normal file
41
backend/src/routes/internal/_test.routes.js
Normal file
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -26,9 +26,17 @@ const generate6DigitCode = () => {
|
|||||||
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0')
|
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 fazpassSendStub = async ({ phone, channel }) => {
|
||||||
const reference = `stub_${crypto.randomUUID()}`
|
const reference = `stub_${crypto.randomUUID()}`
|
||||||
const code = generate6DigitCode()
|
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.
|
// Log the code so developers can read it during dev testing.
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
||||||
|
|||||||
@@ -1,14 +1,99 @@
|
|||||||
# Smoke test: launch the app and assert the home screen renders.
|
# Smoke test: cold-start onboarding, registers a new customer via the
|
||||||
# Use this flow first to verify Maestro can talk to your device/emulator at all.
|
# 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:
|
# Run:
|
||||||
# maestro test client_app/.maestro/flows/01_smoke.yaml
|
# 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.
|
# Pre-req: client_app debug APK installed, backend reachable at
|
||||||
appId: ${APP_ID_ANDROID} # Maestro uses APP_ID_IOS automatically when running against an iOS device
|
# 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:
|
- launchApp:
|
||||||
clearState: false # keep existing auth — set to true to test cold-start onboarding
|
clearState: true
|
||||||
- assertVisible:
|
- extendedWaitUntil:
|
||||||
text: "Mulai Curhat"
|
visible:
|
||||||
timeout: 10000 # 10s — give Riverpod time to hydrate the home screen
|
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
|
||||||
|
|||||||
13
client_app/.maestro/scripts/peek_otp.js
Normal file
13
client_app/.maestro/scripts/peek_otp.js
Normal file
@@ -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
|
||||||
20
client_app/.maestro/scripts/peek_otp.sh
Executable file
20
client_app/.maestro/scripts/peek_otp.sh
Executable file
@@ -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
|
||||||
11
client_app/.maestro/scripts/reset_phone.js
Normal file
11
client_app/.maestro/scripts/reset_phone.js
Normal file
@@ -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}`)
|
||||||
|
}
|
||||||
20
client_app/.maestro/scripts/reset_phone.sh
Executable file
20
client_app/.maestro/scripts/reset_phone.sh
Executable file
@@ -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}"
|
||||||
Reference in New Issue
Block a user