Files
halobestie-clone/backend/test/services/payment-catalog.service.test.js
Ramadhan Sjamsani 2c95fd040d Phase 5.x payment revamp + Xendit Stage-8 prep
- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
  1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
  and fetches via flutter_cache_manager. payment_methods.icon is now a
  CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
  JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
  out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
  (422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
  (BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
  shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
  Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
  index on group name). Operator CC edits never clobbered across re-runs.
  One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
  brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
  URL scheme registered on Android (intent-filter w/ BROWSABLE on
  MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
  owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
  CC pages restyled with new theme tokens (separate work, bundled here).

169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:33:51 +08:00

258 lines
9.6 KiB
JavaScript

/**
* Unit tests for payment-catalog.service.js.
*
* Covers:
* - DB → app-facing shape transformation (grouping, ordering)
* - Active-only filter (inactive group OR method excluded; empty groups dropped)
* - L1 (in-process) cache hit
* - invalidatePaymentCatalog clears the cache
* - findActiveMethodByCode is casing-tolerant; returns null for missing/inactive
*
* We deliberately don't unit-test the Valkey layer here — that's an integration
* concern and the real Valkey is wired up in setup.js. The in-process cache is
* sufficient to assert that mutators correctly invalidate.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { db } from '../helpers/db.js'
const {
getCatalogForApp,
invalidatePaymentCatalog,
findActiveMethodByCode,
createGroup,
updateGroup,
createMethod,
updateMethod,
} = await import('../../src/services/payment-catalog.service.js')
const sql = db()
const wipeCatalog = async () => {
await sql`DELETE FROM payment_methods`
await sql`DELETE FROM payment_method_groups`
// Drop any cached state from the previous test.
await invalidatePaymentCatalog()
}
const insertGroup = async ({ name, order = 0, active = true }) => {
const [row] = await sql`
INSERT INTO payment_method_groups (name, display_order, is_active)
VALUES (${name}, ${order}, ${active})
RETURNING id
`
return row.id
}
const insertMethod = async ({
groupId, code, display = null, order = 0, icon = null,
minAmount = null, maxAmount = null, active = true,
}) => {
const [row] = await sql`
INSERT INTO payment_methods (
group_id, display_name, payment_code, display_order, icon,
min_amount, max_amount, is_active
)
VALUES (
${groupId}, ${display ?? code}, ${code}, ${order}, ${icon},
${minAmount}, ${maxAmount}, ${active}
)
RETURNING id
`
return row.id
}
describe('payment-catalog.service', () => {
beforeEach(async () => {
await wipeCatalog()
})
describe('getCatalogForApp', () => {
it('returns { groups: [] } on an empty catalog', async () => {
const out = await getCatalogForApp()
expect(out).toEqual({ groups: [] })
})
it('groups methods under their group and orders both correctly', async () => {
const gWallet = await insertGroup({ name: 'E-Wallet', order: 1 })
const gFast = await insertGroup({ name: 'Paling Cepat', order: 0 })
await insertMethod({ groupId: gWallet, code: 'DANA', order: 1 })
await insertMethod({ groupId: gWallet, code: 'OVO', order: 0 })
await insertMethod({ groupId: gFast, code: 'QRIS', order: 0 })
await invalidatePaymentCatalog() // ensure no stale L1 from previous reads
const { groups } = await getCatalogForApp()
expect(groups.map((g) => g.name)).toEqual(['Paling Cepat', 'E-Wallet'])
expect(groups[0].methods.map((m) => m.payment_code)).toEqual(['QRIS'])
expect(groups[1].methods.map((m) => m.payment_code)).toEqual(['OVO', 'DANA'])
})
it('drops inactive methods', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO', active: true })
await insertMethod({ groupId: g, code: 'GOPAY', active: false })
await invalidatePaymentCatalog()
const { groups } = await getCatalogForApp()
expect(groups).toHaveLength(1)
expect(groups[0].methods.map((m) => m.payment_code)).toEqual(['OVO'])
})
it('drops inactive groups (and their methods, even if active)', async () => {
const gActive = await insertGroup({ name: 'E-Wallet', order: 0, active: true })
const gHidden = await insertGroup({ name: 'Hidden', order: 1, active: false })
await insertMethod({ groupId: gActive, code: 'OVO' })
await insertMethod({ groupId: gHidden, code: 'X1' })
await invalidatePaymentCatalog()
const { groups } = await getCatalogForApp()
expect(groups.map((g) => g.name)).toEqual(['E-Wallet'])
})
it('drops groups with no active methods', async () => {
const gEmpty = await insertGroup({ name: 'Empty', order: 0 })
const gFull = await insertGroup({ name: 'E-Wallet', order: 1 })
await insertMethod({ groupId: gEmpty, code: 'GHOST', active: false })
await insertMethod({ groupId: gFull, code: 'OVO' })
await invalidatePaymentCatalog()
const { groups } = await getCatalogForApp()
expect(groups.map((g) => g.name)).toEqual(['E-Wallet'])
})
it('parses icon as comma-separated slug list into icon_urls (relative /assets paths)', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO', icon: 'ovo-new', order: 0 })
await insertMethod({ groupId: g, code: 'CARDS', icon: 'visa,mastercard,jcb', order: 1 })
await insertMethod({ groupId: g, code: 'NOICON', icon: null, order: 2 })
await invalidatePaymentCatalog()
const { groups } = await getCatalogForApp()
const methods = groups[0].methods
expect(methods[0].icon_urls).toEqual(['/assets/payment-icons/ovo-new.svg'])
expect(methods[1].icon_urls).toEqual([
'/assets/payment-icons/visa.svg',
'/assets/payment-icons/mastercard.svg',
'/assets/payment-icons/jcb.svg',
])
expect(methods[2].icon_urls).toEqual([])
})
it('emits min_amount + max_amount per method (null when not set)', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO', minAmount: 10000, maxAmount: 10000000, order: 0 })
await insertMethod({ groupId: g, code: 'QRIS', minAmount: 1, maxAmount: null, order: 1 })
await insertMethod({ groupId: g, code: 'BCA_VA', minAmount: null, maxAmount: null, order: 2 })
await invalidatePaymentCatalog()
const { groups } = await getCatalogForApp()
const methods = groups[0].methods
expect(methods[0].min_amount).toBe(10000)
expect(methods[0].max_amount).toBe(10000000)
expect(methods[1].min_amount).toBe(1)
expect(methods[1].max_amount).toBeNull()
expect(methods[2].min_amount).toBeNull()
expect(methods[2].max_amount).toBeNull()
})
it('caches in-process (second call returns the same object reference)', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO' })
await invalidatePaymentCatalog()
const a = await getCatalogForApp()
const b = await getCatalogForApp()
expect(b).toBe(a) // same object identity = served from L1
})
it('a mutator invalidates the cache', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO' })
await invalidatePaymentCatalog()
const first = await getCatalogForApp()
expect(first.groups[0].methods).toHaveLength(1)
// Add a new method via the service mutator.
await createMethod({ groupId: g, displayName: 'DANA', paymentCode: 'DANA' })
const second = await getCatalogForApp()
expect(second).not.toBe(first) // L1 was cleared, fresh read
expect(second.groups[0].methods).toHaveLength(2)
})
})
describe('findActiveMethodByCode', () => {
beforeEach(async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO' })
await insertMethod({ groupId: g, code: 'GOPAY', active: false })
await invalidatePaymentCatalog()
})
it('matches by exact code', async () => {
const m = await findActiveMethodByCode('OVO')
expect(m?.payment_code).toBe('OVO')
})
it('is casing-tolerant (lower-case incoming)', async () => {
const m = await findActiveMethodByCode('ovo')
expect(m?.payment_code).toBe('OVO')
})
it('returns null for an inactive code', async () => {
const m = await findActiveMethodByCode('GOPAY')
expect(m).toBeNull()
})
it('returns null for an unknown code', async () => {
const m = await findActiveMethodByCode('UNKNOWN_CODE')
expect(m).toBeNull()
})
it('returns null for empty / undefined input', async () => {
expect(await findActiveMethodByCode('')).toBeNull()
expect(await findActiveMethodByCode(undefined)).toBeNull()
expect(await findActiveMethodByCode(null)).toBeNull()
})
})
describe('mutator side effects', () => {
it('createGroup persists + uppercases nothing (group names are free-form)', async () => {
const row = await createGroup({ name: 'Cards', displayOrder: 5 })
expect(row.name).toBe('Cards')
expect(row.display_order).toBe(5)
expect(row.is_active).toBe(true)
})
it('createMethod uppercases payment_code', async () => {
const g = await createGroup({ name: 'Cards' })
const m = await createMethod({
groupId: g.id,
displayName: 'Visa',
paymentCode: 'visa', // lowercase incoming
})
expect(m.payment_code).toBe('VISA')
})
it('updateMethod also uppercases payment_code when patched', async () => {
const g = await createGroup({ name: 'Cards' })
const m = await createMethod({ groupId: g.id, displayName: 'Visa', paymentCode: 'VISA' })
const updated = await updateMethod(m.id, { paymentCode: 'mastercard' })
expect(updated.payment_code).toBe('MASTERCARD')
})
it('updateGroup applies COALESCE patches (omitted fields preserved)', async () => {
const g = await createGroup({ name: 'Cards', displayOrder: 1 })
const out = await updateGroup(g.id, { name: 'Credit Cards' })
expect(out.name).toBe('Credit Cards')
expect(out.display_order).toBe(1) // unchanged
})
})
})