File size: 6,823 Bytes
8280d7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | 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<typeof vi.fn>).mockResolvedValue(paymentData);
(prisma.payment.update as ReturnType<typeof vi.fn>).mockResolvedValue({ ...paymentData, status: 'COMPLETED' });
(prisma.enrollment.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(prisma.enrollment.create as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(paymentData);
(prisma.enrollment.findFirst as ReturnType<typeof vi.fn>).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
});
});
|