OTP overhaul: test-user bypass + hash-at-rest + Fazpass integration

- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
  managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
  toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
  + DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
  bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
  sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
  fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
  OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
  pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
  pricing.service.js.

208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 22:39:34 +08:00
parent 3a0cdf5c4e
commit 6fd98ca99c
15 changed files with 1958 additions and 158 deletions

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
// Imported lazily after env stubs so the config getter reads the test values.
let fazpassRequestOtp
let fazpassVerifyOtp
let FazpassError
const setFazpassEnv = () => {
vi.stubEnv('FAZPASS_ENABLED', 'true')
vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example')
vi.stubEnv('FAZPASS_MERCHANT_KEY', 'test-merchant-key')
vi.stubEnv('FAZPASS_GATEWAY_KEY', 'test-gateway-key')
vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000')
}
beforeEach(async () => {
setFazpassEnv()
// Re-import so the module's top-level closures use the stubbed env.
// getFazpassConfig reads process.env at call time so this is mostly a safety
// belt — but it also ensures the test isn't depending on import order.
const mod = await import('../../src/services/fazpass.service.js')
fazpassRequestOtp = mod.fazpassRequestOtp
fazpassVerifyOtp = mod.fazpassVerifyOtp
FazpassError = mod.FazpassError
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl)
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
describe('fazpassRequestOtp', () => {
it('POSTs phone + gateway_key with Bearer auth and returns reference', async () => {
let captured
mockFetch(async (url, init) => {
captured = { url, init }
return jsonResponse(200, {
status: true,
message: 'Request generated successfully',
code: '2000200',
data: {
id: 'abc-123', otp: 'XXXXXX', otp_length: 6,
channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'testing',
},
})
})
const result = await fazpassRequestOtp({ phone: '+628111' })
expect(result).toEqual({
reference: 'abc-123',
channel_used: 'WA_GENERIC_OTP',
provider: 'Fazpass',
})
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/request')
expect(captured.init.method).toBe('POST')
expect(captured.init.headers.Authorization).toBe('Bearer test-merchant-key')
expect(captured.init.headers['Content-Type']).toBe('application/json')
expect(JSON.parse(captured.init.body)).toEqual({
phone: '+628111',
gateway_key: 'test-gateway-key',
})
})
it('throws FazpassError on non-2xx with provider code surfaced', async () => {
mockFetch(async () => jsonResponse(400, { status: false, code: '4000400', message: 'bad gateway_key' }))
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({
code: 'OTP_PROVIDER_FAILED',
statusCode: 502,
httpStatus: 400,
providerCode: '4000400',
providerMessage: 'bad gateway_key',
})
})
it('throws FazpassError when 2xx body has status:false', async () => {
mockFetch(async () => jsonResponse(200, { status: false, code: '5000500', message: 'gateway down' }))
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', providerCode: '5000500' })
})
it('throws FazpassError when 2xx body is missing data.id', async () => {
mockFetch(async () => jsonResponse(200, { status: true, data: { otp: 'XXXXXX' } }))
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toBeInstanceOf(FazpassError)
})
it('throws FazpassError on transport / timeout error', async () => {
mockFetch(async () => { throw new Error('network down') })
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', httpStatus: null })
})
it('throws when FAZPASS_ENABLED is false', async () => {
vi.stubEnv('FAZPASS_ENABLED', 'false')
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
})
it('throws when merchantKey or gatewayKey are blank', async () => {
vi.stubEnv('FAZPASS_MERCHANT_KEY', '')
await expect(fazpassRequestOtp({ phone: '+628111' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
})
})
describe('fazpassVerifyOtp', () => {
it('POSTs otp_id + otp with Bearer auth and returns valid:true on status:true', async () => {
let captured
mockFetch(async (url, init) => {
captured = { url, init }
return jsonResponse(200, {
status: true, message: 'Validate otp successfully', code: '2000200',
})
})
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '424242' })
expect(result).toEqual({
valid: true,
providerCode: '2000200',
providerMessage: 'Validate otp successfully',
})
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/verify')
expect(JSON.parse(captured.init.body)).toEqual({
otp_id: 'abc-123', otp: '424242',
})
})
it('returns valid:false on 2xx + status:false (the "wrong OTP" path)', async () => {
mockFetch(async () => jsonResponse(200, {
status: false, message: 'Invalid OTP', code: '4000401',
}))
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '000000' })
expect(result.valid).toBe(false)
expect(result.providerCode).toBe('4000401')
})
it('throws FazpassError on non-2xx (provider outage)', async () => {
mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }))
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
.rejects.toMatchObject({
code: 'OTP_PROVIDER_FAILED',
httpStatus: 503,
providerCode: '5030503',
})
})
it('throws FazpassError on malformed body (no status field)', async () => {
mockFetch(async () => new Response('not json', { status: 200, headers: { 'Content-Type': 'text/plain' } }))
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
.rejects.toBeInstanceOf(FazpassError)
})
it('throws FazpassError on network error', async () => {
mockFetch(async () => { throw new Error('connection reset') })
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
})
})

