edtech / apps /api /test /payments.test.ts
CognxSafeTrack
feat(billing): complete billing system, push notifications, and tech debt fixes
8280d7d
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
});
});