import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock infrastructure vi.mock('../src/services/prisma', () => ({ prisma: { payment: { findFirst: vi.fn(), update: vi.fn(), create: vi.fn() }, enrollment: { create: vi.fn(), findFirst: vi.fn() }, organization: { findUnique: vi.fn() }, }, })); vi.mock('../src/services/queue', () => ({ scheduleEmail: vi.fn(), whatsappQueue: { add: vi.fn() }, notificationQueue: { add: vi.fn() }, })); import { prisma } from '../src/services/prisma'; // ─── Webhook payload normalization ──────────────────────────────────────────── describe('Payment webhook — payload parsing', () => { beforeEach(() => vi.clearAllMocks()); it('Wave: extracts sessionId from client_reference', () => { const payload = { id: 'sess_123', status: 'complete', client_reference: 'sess_123' }; const sessionId = payload.client_reference ?? payload.id; const isSuccess = payload.status === 'complete'; expect(sessionId).toBe('sess_123'); expect(isSuccess).toBe(true); }); it('Wave: marks failed payment as FAILED', () => { const payload = { id: 'sess_456', status: 'failed', client_reference: 'sess_456' }; const isSuccess = payload.status === 'complete'; expect(isSuccess).toBe(false); }); it('Orange Money: extracts sessionId from reference', () => { const payload = { status: 'SUCCESS', txnid: 'om_789', reference: 'om_789' }; const sessionId = payload.reference ?? payload.txnid; const isSuccess = payload.status === 'SUCCESS'; expect(sessionId).toBe('om_789'); expect(isSuccess).toBe(true); }); it('Orange Money: marks FAILED status as not success', () => { const payload = { status: 'FAILED', txnid: 'om_999', reference: 'om_999' }; const isSuccess = payload.status === 'SUCCESS'; expect(isSuccess).toBe(false); }); }); // ─── Payment state transitions ──────────────────────────────────────────────── describe('Payment — state machine', () => { it('PENDING → COMPLETED on successful webhook', () => { const payment = { id: 'pay_1', status: 'PENDING', userId: 'user_1', trackId: 'track_1' }; const updated = { ...payment, status: 'COMPLETED' }; expect(updated.status).toBe('COMPLETED'); }); it('PENDING → FAILED on failed webhook', () => { const payment = { id: 'pay_2', status: 'PENDING' }; const updated = { ...payment, status: 'FAILED' }; expect(updated.status).toBe('FAILED'); }); it('does not re-process already COMPLETED payment', () => { const payment = { status: 'COMPLETED' }; // Guard: if payment is already COMPLETED, skip const shouldProcess = payment.status === 'PENDING'; expect(shouldProcess).toBe(false); }); }); // ─── Enrollment creation logic ──────────────────────────────────────────────── describe('Payment — auto enrollment on success', () => { beforeEach(() => vi.clearAllMocks()); it('creates enrollment when payment succeeds and userId + trackId exist', async () => { const paymentData = { id: 'pay_1', status: 'PENDING', userId: 'user_1', trackId: 'track_1', organizationId: 'org_1', }; (prisma.payment.findFirst as ReturnType).mockResolvedValue(paymentData); (prisma.payment.update as ReturnType).mockResolvedValue({ ...paymentData, status: 'COMPLETED' }); (prisma.enrollment.findFirst as ReturnType).mockResolvedValue(null); (prisma.enrollment.create as ReturnType).mockResolvedValue({ id: 'enroll_1' }); // Simulate webhook handler logic const payment = await prisma.payment.findFirst({ where: { paymentSessionId: 'sess_1' } as any }); expect(payment).not.toBeNull(); await prisma.payment.update({ where: { id: payment!.id }, data: { status: 'COMPLETED' } as any }); const existingEnrollment = await prisma.enrollment.findFirst({ where: { userId: payment!.userId } as any }); if (!existingEnrollment && payment!.userId && payment!.trackId) { await prisma.enrollment.create({ data: { userId: payment!.userId } as any }); } expect(prisma.enrollment.create).toHaveBeenCalledTimes(1); }); it('skips enrollment creation when userId is missing', async () => { const paymentData = { id: 'pay_2', status: 'PENDING', userId: null, trackId: 'track_1' }; (prisma.payment.findFirst as ReturnType).mockResolvedValue(paymentData); // Simulate: no userId → no enrollment const payment = await prisma.payment.findFirst({ where: {} as any }); const shouldEnroll = !!payment?.userId && !!payment?.trackId; expect(shouldEnroll).toBe(false); expect(prisma.enrollment.create).not.toHaveBeenCalled(); }); it('skips enrollment if one already exists', async () => { const paymentData = { id: 'pay_3', status: 'PENDING', userId: 'user_2', trackId: 'track_1' }; (prisma.payment.findFirst as ReturnType).mockResolvedValue(paymentData); (prisma.enrollment.findFirst as ReturnType).mockResolvedValue({ id: 'existing_enroll' }); const payment = await prisma.payment.findFirst({ where: {} as any }); const existingEnrollment = await prisma.enrollment.findFirst({ where: {} as any }); const shouldCreate = !existingEnrollment; expect(shouldCreate).toBe(false); expect(prisma.enrollment.create).not.toHaveBeenCalled(); }); }); // ─── Provider amount validation ─────────────────────────────────────────────── describe('Payment — provider validation', () => { it('rejects amount below minimum (1 FCFA)', () => { const amount = 0; const isValid = amount > 0; expect(isValid).toBe(false); }); it('accepts valid FCFA amount', () => { const amount = 20000; const isValid = amount > 0; expect(isValid).toBe(true); }); it('validates supported providers', () => { const SUPPORTED = ['WAVE', 'ORANGE_MONEY']; expect(SUPPORTED.includes('WAVE')).toBe(true); expect(SUPPORTED.includes('STRIPE')).toBe(false); // deprecated }); });