View File

@@ -0,0 +1,436 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import bcrypt from 'bcrypt'
const { requestOtp, verifyOtp, OtpError } = await import('../../src/services/otp.service.js')
const {
addTestOtpBypassEntry,
setTestOtpBypassEnabled,
getTestOtpBypass,
} = await import('../../src/services/config.service.js')
const { db, resetDb } = await import('../helpers/db.js')
// Unique phone per test so rate-limit (3 per hour per phone) doesn't poison
// tests that reuse otp_requests rows. resetDb() truncates otp_requests but
// keeps the rate-limit guarantee tight regardless.
const uniquePhone = () => {
const digits = randomUUID().replace(/[^0-9]/g, '').slice(0, 10).padEnd(10, '0')
return `+628${digits}`
}
const clearBypassConfig = async () => {
const sql = db()
await sql`DELETE FROM app_config WHERE key = 'test_otp_bypass'`
}
const peekOtpRow = async (id) => {
const sql = db()
const [row] = await sql`
SELECT id, phone, fazpass_reference, is_bypass, code_hash, used_at, expires_at
FROM otp_requests WHERE id = ${id}
`
return row
}
describe('otp.service — hash-at-rest (stub mode)', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
it('stores bcrypt(code_hash) instead of plaintext after requestOtp', async () => {
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.1',
})
const row = await peekOtpRow(otp_request_id)
expect(row).toBeDefined()
expect(row.is_bypass).toBe(false)
expect(row.code_hash).toMatch(/^\$2[aby]\$/) // bcrypt signature
// fazpass_reference holds ONLY the stub reference now — no ":code" suffix.
expect(row.fazpass_reference).toMatch(/^stub_/)
expect(row.fazpass_reference).not.toContain(':')
})
it('verifyOtp succeeds against the same plaintext code (via stub peek)', async () => {
const phone = uniquePhone()
// Pin the stub to a known code so we don't depend on the in-memory Map.
vi.stubEnv('OTP_STATIC_CODE', '424242')
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.1',
})
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' })
expect(result).toEqual({ phone, user_type: 'customer' })
const used = await peekOtpRow(otp_request_id)
expect(used.used_at).not.toBeNull()
vi.unstubAllEnvs()
})
it('verifyOtp rejects a wrong code with CODE_MISMATCH', async () => {
const phone = uniquePhone()
vi.stubEnv('OTP_STATIC_CODE', '111111')
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.1',
})
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '999999' }))
.rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 })
vi.unstubAllEnvs()
})
})
describe('otp.service — DB-level CHECK constraint', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
it('rejects an insert with is_bypass=true and code_hash NULL', async () => {
const sql = db()
await expect(sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash)
VALUES ('+628999999991', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', true, NULL)
`).rejects.toMatchObject({ code: '23514' }) // PG check_violation
})
it('rejects an insert with is_bypass=true and fazpass_reference set', async () => {
const sql = db()
await expect(sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999992', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
true, '$2b$10$abcdefghijklmnopqrstuv', 'leak_ref')
`).rejects.toMatchObject({ code: '23514' })
})
it('allows is_bypass=false with code_hash NULL (Fazpass-live shape) at insert time', async () => {
const sql = db()
const [row] = await sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999993', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
false, NULL, 'fazpass_ref_xyz')
RETURNING id
`
expect(row.id).toBeDefined()
})
})
describe('otp.service — verify anomaly refusal', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
it('rejects verify on a row missing BOTH code_hash and fazpass_reference (unverifiable)', async () => {
const sql = db()
const [row] = await sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999994', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
false, NULL, NULL)
RETURNING id
`
await expect(verifyOtp({ otpRequestId: row.id, code: '123456' }))
.rejects.toMatchObject({ code: 'OTP_CORRUPT', statusCode: 500 })
})
it('returns OTP_PROVIDER_FAILED when row has fazpass_reference but Fazpass is disabled', async () => {
const sql = db()
const [row] = await sql`
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
is_bypass, code_hash, fazpass_reference)
VALUES ('+628999999998', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
false, NULL, 'fazpass_ref_xyz')
RETURNING id
`
// FAZPASS_ENABLED is unset/false in tests; fazpassVerifyOtp throws
// FazpassError, which otp.service.js converts to OTP_PROVIDER_FAILED 502.
await expect(verifyOtp({ otpRequestId: row.id, code: '123456' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
})
})
describe('otp.service — test OTP bypass allowlist', () => {
beforeEach(async () => {
await resetDb()
await clearBypassConfig()
})
afterEach(async () => {
await clearBypassConfig()
})
it('plants a bypass row that verifies against the configured static OTP', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '294857', user_type: 'customer', label: 'Apple Reviewer #1',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.2',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(true)
expect(row.fazpass_reference).toBeNull()
expect(row.code_hash).toMatch(/^\$2[aby]\$/)
// Verify against the configured static OTP succeeds.
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '294857' })
expect(result).toEqual({ phone, user_type: 'customer' })
})
it('does not match when user_type differs (same phone for customer + mitra is distinct)', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '111111', user_type: 'mitra', label: 'Internal QA Mitra',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
// Customer request to the same phone → falls through to stub.
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.3',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
expect(row.fazpass_reference).toMatch(/^stub_/)
})
it('does not match when the entry has expired', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
// addTestOtpBypassEntry refuses past dates, so set a valid future date,
// then manually backdate the entry via SQL — emulating "this entry has
// been sitting in the list for too long".
await addTestOtpBypassEntry({
phone, otp: '222222', user_type: 'customer', label: 'Old Reviewer',
expires_at: new Date(Date.now() + 60_000).toISOString(),
})
const sql = db()
await sql`
UPDATE app_config
SET value = jsonb_set(
value,
'{entries,0,expires_at}',
to_jsonb(${new Date(Date.now() - 60_000).toISOString()}::text)
)
WHERE key = 'test_otp_bypass'
`
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.4',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
})
it('does not match when the global kill switch is off', async () => {
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '333333', user_type: 'customer', label: 'Disabled later',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
// Flip the kill switch off — entries remain but no longer match.
await setTestOtpBypassEnabled(false)
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.5',
})
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
})
it('rejects an entry whose plaintext OTP is malformed', async () => {
await expect(addTestOtpBypassEntry({
phone: '+628999999995', otp: 'abc', user_type: 'customer', label: 'Bad OTP',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
it('rejects an entry whose expires_at is in the past', async () => {
await expect(addTestOtpBypassEntry({
phone: '+628999999996', otp: '123456', user_type: 'customer', label: 'Stale',
expires_at: new Date(Date.now() - 60_000).toISOString(),
})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
it('rejects a duplicate (phone, user_type) entry', async () => {
const phone = uniquePhone()
const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
await addTestOtpBypassEntry({
phone, otp: '101010', user_type: 'customer', label: 'First',
expires_at: future,
})
await expect(addTestOtpBypassEntry({
phone, otp: '202020', user_type: 'customer', label: 'Second',
expires_at: future,
})).rejects.toMatchObject({ code: 'DUPLICATE_ENTRY' })
})
// No new tests in this describe — see "Fazpass-live mode" below for the
// request/verify integration coverage.
it('getTestOtpBypass returns the bcrypt hash, not the plaintext OTP', async () => {
const phone = uniquePhone()
await addTestOtpBypassEntry({
phone, otp: '424242', user_type: 'customer', label: 'Apple #1',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const list = await getTestOtpBypass()
expect(list.entries).toHaveLength(1)
const entry = list.entries[0]
expect(entry.otp_hash).toMatch(/^\$2[aby]\$/)
// Defense-in-depth: serialised object must not contain the plaintext anywhere.
expect(JSON.stringify(entry)).not.toContain('424242')
// And the hash actually matches the plaintext (so verify works downstream).
expect(await bcrypt.compare('424242', entry.otp_hash)).toBe(true)
})
})
describe('otp.service — Fazpass-live mode (FAZPASS_ENABLED=true)', () => {
const setFazpassEnv = () => {
vi.stubEnv('FAZPASS_ENABLED', 'true')
vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example')
vi.stubEnv('FAZPASS_MERCHANT_KEY', 'mkey')
vi.stubEnv('FAZPASS_GATEWAY_KEY', 'gkey')
vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000')
}
const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl)
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
status, headers: { 'Content-Type': 'application/json' },
})
beforeEach(async () => {
setFazpassEnv()
await resetDb()
await clearBypassConfig()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
it('requestOtp stores fazpass_reference + leaves code_hash NULL when Fazpass returns success', async () => {
mockFetch(async () => jsonResponse(200, {
status: true,
data: {
id: 'fzp-ref-001', otp: 'XXXXXX', otp_length: 6,
channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'production',
},
}))
const phone = uniquePhone()
const { otp_request_id, channel_used } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.10', channel: 'whatsapp',
})
expect(channel_used).toBe('whatsapp') // API contract: echoes client-requested channel
const row = await peekOtpRow(otp_request_id)
expect(row.is_bypass).toBe(false)
expect(row.fazpass_reference).toBe('fzp-ref-001')
expect(row.code_hash).toBeNull()
})
it('requestOtp propagates Fazpass error and does NOT insert a row', async () => {
mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }))
const phone = uniquePhone()
await expect(requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.11', channel: 'whatsapp',
})).rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
const sql = db()
const [{ n }] = await sql`SELECT COUNT(*)::int AS n FROM otp_requests WHERE phone = ${phone}`
expect(n).toBe(0)
})
it('verifyOtp delegates to Fazpass and succeeds on status:true', async () => {
// Sequence: 1st fetch = /request, 2nd fetch = /verify.
let call = 0
mockFetch(async (url) => {
call++
if (url.endsWith('/v1/otp/request')) {
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-002' } })
}
if (url.endsWith('/v1/otp/verify')) {
return jsonResponse(200, { status: true, code: '2000200' })
}
throw new Error(`unexpected url ${url}`)
})
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.12', channel: 'whatsapp',
})
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' })
expect(result).toEqual({ phone, user_type: 'customer' })
expect(call).toBe(2)
const used = await peekOtpRow(otp_request_id)
expect(used.used_at).not.toBeNull()
})
it('verifyOtp surfaces wrong OTP as CODE_MISMATCH when Fazpass returns status:false', async () => {
mockFetch(async (url) => {
if (url.endsWith('/v1/otp/request')) {
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-003' } })
}
return jsonResponse(200, { status: false, code: '4000401', message: 'Invalid OTP' })
})
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.13', channel: 'whatsapp',
})
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '000000' }))
.rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 })
// Row stays unused — attempts incremented but not marked.
const row = await peekOtpRow(otp_request_id)
expect(row.used_at).toBeNull()
})
it('verifyOtp returns OTP_PROVIDER_FAILED 502 on Fazpass outage (distinct from wrong code)', async () => {
mockFetch(async (url) => {
if (url.endsWith('/v1/otp/request')) {
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-004' } })
}
return jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' })
})
const phone = uniquePhone()
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.14', channel: 'whatsapp',
})
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '424242' }))
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
})
it('test-OTP bypass still works even when FAZPASS_ENABLED=true (skips Fazpass entirely)', async () => {
const fetchSpy = mockFetch(async () => {
throw new Error('Fazpass MUST NOT be called for bypass rows')
})
const phone = uniquePhone()
await setTestOtpBypassEnabled(true)
await addTestOtpBypassEntry({
phone, otp: '999000', user_type: 'customer', label: 'Apple #1',
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
const { otp_request_id } = await requestOtp({
phone, userType: 'customer', ipAddress: '10.0.0.15', channel: 'whatsapp',
})
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '999000' })
expect(result).toEqual({ phone, user_type: 'customer' })
expect(fetchSpy).not.toHaveBeenCalled()
})
})