/** * 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, active = true }) => { const [row] = await sql` INSERT INTO payment_methods (group_id, display_name, payment_code, display_order, icon, is_active) VALUES (${groupId}, ${display ?? code}, ${code}, ${order}, ${icon}, ${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('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 }) }) })