1230 lines
40 KiB
TypeScript
1230 lines
40 KiB
TypeScript
/**
|
|
* Unit Tests for SegpayWebhookController
|
|
*
|
|
* Tests cover:
|
|
* - Signature validation (missing header, invalid signature, valid signature)
|
|
* - Replay prevention (timestamp tolerance checks)
|
|
* - Idempotency (duplicate event detection)
|
|
* - Error handling (processing failures)
|
|
* - Event routing (all event types with correct analytics/domain events)
|
|
*/
|
|
|
|
/**
|
|
* 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 { SegpayWebhookController } from './segpay.webhook.controller';
|
|
import { TransactionEntity } from '../src/entities/transaction.entity';
|
|
|
|
import {
|
|
createMockSegpayProvider,
|
|
createMockWebhookEventsService,
|
|
createMockPaymentAnalyticsService,
|
|
createMockDomainEventsEmitter,
|
|
createMockEarningsService,
|
|
createMockConfigService,
|
|
createMockRepository,
|
|
type MockSegpayProvider,
|
|
type MockWebhookEventsService,
|
|
type MockPaymentAnalyticsService,
|
|
type MockDomainEventsEmitter,
|
|
type MockEarningsService,
|
|
type MockConfigService,
|
|
type MockRepository,
|
|
} from '../test/mocks';
|
|
|
|
// Define enum values inline to avoid importing from actual modules
|
|
const TransactionType = {
|
|
PRODUCTSALE: 'PRODUCTSALE',
|
|
SUBSCRIPTION: 'SUBSCRIPTION',
|
|
TIP: 'TIP',
|
|
SERVICEBOOKING: 'SERVICEBOOKING',
|
|
} as const;
|
|
|
|
const WebhookProcessingStatus = {
|
|
PENDING: 'pending',
|
|
SUCCESS: 'success',
|
|
FAILED: 'failed',
|
|
} as const;
|
|
|
|
describe('SegpayWebhookController', () => {
|
|
let controller: SegpayWebhookController;
|
|
let segpayProvider: MockSegpayProvider;
|
|
let webhookEventsService: MockWebhookEventsService;
|
|
let paymentAnalytics: MockPaymentAnalyticsService;
|
|
let domainEvents: MockDomainEventsEmitter;
|
|
let earningsService: MockEarningsService;
|
|
let configService: MockConfigService;
|
|
let transactionRepository: MockRepository<TransactionEntity>;
|
|
|
|
beforeEach(() => {
|
|
segpayProvider = createMockSegpayProvider();
|
|
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 SegpayWebhookController(
|
|
segpayProvider,
|
|
paymentAnalytics,
|
|
domainEvents,
|
|
webhookEventsService,
|
|
earningsService,
|
|
configService,
|
|
transactionRepository,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('Signature Validation', () => {
|
|
it('should throw UnauthorizedException when signature header is missing', async () => {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: { subscriptionId: 'sub_123' },
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
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 = {
|
|
event: 'subscription.created',
|
|
data: { subscriptionId: 'sub_123' },
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(false);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'invalid-signature', payload),
|
|
).rejects.toThrow(UnauthorizedException);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'invalid-signature', payload),
|
|
).rejects.toThrow('Invalid signature');
|
|
|
|
expect(segpayProvider.verifyWebhookSignature).toHaveBeenCalledWith({
|
|
rawBody: JSON.stringify(payload),
|
|
signature: 'invalid-signature',
|
|
secret: 'test-webhook-secret',
|
|
});
|
|
});
|
|
|
|
it('should proceed when signature is valid', async () => {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 29.99,
|
|
tierId: 'tier_gold',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_123',
|
|
provider: 'segpay',
|
|
eventType: 'subscription.created',
|
|
payload,
|
|
idempotencyKey: 'sub_123',
|
|
processingStatus: WebhookProcessingStatus.PENDING,
|
|
},
|
|
isDuplicate: false,
|
|
});
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(result).toEqual({ received: true, eventId: 'evt_123' });
|
|
expect(segpayProvider.verifyWebhookSignature).toHaveBeenCalledWith({
|
|
rawBody: JSON.stringify(payload),
|
|
signature: 'valid-signature',
|
|
secret: 'test-webhook-secret',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Replay Prevention', () => {
|
|
it('should reject webhook with timestamp older than 5 minutes', async () => {
|
|
const oldTimestamp = Date.now() - (6 * 60 * 1000); // 6 minutes ago
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: { subscriptionId: 'sub_123' },
|
|
timestamp: oldTimestamp,
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'valid-signature', payload),
|
|
).rejects.toThrow(BadRequestException);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'valid-signature', payload),
|
|
).rejects.toThrow('Webhook timestamp too old');
|
|
});
|
|
|
|
it('should reject webhook with future timestamp beyond tolerance', async () => {
|
|
const futureTimestamp = Date.now() + (6 * 60 * 1000); // 6 minutes in future
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: { subscriptionId: 'sub_123' },
|
|
timestamp: futureTimestamp,
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'valid-signature', payload),
|
|
).rejects.toThrow(BadRequestException);
|
|
|
|
await expect(
|
|
controller.handleWebhook(req, 'valid-signature', payload),
|
|
).rejects.toThrow('Webhook timestamp too old');
|
|
});
|
|
|
|
it('should accept webhook within timestamp tolerance', async () => {
|
|
const validTimestamp = Date.now() - (4 * 60 * 1000); // 4 minutes ago (within 5 min tolerance)
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 29.99,
|
|
},
|
|
timestamp: validTimestamp,
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_123',
|
|
provider: 'segpay',
|
|
eventType: 'subscription.created',
|
|
payload,
|
|
idempotencyKey: 'sub_123',
|
|
processingStatus: WebhookProcessingStatus.PENDING,
|
|
},
|
|
isDuplicate: false,
|
|
});
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(result).toEqual({ received: true, eventId: 'evt_123' });
|
|
});
|
|
});
|
|
|
|
describe('Idempotency', () => {
|
|
it('should return early when webhook is duplicate', async () => {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 29.99,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_123',
|
|
provider: 'segpay',
|
|
eventType: 'subscription.created',
|
|
payload,
|
|
idempotencyKey: 'sub_123',
|
|
processingStatus: WebhookProcessingStatus.SUCCESS,
|
|
},
|
|
isDuplicate: true,
|
|
});
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(result).toEqual({ received: true, duplicate: true, eventId: 'evt_123' });
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitSubscriptionCreated).not.toHaveBeenCalled();
|
|
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should process new webhook event', async () => {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 29.99,
|
|
tierId: 'tier_gold',
|
|
transactionId: 'txn_789',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_123',
|
|
provider: 'segpay',
|
|
eventType: 'subscription.created',
|
|
payload,
|
|
idempotencyKey: 'sub_123',
|
|
processingStatus: WebhookProcessingStatus.PENDING,
|
|
},
|
|
isDuplicate: false,
|
|
});
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(result).toEqual({ received: true, eventId: 'evt_123' });
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_123');
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should mark event as failed when processing throws error', async () => {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 29.99,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_123',
|
|
provider: 'segpay',
|
|
eventType: 'subscription.created',
|
|
payload,
|
|
idempotencyKey: 'sub_123',
|
|
processingStatus: WebhookProcessingStatus.PENDING,
|
|
},
|
|
isDuplicate: false,
|
|
});
|
|
|
|
const processingError = new Error('Database connection failed');
|
|
paymentAnalytics.trackRevenue.mockRejectedValue(processingError);
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(result).toEqual({ received: true, eventId: 'evt_123' });
|
|
expect(webhookEventsService.markAsFailed).toHaveBeenCalledWith('evt_123', processingError);
|
|
expect(webhookEventsService.markAsProcessed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return 200 even when processing fails', async () => {
|
|
const payload = {
|
|
event: 'payment.succeeded',
|
|
data: {
|
|
transactionId: 'txn_123',
|
|
userId: 'user_456',
|
|
amount: 49.99,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: {
|
|
id: 'evt_456',
|
|
provider: 'segpay',
|
|
eventType: 'payment.succeeded',
|
|
payload,
|
|
idempotencyKey: 'txn_123',
|
|
processingStatus: WebhookProcessingStatus.PENDING,
|
|
},
|
|
isDuplicate: false,
|
|
});
|
|
|
|
paymentAnalytics.trackRevenue.mockRejectedValue(new Error('Analytics service down'));
|
|
|
|
const result = await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Should return success to prevent provider retries
|
|
expect(result).toEqual({ received: true, eventId: 'evt_456' });
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - subscription.created', () => {
|
|
it('should track revenue and emit domain events for subscription.created', async () => {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 29.99,
|
|
currency: 'USD',
|
|
tierId: 'tier_gold',
|
|
transactionId: 'txn_789',
|
|
interval: 'monthly',
|
|
platformFee: 2.99,
|
|
merchantId: 'merchant_abc',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify trackRevenue call
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
|
|
userId: 'user_456',
|
|
transactionId: 'txn_789',
|
|
transactionType: TransactionType.SUBSCRIPTION,
|
|
amount: 29.99,
|
|
currency: 'USD',
|
|
platformFee: 2.99,
|
|
metadata: 'subscription.created',
|
|
});
|
|
|
|
// Verify emitSubscriptionCreated call
|
|
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalledWith({
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
tierId: 'tier_gold',
|
|
transactionId: 'txn_789',
|
|
amountInCents: 2999, // 29.99 * 100 rounded
|
|
currency: 'USD',
|
|
interval: 'monthly',
|
|
createdAt: expect.any(String),
|
|
merchantId: 'merchant_abc',
|
|
metadata: {
|
|
provider: 'segpay',
|
|
platformFee: 2.99,
|
|
},
|
|
});
|
|
|
|
// Verify emitPurchase call (first subscription payment)
|
|
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
|
|
sessionId: 'txn_789',
|
|
userId: 'user_456',
|
|
transactionId: 'txn_789',
|
|
amountInCents: 2999,
|
|
type: 'subscription',
|
|
attribution: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should use default values when optional fields are missing', async () => {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 19.99,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalledWith({
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
tierId: 'default',
|
|
transactionId: 'sub_123', // Falls back to subscriptionId
|
|
amountInCents: 1999,
|
|
currency: 'USD', // Default
|
|
interval: 'monthly', // Default
|
|
createdAt: expect.any(String),
|
|
merchantId: undefined,
|
|
metadata: {
|
|
provider: 'segpay',
|
|
platformFee: 0, // Default
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - subscription.cancelled', () => {
|
|
it('should emit SubscriptionCancelled domain event', async () => {
|
|
const payload = {
|
|
event: 'subscription.cancelled',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
tierId: 'tier_gold',
|
|
reason: 'customer_request',
|
|
endsAt: '2026-03-05T00:00:00Z',
|
|
merchantId: 'merchant_abc',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(domainEvents.emitSubscriptionCancelled).toHaveBeenCalledWith({
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
tierId: 'tier_gold',
|
|
reason: 'customer_request',
|
|
cancelledAt: expect.any(String),
|
|
endsAt: '2026-03-05T00:00:00Z',
|
|
merchantId: 'merchant_abc',
|
|
metadata: {
|
|
provider: 'segpay',
|
|
},
|
|
});
|
|
|
|
// Should NOT track revenue for cancellations
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use default values for missing cancellation fields', async () => {
|
|
const payload = {
|
|
event: 'subscription.cancelled',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(domainEvents.emitSubscriptionCancelled).toHaveBeenCalledWith({
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
tierId: 'default',
|
|
reason: 'customer_request', // Default
|
|
cancelledAt: expect.any(String),
|
|
endsAt: expect.any(String), // Default: 30 days from now
|
|
merchantId: undefined,
|
|
metadata: {
|
|
provider: 'segpay',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - subscription.renewed', () => {
|
|
it('should track revenue and emit renewal events', async () => {
|
|
const payload = {
|
|
event: 'subscription.renewed',
|
|
data: {
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
amount: 29.99,
|
|
currency: 'USD',
|
|
tierId: 'tier_gold',
|
|
transactionId: 'txn_renewal_789',
|
|
interval: 'monthly',
|
|
renewalCount: 2, // 3rd payment total
|
|
platformFee: 2.99,
|
|
merchantId: 'merchant_abc',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify trackRevenue call
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
|
|
userId: 'user_456',
|
|
transactionId: 'txn_renewal_789',
|
|
transactionType: TransactionType.SUBSCRIPTION,
|
|
amount: 29.99,
|
|
currency: 'USD',
|
|
platformFee: 2.99,
|
|
metadata: 'subscription.renewed',
|
|
});
|
|
|
|
// Verify emitSubscriptionRenewed call
|
|
expect(domainEvents.emitSubscriptionRenewed).toHaveBeenCalledWith({
|
|
subscriptionId: 'sub_123',
|
|
userId: 'user_456',
|
|
tierId: 'tier_gold',
|
|
transactionId: 'txn_renewal_789',
|
|
amountInCents: 2999,
|
|
currency: 'USD',
|
|
interval: 'monthly',
|
|
renewalCount: 2,
|
|
renewedAt: expect.any(String),
|
|
merchantId: 'merchant_abc',
|
|
metadata: {
|
|
provider: 'segpay',
|
|
platformFee: 2.99,
|
|
},
|
|
});
|
|
|
|
// Verify emitRepeatPurchase call (renewalCount=2 means 3rd payment)
|
|
expect(domainEvents.emitRepeatPurchase).toHaveBeenCalledWith({
|
|
sessionId: 'txn_renewal_789',
|
|
userId: 'user_456',
|
|
transactionId: 'txn_renewal_789',
|
|
amountInCents: 2999,
|
|
purchaseCount: 3, // renewalCount + 1
|
|
attribution: expect.any(Object),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - payment.succeeded', () => {
|
|
it('should track revenue and emit events for tip payment', async () => {
|
|
const payload = {
|
|
event: 'payment.succeeded',
|
|
data: {
|
|
transactionId: 'txn_tip_123',
|
|
userId: 'user_456',
|
|
amount: 15.50,
|
|
currency: 'USD',
|
|
paymentType: 'tip',
|
|
platformFee: 1.50,
|
|
merchantId: 'merchant_abc',
|
|
productId: 'prod_tip_001',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify trackRevenue call with TIP type
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
|
|
userId: 'user_456',
|
|
transactionId: 'txn_tip_123',
|
|
transactionType: TransactionType.TIP,
|
|
amount: 15.50,
|
|
currency: 'USD',
|
|
platformFee: 1.50,
|
|
metadata: 'payment.succeeded.tip',
|
|
});
|
|
|
|
// Verify emitPaymentCompleted call
|
|
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
|
|
transactionId: 'txn_tip_123',
|
|
userId: 'user_456',
|
|
amountInCents: 1550, // 15.50 * 100
|
|
currency: 'USD',
|
|
paymentMethod: 'card',
|
|
paymentType: 'tip',
|
|
completedAt: expect.any(String),
|
|
merchantId: 'merchant_abc',
|
|
productId: 'prod_tip_001',
|
|
metadata: {
|
|
provider: 'segpay',
|
|
platformFee: 1.50,
|
|
},
|
|
});
|
|
|
|
// Verify emitPurchase call with type 'tip'
|
|
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
|
|
sessionId: 'txn_tip_123',
|
|
userId: 'user_456',
|
|
transactionId: 'txn_tip_123',
|
|
amountInCents: 1550,
|
|
type: 'tip',
|
|
attribution: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should track revenue and emit events for booking payment', async () => {
|
|
const payload = {
|
|
event: 'payment.succeeded',
|
|
data: {
|
|
transactionId: 'txn_booking_456',
|
|
userId: 'user_789',
|
|
amount: 199.99,
|
|
currency: 'EUR',
|
|
paymentType: 'booking',
|
|
platformFee: 19.99,
|
|
merchantId: 'merchant_xyz',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_456' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify trackRevenue call with SERVICEBOOKING type
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
|
|
userId: 'user_789',
|
|
transactionId: 'txn_booking_456',
|
|
transactionType: TransactionType.SERVICEBOOKING,
|
|
amount: 199.99,
|
|
currency: 'EUR',
|
|
platformFee: 19.99,
|
|
metadata: 'payment.succeeded.booking',
|
|
});
|
|
|
|
// Verify emitPaymentCompleted call
|
|
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
|
|
transactionId: 'txn_booking_456',
|
|
userId: 'user_789',
|
|
amountInCents: 19999,
|
|
currency: 'EUR',
|
|
paymentMethod: 'card',
|
|
paymentType: 'booking',
|
|
completedAt: expect.any(String),
|
|
merchantId: 'merchant_xyz',
|
|
productId: undefined,
|
|
metadata: {
|
|
provider: 'segpay',
|
|
platformFee: 19.99,
|
|
},
|
|
});
|
|
|
|
// Verify emitPurchase call with type 'one_time' (booking maps to one_time for funnel)
|
|
expect(domainEvents.emitPurchase).toHaveBeenCalledWith({
|
|
sessionId: 'txn_booking_456',
|
|
userId: 'user_789',
|
|
transactionId: 'txn_booking_456',
|
|
amountInCents: 19999,
|
|
type: 'one_time',
|
|
attribution: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should track revenue and emit events for default product payment', async () => {
|
|
const payload = {
|
|
event: 'payment.succeeded',
|
|
data: {
|
|
transactionId: 'txn_product_789',
|
|
userId: 'user_123',
|
|
amount: 49.99,
|
|
paymentType: 'product',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_789' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify trackRevenue call with PRODUCTSALE type (default)
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
|
|
userId: 'user_123',
|
|
transactionId: 'txn_product_789',
|
|
transactionType: TransactionType.PRODUCTSALE,
|
|
amount: 49.99,
|
|
currency: 'USD', // Default
|
|
platformFee: 0, // Default
|
|
metadata: 'payment.succeeded.product',
|
|
});
|
|
|
|
// Verify emitPaymentCompleted call
|
|
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith({
|
|
transactionId: 'txn_product_789',
|
|
userId: 'user_123',
|
|
amountInCents: 4999,
|
|
currency: 'USD',
|
|
paymentMethod: 'card',
|
|
paymentType: 'one_time',
|
|
completedAt: expect.any(String),
|
|
merchantId: undefined,
|
|
productId: undefined,
|
|
metadata: {
|
|
provider: 'segpay',
|
|
platformFee: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle payment without paymentType field', async () => {
|
|
const payload = {
|
|
event: 'payment.succeeded',
|
|
data: {
|
|
transactionId: 'txn_unknown_999',
|
|
userId: 'user_999',
|
|
amount: 99.99,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_999' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Should default to PRODUCTSALE
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
transactionType: TransactionType.PRODUCTSALE,
|
|
}),
|
|
);
|
|
|
|
// Should map to 'one_time' for PaymentCompleted
|
|
expect(domainEvents.emitPaymentCompleted).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
paymentType: 'one_time',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - payment.failed', () => {
|
|
it('should only log payment.failed events', async () => {
|
|
const payload = {
|
|
event: 'payment.failed',
|
|
data: {
|
|
transactionId: 'txn_failed_123',
|
|
failureReason: 'insufficient_funds',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_fail_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Should NOT track revenue or emit events for failed payments
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPurchase).not.toHaveBeenCalled();
|
|
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_fail_123');
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - gift_card.purchased', () => {
|
|
it('should track gift card purchase as product sale', async () => {
|
|
const payload = {
|
|
event: 'gift_card.purchased',
|
|
data: {
|
|
giftCardId: 'gc_123',
|
|
purchaserId: 'user_buyer_456',
|
|
amount: 50.00,
|
|
currency: 'USD',
|
|
platformFee: 5.00,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_gc_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify trackRevenue call
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith({
|
|
userId: 'user_buyer_456',
|
|
transactionId: 'gc_123',
|
|
transactionType: TransactionType.PRODUCTSALE,
|
|
amount: 50.00,
|
|
currency: 'USD',
|
|
platformFee: 5.00,
|
|
metadata: 'gift_card.purchased',
|
|
});
|
|
|
|
// Should NOT emit domain events (gift cards have separate flow)
|
|
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPurchase).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use generated transactionId when giftCardId is missing', async () => {
|
|
const payload = {
|
|
event: 'gift_card.purchased',
|
|
data: {
|
|
purchaserId: 'user_buyer_789',
|
|
amount: 100.00,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_gc_456' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Should generate transactionId
|
|
expect(paymentAnalytics.trackRevenue).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user_buyer_789',
|
|
transactionId: expect.stringMatching(/^gc_\d+$/),
|
|
amount: 100.00,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - chargeback.lost', () => {
|
|
it('should track refund and emit PaymentRefunded event', async () => {
|
|
const payload = {
|
|
event: 'chargeback.lost',
|
|
data: {
|
|
chargebackId: 'cb_123',
|
|
userId: 'user_456',
|
|
originalTransactionId: 'txn_original_789',
|
|
amount: 29.99,
|
|
currency: 'USD',
|
|
platformFee: 2.99,
|
|
merchantId: 'merchant_abc',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_cb_123' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify trackRefund call
|
|
expect(paymentAnalytics.trackRefund).toHaveBeenCalledWith({
|
|
userId: 'user_456',
|
|
transactionId: 'cb_123',
|
|
amount: 29.99,
|
|
currency: 'USD',
|
|
platformFee: 2.99,
|
|
metadata: 'chargeback.lost',
|
|
});
|
|
|
|
// Verify emitPaymentRefunded call
|
|
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith({
|
|
transactionId: 'txn_original_789',
|
|
refundId: 'cb_123',
|
|
userId: 'user_456',
|
|
amountInCents: 2999,
|
|
currency: 'USD',
|
|
reason: 'chargeback',
|
|
refundType: 'full',
|
|
refundedAt: expect.any(String),
|
|
merchantId: 'merchant_abc',
|
|
metadata: {
|
|
provider: 'segpay',
|
|
chargebackId: 'cb_123',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should fallback to transactionId when originalTransactionId is missing', async () => {
|
|
const payload = {
|
|
event: 'chargeback.lost',
|
|
data: {
|
|
chargebackId: 'cb_456',
|
|
userId: 'user_789',
|
|
transactionId: 'txn_fallback_123',
|
|
amount: 49.99,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_cb_456' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Verify emitPaymentRefunded uses transactionId fallback
|
|
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
transactionId: 'txn_fallback_123',
|
|
refundId: 'cb_456',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should generate refundId when chargebackId is missing', async () => {
|
|
const payload = {
|
|
event: 'chargeback.lost',
|
|
data: {
|
|
userId: 'user_999',
|
|
transactionId: 'txn_999',
|
|
amount: 99.99,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_cb_999' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Should generate refundId with timestamp
|
|
expect(paymentAnalytics.trackRefund).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
transactionId: expect.stringMatching(/^chargeback_\d+$/),
|
|
}),
|
|
);
|
|
|
|
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
refundId: expect.stringMatching(/^chargeback_\d+$/),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - chargeback.created and chargeback.won', () => {
|
|
it('should flag transaction and emit refund event for chargeback.created', async () => {
|
|
const payload = {
|
|
event: 'chargeback.created',
|
|
data: {
|
|
chargebackId: 'cb_new_123',
|
|
transactionId: 'txn_disputed_456',
|
|
amount: 75.00,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_cb_created' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// chargeback.created emits refund event (for admin notification) but does NOT track refund revenue
|
|
expect(paymentAnalytics.trackRefund).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPaymentRefunded).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
refundId: 'cb_new_123',
|
|
reason: 'chargeback',
|
|
}),
|
|
);
|
|
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_cb_created');
|
|
});
|
|
|
|
it('should only update metadata for chargeback.won events', async () => {
|
|
const payload = {
|
|
event: 'chargeback.won',
|
|
data: {
|
|
chargebackId: 'cb_won_789',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_cb_won' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// chargeback.won does NOT track refund or emit events — it only logs + updates metadata
|
|
expect(paymentAnalytics.trackRefund).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPaymentRefunded).not.toHaveBeenCalled();
|
|
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_cb_won');
|
|
});
|
|
});
|
|
|
|
describe('Event Routing - unknown event types', () => {
|
|
it('should log warning for unknown event types', async () => {
|
|
const payload = {
|
|
event: 'unknown.event.type',
|
|
data: {
|
|
someField: 'someValue',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_unknown' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
// Should complete successfully without processing
|
|
expect(webhookEventsService.markAsProcessed).toHaveBeenCalledWith('evt_unknown');
|
|
|
|
// Should NOT call any analytics or domain events
|
|
expect(paymentAnalytics.trackRevenue).not.toHaveBeenCalled();
|
|
expect(paymentAnalytics.trackRefund).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitSubscriptionCreated).not.toHaveBeenCalled();
|
|
expect(domainEvents.emitPaymentCompleted).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Amount Calculation', () => {
|
|
it('should correctly calculate amountInCents with rounding', async () => {
|
|
const testCases = [
|
|
{ amount: 29.99, expected: 2999 },
|
|
{ amount: 0.01, expected: 1 },
|
|
{ amount: 100.00, expected: 10000 },
|
|
{ amount: 15.555, expected: 1556 }, // Rounds 1555.5 to 1556
|
|
{ amount: 15.554, expected: 1555 }, // Rounds 1555.4 to 1555
|
|
];
|
|
|
|
for (const { amount, expected } of testCases) {
|
|
const payload = {
|
|
event: 'subscription.created',
|
|
data: {
|
|
subscriptionId: 'sub_test',
|
|
userId: 'user_test',
|
|
amount,
|
|
transactionId: 'txn_test',
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const req = { rawBody: Buffer.from(JSON.stringify(payload)) } as RawBodyRequest<Request>;
|
|
|
|
segpayProvider.verifyWebhookSignature.mockResolvedValue(true);
|
|
webhookEventsService.storeWebhookEvent.mockResolvedValue({
|
|
event: { id: 'evt_test' },
|
|
isDuplicate: false,
|
|
});
|
|
|
|
await controller.handleWebhook(req, 'valid-signature', payload);
|
|
|
|
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
amountInCents: expected,
|
|
}),
|
|
);
|
|
|
|
// Reset mocks for next iteration
|
|
vi.clearAllMocks();
|
|
}
|
|
});
|
|
});
|
|
});
|