mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
329 lines
10 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|
|
});
|