test(auth,payments,subs): add 58 unit tests for critical auth, payment, and subscription paths
Cover auth handlers (RegisterUser, LoginUser, RefreshToken), TokenService (token rotation, reuse attack detection), payment callback edge cases (duplicate/concurrent callbacks, multi-provider), subscription lifecycle transitions (expire, pastDue, renew), and throttler proxy guard. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { PaymentEntity } from '../../domain/entities/payment.entity';
|
||||
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
|
||||
import { Money } from '../../domain/value-objects/money.vo';
|
||||
import { HandleCallbackCommand } from '../commands/handle-callback/handle-callback.command';
|
||||
import { HandleCallbackHandler } from '../commands/handle-callback/handle-callback.handler';
|
||||
|
||||
function createPaymentEntity(
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' = 'PROCESSING',
|
||||
provider: 'VNPAY' | 'MOMO' | 'ZALOPAY' = 'VNPAY',
|
||||
): PaymentEntity {
|
||||
const money = Money.create(500_000n).unwrap();
|
||||
const payment = PaymentEntity.createNew('pay-1', 'user-1', provider, 'SUBSCRIPTION', money);
|
||||
if (status === 'PROCESSING') payment.markProcessing('provider-tx-1');
|
||||
if (status === 'COMPLETED') {
|
||||
payment.markProcessing('provider-tx-1');
|
||||
payment.markCompleted({ verified: true });
|
||||
}
|
||||
if (status === 'FAILED') {
|
||||
payment.markProcessing('provider-tx-1');
|
||||
payment.markFailed({ reason: 'declined' });
|
||||
}
|
||||
payment.clearDomainEvents();
|
||||
return payment;
|
||||
}
|
||||
|
||||
describe('HandleCallbackHandler — edge cases', () => {
|
||||
let handler: HandleCallbackHandler;
|
||||
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockGatewayFactory: { getGateway: ReturnType<typeof vi.fn> };
|
||||
let mockGateway: { verifyCallback: ReturnType<typeof vi.fn>; createPaymentUrl: ReturnType<typeof vi.fn>; refund: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPaymentRepo = {
|
||||
findById: vi.fn(),
|
||||
findByProviderTxId: vi.fn(),
|
||||
findByIdempotencyKey: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateIfStatus: vi.fn(),
|
||||
};
|
||||
mockGateway = {
|
||||
verifyCallback: vi.fn(),
|
||||
createPaymentUrl: vi.fn(),
|
||||
refund: vi.fn(),
|
||||
};
|
||||
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new HandleCallbackHandler(
|
||||
mockPaymentRepo as any,
|
||||
mockGatewayFactory as any,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns idempotent response for duplicate success callback on COMPLETED payment', async () => {
|
||||
const completedPayment = createPaymentEntity('COMPLETED');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'provider-tx-1',
|
||||
isSuccess: true,
|
||||
rawData: { responseCode: '00' },
|
||||
});
|
||||
// updateIfStatus returns null because payment is already COMPLETED
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||
mockPaymentRepo.findById.mockResolvedValue(completedPayment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.paymentId).toBe('pay-1');
|
||||
expect(result.status).toBe('COMPLETED');
|
||||
expect(result.isSuccess).toBe(true);
|
||||
// No new events should be published for idempotent response
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns idempotent response for duplicate callback on FAILED payment', async () => {
|
||||
const failedPayment = createPaymentEntity('FAILED');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'provider-tx-1',
|
||||
isSuccess: false,
|
||||
rawData: { responseCode: '24' },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||
mockPaymentRepo.findById.mockResolvedValue(failedPayment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '24' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.paymentId).toBe('pay-1');
|
||||
expect(result.status).toBe('FAILED');
|
||||
expect(result.isSuccess).toBe(false);
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses correct gateway for MoMo provider', async () => {
|
||||
const payment = createPaymentEntity('PROCESSING', 'MOMO');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'momo-tx-1',
|
||||
isSuccess: true,
|
||||
rawData: { resultCode: 0 },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||
|
||||
const command = new HandleCallbackCommand('MOMO', { resultCode: 0 });
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockGatewayFactory.getGateway).toHaveBeenCalledWith('MOMO');
|
||||
});
|
||||
|
||||
it('uses correct gateway for ZaloPay provider', async () => {
|
||||
const payment = createPaymentEntity('PROCESSING', 'ZALOPAY');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'zalo-tx-1',
|
||||
isSuccess: true,
|
||||
rawData: { return_code: 1 },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||
|
||||
const command = new HandleCallbackCommand('ZALOPAY', { return_code: 1 });
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockGatewayFactory.getGateway).toHaveBeenCalledWith('ZALOPAY');
|
||||
});
|
||||
|
||||
it('publishes PaymentFailedEvent on failed callback', async () => {
|
||||
const payment = createPaymentEntity('PROCESSING');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'provider-tx-1',
|
||||
isSuccess: false,
|
||||
rawData: { responseCode: '99' },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '99' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.isSuccess).toBe(false);
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventName: 'payment.failed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes PaymentCompletedEvent on successful callback', async () => {
|
||||
const payment = createPaymentEntity('PROCESSING');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'provider-tx-1',
|
||||
isSuccess: true,
|
||||
rawData: { responseCode: '00' },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(payment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.isSuccess).toBe(true);
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventName: 'payment.completed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles concurrent callbacks — second callback gets idempotent response', async () => {
|
||||
// Simulate: first callback succeeds, second callback arrives and updateIfStatus returns null
|
||||
const completedPayment = createPaymentEntity('COMPLETED');
|
||||
mockGateway.verifyCallback.mockReturnValue({
|
||||
isValid: true,
|
||||
orderId: 'pay-1',
|
||||
providerTxId: 'provider-tx-1',
|
||||
isSuccess: true,
|
||||
rawData: { responseCode: '00' },
|
||||
});
|
||||
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
|
||||
mockPaymentRepo.findById.mockResolvedValue(completedPayment);
|
||||
|
||||
const command = new HandleCallbackCommand('VNPAY', { vnp_ResponseCode: '00' });
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('COMPLETED');
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user