Files
edr-platform/apps/edr-passenger-api/src/modules/payments/payments.service.spec.ts

329 lines
10 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing';
import { PaymentsService } from './payments.service';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TelebirrProvider } from './providers/telebirr.provider';
import { CbeBirrProvider } from './providers/cbe-birr.provider';
import { EBirrProvider } from './providers/ebirr.provider';
import { CardProvider } from './providers/card.provider';
import { PaymentIntentStatus, PaymentMethodType } from '@prisma/client';
import { BadRequestException, NotFoundException } from '@nestjs/common';
describe('PaymentsService', () => {
let service: PaymentsService;
let prisma: PrismaService;
let seatsService: SeatsService;
let ticketsService: TicketsService;
let eventEmitter: EventEmitter2;
const mockPrisma: Record<string, any> = {
booking: {
findUnique: jest.fn(),
update: jest.fn(),
},
paymentIntent: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
walletAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
walletLedgerEntry: {
create: jest.fn(),
},
loyaltyAccount: {
findUnique: jest.fn(),
update: jest.fn(),
},
loyaltyLedgerEntry: {
create: jest.fn(),
},
$transaction: jest.fn((callback: (tx: any) => any) => callback(mockPrisma)),
};
const mockSeatsService = {
confirmSeats: jest.fn(),
releaseSeats: jest.fn(),
};
const mockTicketsService = {
generate: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const mockTelebirrProvider = {
method: PaymentMethodType.TELEBIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockCbeBirrProvider = {
method: PaymentMethodType.CBE_BIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockEBirrProvider = {
method: PaymentMethodType.EBIRR,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
const mockCardProvider = {
method: PaymentMethodType.CARD,
initiate: jest.fn(),
queryStatus: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SeatsService, useValue: mockSeatsService },
{ provide: TicketsService, useValue: mockTicketsService },
{ provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: TelebirrProvider, useValue: mockTelebirrProvider },
{ provide: CbeBirrProvider, useValue: mockCbeBirrProvider },
{ provide: EBirrProvider, useValue: mockEBirrProvider },
{ provide: CardProvider, useValue: mockCardProvider },
],
}).compile();
service = module.get<PaymentsService>(PaymentsService);
prisma = module.get<PrismaService>(PrismaService);
seatsService = module.get<SeatsService>(SeatsService);
ticketsService = module.get<TicketsService>(TicketsService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
jest.clearAllMocks();
});
describe('initiatePayment', () => {
const mockBooking = {
id: 'booking-1',
bookingRef: 'EDR123456',
passengerId: 'passenger-1',
totalMinor: 50000,
currency: 'ETB',
status: 'PENDING_PAYMENT',
seats: [{ id: 'seat-1', seatId: 'seat-id-1' }],
};
it('should throw NotFoundException if booking not found', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(null);
await expect(
service.initiatePayment({
bookingId: 'invalid',
method: 'TELEBIRR' as any,
}),
).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException if booking not payable', async () => {
mockPrisma.booking.findUnique.mockResolvedValue({
...mockBooking,
status: 'CONFIRMED',
});
await expect(
service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
}),
).rejects.toThrow(BadRequestException);
});
it('should initiate Telebirr payment successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockTelebirrProvider.initiate.mockResolvedValue({
providerOrderId: 'TB-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://telebirr.com/pay' },
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'TELEBIRR' as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockTelebirrProvider.initiate).toHaveBeenCalled();
});
it('should initiate CBE Birr payment successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockCbeBirrProvider.initiate.mockResolvedValue({
providerOrderId: 'CBE-ORDER-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
expiresAt: new Date(),
rawInitiation: {},
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.REQUIRES_ACTION,
merchantOrderId: 'MERCH-123',
clientAction: { type: 'REDIRECT', url: 'https://cbe.com/pay' },
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'CBE_BIRR' as any,
});
expect(result.status).toBe(PaymentIntentStatus.REQUIRES_ACTION);
expect(mockCbeBirrProvider.initiate).toHaveBeenCalled();
});
it('should initiate wallet payment and debit successfully', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
balanceMinor: 100000,
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.PROCESSING,
});
mockPrisma.paymentIntent.findUniqueOrThrow.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.SUCCEEDED,
bookingId: 'booking-1',
});
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
pointsBalance: 100,
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
});
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
expect(mockSeatsService.confirmSeats).toHaveBeenCalled();
expect(mockTicketsService.generate).toHaveBeenCalled();
});
it('should fail wallet payment with insufficient balance', async () => {
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
mockPrisma.walletAccount.findUnique.mockResolvedValue({
id: 'wallet-1',
passengerId: 'passenger-1',
balanceMinor: 10000, // Less than booking total
});
mockPrisma.paymentIntent.upsert.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.FAILED,
failureCode: 'INSUFFICIENT_BALANCE',
});
const result = await service.initiatePayment({
bookingId: 'booking-1',
method: 'WALLET' as any,
});
expect(result.status).toBe(PaymentIntentStatus.FAILED);
});
});
describe('finalizePaymentSuccess', () => {
it('should finalize payment and issue ticket', async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
status: PaymentIntentStatus.PROCESSING,
};
const mockBooking = {
id: 'booking-1',
passengerId: 'passenger-1',
totalMinor: 50000,
seats: [{ seatId: 'seat-1' }],
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
mockPrisma.booking.findUnique.mockResolvedValue(mockBooking);
mockPrisma.loyaltyAccount.findUnique.mockResolvedValue({
id: 'loyalty-1',
pointsBalance: 100,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
providerTxnId: 'TXN-123',
});
expect(result.alreadyFinalized).toBe(false);
expect(mockSeatsService.confirmSeats).toHaveBeenCalledWith(['seat-1']);
expect(mockTicketsService.generate).toHaveBeenCalledWith('booking-1');
expect(mockEventEmitter.emit).toHaveBeenCalledWith('payment.succeeded', {
booking: mockBooking,
});
});
it('should return alreadyFinalized if payment already succeeded', async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue({
id: 'intent-1',
status: PaymentIntentStatus.SUCCEEDED,
});
const result = await service.finalizePaymentSuccess({
intentId: 'intent-1',
});
expect(result.alreadyFinalized).toBe(true);
});
});
describe('getIntentByBookingId', () => {
it('should return intent status', async () => {
const mockIntent = {
id: 'intent-1',
bookingId: 'booking-1',
status: PaymentIntentStatus.SUCCEEDED,
method: PaymentMethodType.TELEBIRR,
paidAt: new Date(),
merchantOrderId: 'MERCH-123',
updatedAt: new Date(),
};
mockPrisma.paymentIntent.findUnique.mockResolvedValue(mockIntent);
const result = await service.getIntentByBookingId('booking-1');
expect(result.intentId).toBe('intent-1');
expect(result.status).toBe(PaymentIntentStatus.SUCCEEDED);
});
it('should throw NotFoundException if intent not found', async () => {
mockPrisma.paymentIntent.findUnique.mockResolvedValue(null);
await expect(service.getIntentByBookingId('invalid')).rejects.toThrow(
NotFoundException,
);
});
});
});