587 lines
20 KiB
TypeScript
587 lines
20 KiB
TypeScript
/**
|
|
* Unit Tests for NOWPaymentsWebhookController
|
|
*
|
|
* Tests cover:
|
|
* - Signature validation (missing header, invalid signature, valid signature)
|
|
* - Idempotency (duplicate event detection)
|
|
* - Event routing (finished, confirmed, failed, expired, partially_paid, refunded)
|
|
* - Transaction status updates
|
|
* - Error handling (processing failures)
|
|
*/
|
|
|
|
/**
|
|
* IMPORTANT: Set LILITH_PROJECT_ROOT before any module imports.
|
|
* The PaymentAnalyticsService uses @lilith/service-registry which requires this env var.
|
|
*/
|
|
import path from 'path';
|
|
if (!process.env.LILITH_PROJECT_ROOT) {
|
|
// webhooks -> backend-api -> payments -> features -> codebase -> lilith-platform
|
|
process.env.LILITH_PROJECT_ROOT = path.resolve(__dirname, '../../../../..');
|
|
}
|
|
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { UnauthorizedException, BadRequestException, Logger } from '@nestjs/common';
|
|
import type { RawBodyRequest } from '@nestjs/common';
|
|
import type { Request } from 'express';
|
|
|
|
import { NOWPaymentsWebhookController } from './nowpayments.webhook.controller';
|
|
import type { NOWPaymentsIPNPayload } from './nowpayments.webhook.controller';
|
|
import { TransactionEntity } from '@/src/entities/transaction.entity';
|
|
|
|
import {
|
|
createMockNOWPaymentsProvider,
|
|
createMockWebhookEventsService,
|
|
createMockPaymentAnalyticsService,
|
|
createMockDomainEventsEmitter,
|
|
createMockEarningsService,
|
|
createMockConfigService,
|
|
createMockRepository,
|
|
type MockNOWPaymentsProvider,
|
|
type MockWebhookEventsService,
|
|
type MockPaymentAnalyticsService,
|
|
type MockDomainEventsEmitter,
|
|
type MockEarningsService,
|
|
type MockConfigService,
|
|
type MockRepository,
|
|
} from '../test/mocks';
|
|
|
|
const WebhookProcessingStatus = {
|
|
PENDING: 'pending',
|
|
SUCCESS: 'success',
|
|
FAILED: 'failed',
|
|
} as const;
|
|
|
|
function createIPNPayload(overrides: Partial<NOWPaymentsIPNPayload> = {}): NOWPaymentsIPNPayload {
|
|
return {
|
|
payment_id: 12345,
|
|
payment_status: 'finished',
|
|
pay_address: '0xabc123',
|
|
price_amount: 49.99,
|
|
price_currency: 'usd',
|
|
pay_amount: 0.025,
|
|
pay_currency: 'btc',
|
|
order_id: 'tx_user456_tip_1700000000',
|
|
order_description: 'Payment',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createRawBodyRequest(payload: NOWPaymentsIPNPayload): RawBodyRequest<Request> {
|
|
return { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
}
|
|
|
|
describe('NOWPaymentsWebhookController', () => {
|
|
let controller: NOWPaymentsWebhookController;
|
|
let nowPaymentsProvider: MockNOWPaymentsProvider;
|
|
let webhookEventsService: MockWebhookEventsService;
|
|
let paymentAnalytics: MockPaymentAnalyticsService;
|
|
let domainEvents: MockDomainEventsEmitter;
|
|
let earningsService: MockEarningsService;
|
|
let configService: MockConfigService;
|
|
let transactionRepository: MockRepository<TransactionEntity>;
|
|
|
|
beforeEach(() => {
|
|
nowPaymentsProvider = createMockNOWPaymentsProvider();
|
|
webhookEventsService = createMockWebhookEventsService();
|
|
paymentAnalytics = createMockPaymentAnalyticsService();
|
|
domainEvents = createMockDomainEventsEmitter();
|
|
earningsService = createMockEarningsService();
|
|
configService = createMockConfigService();
|
|
({ repository: transactionRepository } = createMockRepository<TransactionEntity>());
|
|
|
|
// Suppress logger output in tests
|
|
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
|
|
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
|
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => {});
|
|
|
|
controller = new NOWPaymentsWebhookController(
|
|
nowPaymentsProvider,
|
|
paymentAnalytics,
|
|
domainEvents,
|
|
webhookEventsService,
|
|
earningsService,
|
|
configService,
|
|
transactionRepository,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('Signature Validation', () => {
|
|
it('should throw UnauthorizedException when signature header is missing', async () => {
|
|
const payload = createIPNPayload();
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, '', payload),
|
|
).rejects.toThrow(UnauthorizedException);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, '', payload),
|
|
).rejects.toThrow('Missing signature');
|
|
});
|
|
|
|
it('should throw UnauthorizedException when signature is invalid', async () => {
|
|
const payload = createIPNPayload();
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(false);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'invalid-sig', payload),
|
|
).rejects.toThrow(UnauthorizedException);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'invalid-sig', payload),
|
|
).rejects.toThrow('Invalid signature');
|
|
|
|
expect(nowPaymentsProvider.verifyWebhookSignature).toHaveBeenCalledWith({
|
|
rawBody: JSON.stringify(payload),
|
|
signature: 'invalid-sig',
|
|
secret: 'test-ipn-secret',
|
|
});
|
|
});
|
|
|
|
it('should proceed when signature is valid', async () => {
|
|
const payload = createIPNPayload();
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(null);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_123',
|
|
provider: 'nowpayments',
|
|
eventType: 'payment.succeeded',
|
|
payload,
|
|
idempotencyKey: 'np_12345',
|
|
processingStatus: WebhookProcessingStatus.PENDING,
|
|
},
|
|
isDuplicate: false,
|
|
});
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(result).toEqual({ received: true, eventId: 'evt_123' });
|
|
});
|
|
|
|
it('should throw BadRequestException when IPN secret is not configured', async () => {
|
|
const payload = createIPNPayload();
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
const emptyConfig = createMockConfigService({ NOWPAYMENTS_IPN_SECRET: '' });
|
|
const controllerNoSecret = new NOWPaymentsWebhookController(
|
|
nowPaymentsProvider,
|
|
paymentAnalytics,
|
|
domainEvents,
|
|
webhookEventsService,
|
|
earningsService,
|
|
emptyConfig,
|
|
transactionRepository,
|
|
);
|
|
|
|
await expect(
|
|
controllerNoSecret.handleWebhook(req, 'some-sig', payload),
|
|
).rejects.toThrow(BadRequestException);
|
|
|
|
await expect(
|
|
controllerNoSecret.handleWebhook(req, 'some-sig', payload),
|
|
).rejects.toThrow('Webhook verification unavailable');
|
|
});
|
|
});
|
|
|
|
describe('Idempotency', () => {
|
|
it('should return early when webhook is duplicate', async () => {
|
|
const payload = createIPNPayload();
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_existing',
|
|
provider: 'nowpayments',
|
|
eventType: 'payment.succeeded',
|
|
payload,
|
|
idempotencyKey: 'np_12345',
|
|
processingStatus: WebhookProcessingStatus.SUCCESS,
|
|
},
|
|
isDuplicate: true,
|
|
});
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(result).toEqual({ received: true, duplicate: true, eventId: 'evt_existing' });
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
|
|
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use payment_id as idempotency key', async () => {
|
|
const payload = createIPNPayload({ payment_id: 99999 });
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(null);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_new' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(webhookEventsService.storeWebhookEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
provider: 'nowpayments',
|
|
idempotencyKey: 'np_99999',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - payment succeeded (finished)', () => {
|
|
it('should track revenue and emit domain events for finished payment', async () => {
|
|
const payload = createIPNPayload({
|
|
payment_id: 55555,
|
|
payment_status: 'finished',
|
|
price_amount: 99.99,
|
|
price_currency: 'usd',
|
|
pay_amount: 0.05,
|
|
pay_currency: 'btc',
|
|
order_id: 'tx_user789_tip_1700000000',
|
|
});
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(null);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_success' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
|
|
userId: 'user789',
|
|
transactionId: '55555',
|
|
transactionType: 'PRODUCTSALE',
|
|
amount: 99.99,
|
|
currency: 'usd',
|
|
platformFee: 0,
|
|
metadata: 'nowpayments.finished',
|
|
});
|
|
|
|
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
|
|
transactionId: '55555',
|
|
userId: 'user789',
|
|
amountInCents: 9999,
|
|
currency: 'usd',
|
|
paymentMethod: 'crypto',
|
|
paymentType: 'one_time',
|
|
completedAt: expect.any(String),
|
|
merchantId: '',
|
|
productId: 'tx_user789_tip_1700000000',
|
|
metadata: {
|
|
provider: 'nowpayments',
|
|
payCurrency: 'btc',
|
|
payAmount: 0.05,
|
|
actuallyPaid: undefined,
|
|
},
|
|
});
|
|
|
|
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
|
|
sessionId: '55555',
|
|
userId: 'user789',
|
|
transactionId: '55555',
|
|
amountInCents: 9999,
|
|
type: 'one_time',
|
|
attribution: expect.any(Object),
|
|
});
|
|
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_success');
|
|
});
|
|
|
|
it('should update transaction status on succeeded payment', async () => {
|
|
const payload = createIPNPayload({
|
|
payment_id: 77777,
|
|
payment_status: 'confirmed',
|
|
});
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
const mockTransaction = {
|
|
id: 'txn_uuid',
|
|
providerTransactionId: '77777',
|
|
status: 'pending',
|
|
metadata: { existing: true },
|
|
};
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(mockTransaction);
|
|
transactionRepository.save!.mockResolvedValue(mockTransaction);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_confirmed' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(transactionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: 'completed',
|
|
metadata: expect.objectContaining({
|
|
existing: true,
|
|
paymentStatus: 'confirmed',
|
|
payCurrency: 'btc',
|
|
completedAt: expect.any(String),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - payment failed', () => {
|
|
it('should update transaction status on failed payment', async () => {
|
|
const payload = createIPNPayload({
|
|
payment_id: 88888,
|
|
payment_status: 'failed',
|
|
order_id: 'tx_user123_tip_1700000000',
|
|
});
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
const mockTransaction = {
|
|
id: 'txn_uuid',
|
|
providerTransactionId: '88888',
|
|
status: 'pending',
|
|
metadata: {},
|
|
};
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(mockTransaction);
|
|
transactionRepository.save!.mockResolvedValue(mockTransaction);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_failed' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(transactionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: 'failed',
|
|
metadata: expect.objectContaining({
|
|
paymentStatus: 'failed',
|
|
failedAt: expect.any(String),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
|
|
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_failed');
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - payment expired', () => {
|
|
it('should update transaction status on expired payment', async () => {
|
|
const payload = createIPNPayload({
|
|
payment_id: 66666,
|
|
payment_status: 'expired',
|
|
});
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
const mockTransaction = {
|
|
id: 'txn_uuid',
|
|
providerTransactionId: '66666',
|
|
status: 'pending',
|
|
metadata: {},
|
|
};
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(mockTransaction);
|
|
transactionRepository.save!.mockResolvedValue(mockTransaction);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_expired' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(transactionRepository.save).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
status: 'cancelled',
|
|
metadata: expect.objectContaining({
|
|
paymentStatus: 'expired',
|
|
expiredAt: expect.any(String),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_expired');
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - payment refunded', () => {
|
|
it('should track refund and emit refund domain event', async () => {
|
|
const payload = createIPNPayload({
|
|
payment_id: 44444,
|
|
payment_status: 'refunded',
|
|
price_amount: 25.00,
|
|
price_currency: 'usd',
|
|
order_id: 'tx_userABC_tip_1700000000',
|
|
});
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(null);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_refund' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(paymentAnalytics.trackRefund).toHaveBeenCalledWith({
|
|
userId: 'userABC',
|
|
transactionId: '44444',
|
|
amount: 25.00,
|
|
currency: 'usd',
|
|
platformFee: 0,
|
|
metadata: 'nowpayments.refunded',
|
|
});
|
|
|
|
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith({
|
|
transactionId: '44444',
|
|
refundId: 'np_refund_44444',
|
|
userId: 'userABC',
|
|
amountInCents: 2500,
|
|
currency: 'usd',
|
|
reason: 'other',
|
|
refundType: 'full',
|
|
refundedAt: expect.any(String),
|
|
merchantId: '',
|
|
metadata: {
|
|
provider: 'nowpayments',
|
|
payCurrency: 'btc',
|
|
},
|
|
});
|
|
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_refund');
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - partially_paid', () => {
|
|
it('should log warning for partial payments without processing', async () => {
|
|
const payload = createIPNPayload({
|
|
payment_id: 33333,
|
|
payment_status: 'partially_paid',
|
|
actually_paid: 0.01,
|
|
pay_amount: 0.05,
|
|
});
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_partial' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
|
|
expect(transactionRepository.save).not.toHaveBeenCalled();
|
|
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_partial');
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should mark event as failed when processing throws error', async () => {
|
|
const payload = createIPNPayload();
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_error',
|
|
provider: 'nowpayments',
|
|
eventType: 'payment.succeeded',
|
|
payload,
|
|
idempotencyKey: 'np_12345',
|
|
processingStatus: WebhookProcessingStatus.PENDING,
|
|
},
|
|
isDuplicate: false,
|
|
});
|
|
|
|
const processingError = new Error('Database connection failed');
|
|
transactionRepository.findOne!.mockRejectedValue(processingError);
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(result).toEqual({ received: true, eventId: 'evt_error' });
|
|
expect(webhookEventsService.markAsFailed).toHaveBeenCalledWith('evt_error', processingError);
|
|
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 200 even when processing fails', async () => {
|
|
const payload = createIPNPayload({ payment_status: 'failed' });
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_fail_process' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
transactionRepository.findOne!.mockRejectedValue(new Error('DB down'));
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(result).toEqual({ received: true, eventId: 'evt_fail_process' });
|
|
});
|
|
});
|
|
|
|
describe('Event Type Mapping', () => {
|
|
it('should map payment_status to correct event types in stored events', async () => {
|
|
const statusToEvent: Record<string, string> = {
|
|
finished: 'payment.succeeded',
|
|
confirmed: 'payment.succeeded',
|
|
failed: 'payment.failed',
|
|
expired: 'payment.expired',
|
|
partially_paid: 'payment.partial',
|
|
refunded: 'payment.refunded',
|
|
};
|
|
|
|
for (const [status, expectedEvent] of Object.entries(statusToEvent)) {
|
|
vi.clearAllMocks();
|
|
|
|
// Re-suppress logger after clearAllMocks
|
|
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
|
|
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
|
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
|
|
|
const payload = createIPNPayload({ payment_status: status });
|
|
const req = createRawBodyRequest(payload);
|
|
|
|
nowPaymentsProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
transactionRepository.findOne!.mockResolvedValue(null);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: `evt_${status}` },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-sig', payload);
|
|
|
|
expect(webhookEventsService.storeWebhookEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
eventType: expectedEvent,
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|