import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { createMockRepository } from '../../../__tests__/helpers.js'; // Mock factories for Stripe entities function createMockStripeEvent(overrides: Record = {}) { return { id: 'event-uuid-1', stripeEventId: 'evt_1234567890', eventType: 'customer.subscription.created', apiVersion: '2023-10-16', data: { object: { id: 'sub_123', customer: 'cus_123', status: 'active', current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }, processed: false, processedAt: null, retryCount: 0, errorMessage: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function createMockSubscription(overrides: Record = {}) { return { id: 'subscription-uuid-1', tenantId: 'tenant-uuid-1', planId: 'plan-uuid-1', status: 'active', stripeCustomerId: 'cus_123', stripeSubscriptionId: 'sub_123', currentPeriodStart: new Date(), currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), billingCycle: 'monthly', currentPrice: 499, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } // Mock repositories const mockEventRepository = createMockRepository(); const mockSubscriptionRepository = createMockRepository(); // Mock DataSource const mockDataSource = { getRepository: jest.fn((entity: any) => { const entityName = entity.name || entity; if (entityName === 'StripeEvent') return mockEventRepository; if (entityName === 'TenantSubscription') return mockSubscriptionRepository; return mockEventRepository; }), }; jest.mock('../../../shared/utils/logger.js', () => ({ logger: { info: jest.fn(), error: jest.fn(), debug: jest.fn(), warn: jest.fn(), }, })); // Import after mocking import { StripeWebhookService, StripeWebhookPayload } from '../services/stripe-webhook.service.js'; describe('StripeWebhookService', () => { let service: StripeWebhookService; beforeEach(() => { jest.clearAllMocks(); service = new StripeWebhookService(mockDataSource as any); }); describe('processWebhook', () => { it('should process a new webhook event successfully', async () => { const payload: StripeWebhookPayload = { id: 'evt_new_event', type: 'customer.subscription.created', api_version: '2023-10-16', data: { object: { id: 'sub_new', customer: 'cus_123', status: 'active', current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_new_event' }); const mockSubscription = createMockSubscription(); mockEventRepository.findOne.mockResolvedValue(null); // No existing event mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockResolvedValue(mockSubscription); const result = await service.processWebhook(payload); expect(result.success).toBe(true); expect(result.message).toBe('Event processed successfully'); expect(mockEventRepository.create).toHaveBeenCalled(); }); it('should return success for already processed event', async () => { const payload: StripeWebhookPayload = { id: 'evt_already_processed', type: 'customer.subscription.created', data: { object: {} }, created: Math.floor(Date.now() / 1000), livemode: false, }; const existingEvent = createMockStripeEvent({ stripeEventId: 'evt_already_processed', processed: true, }); mockEventRepository.findOne.mockResolvedValue(existingEvent); const result = await service.processWebhook(payload); expect(result.success).toBe(true); expect(result.message).toBe('Event already processed'); }); it('should retry processing for failed event', async () => { const payload: StripeWebhookPayload = { id: 'evt_failed', type: 'customer.subscription.created', data: { object: { id: 'sub_retry', customer: 'cus_123', status: 'active', current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const failedEvent = createMockStripeEvent({ stripeEventId: 'evt_failed', processed: false, retryCount: 1, data: payload.data, }); const mockSubscription = createMockSubscription(); mockEventRepository.findOne.mockResolvedValue(failedEvent); mockEventRepository.save.mockResolvedValue(failedEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockResolvedValue(mockSubscription); const result = await service.processWebhook(payload); expect(result.success).toBe(true); expect(result.message).toBe('Event processed on retry'); }); it('should handle processing errors gracefully', async () => { const payload: StripeWebhookPayload = { id: 'evt_error', type: 'customer.subscription.created', data: { object: { id: 'sub_error', customer: 'cus_123', status: 'active', }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_error' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockRejectedValue(new Error('Database error')); const result = await service.processWebhook(payload); expect(result.success).toBe(false); expect(result.error).toBe('Database error'); }); }); describe('handleSubscriptionCreated', () => { it('should create/link subscription for existing customer', async () => { const payload: StripeWebhookPayload = { id: 'evt_sub_created', type: 'customer.subscription.created', data: { object: { id: 'sub_new_123', customer: 'cus_existing', status: 'active', current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, trial_end: null, }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent(); const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_existing' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockResolvedValue(mockSubscription); const result = await service.processWebhook(payload); expect(result.success).toBe(true); expect(mockSubscriptionRepository.save).toHaveBeenCalled(); }); }); describe('handleSubscriptionUpdated', () => { it('should update subscription status', async () => { const payload: StripeWebhookPayload = { id: 'evt_sub_updated', type: 'customer.subscription.updated', data: { object: { id: 'sub_123', customer: 'cus_123', status: 'past_due', current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, cancel_at_period_end: false, canceled_at: null, }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent(); const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_123' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockResolvedValue({ ...mockSubscription, status: 'past_due' }); const result = await service.processWebhook(payload); expect(result.success).toBe(true); }); it('should handle cancel_at_period_end flag', async () => { const payload: StripeWebhookPayload = { id: 'evt_sub_cancel_scheduled', type: 'customer.subscription.updated', data: { object: { id: 'sub_cancel', customer: 'cus_123', status: 'active', current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, cancel_at_period_end: true, canceled_at: null, }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent({ eventType: 'customer.subscription.updated' }); const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_cancel' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub)); await service.processWebhook(payload); expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( expect.objectContaining({ cancelAtPeriodEnd: true }) ); }); }); describe('handleSubscriptionDeleted', () => { it('should mark subscription as cancelled', async () => { const payload: StripeWebhookPayload = { id: 'evt_sub_deleted', type: 'customer.subscription.deleted', data: { object: { id: 'sub_deleted', customer: 'cus_123', status: 'canceled', }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent(); const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_deleted' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub)); await service.processWebhook(payload); expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( expect.objectContaining({ status: 'cancelled' }) ); }); }); describe('handlePaymentSucceeded', () => { it('should update subscription with payment info', async () => { const payload: StripeWebhookPayload = { id: 'evt_payment_success', type: 'invoice.payment_succeeded', data: { object: { id: 'inv_123', customer: 'cus_123', amount_paid: 49900, // $499.00 in cents subscription: 'sub_123', }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_succeeded' }); const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub)); await service.processWebhook(payload); expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( expect.objectContaining({ status: 'active', lastPaymentAmount: 499, // Converted from cents }) ); }); }); describe('handlePaymentFailed', () => { it('should mark subscription as past_due', async () => { const payload: StripeWebhookPayload = { id: 'evt_payment_failed', type: 'invoice.payment_failed', data: { object: { id: 'inv_failed', customer: 'cus_123', attempt_count: 1, }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_failed' }); const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123', status: 'active' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub)); await service.processWebhook(payload); expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( expect.objectContaining({ status: 'past_due' }) ); }); }); describe('handleCheckoutCompleted', () => { it('should link Stripe customer to tenant', async () => { const payload: StripeWebhookPayload = { id: 'evt_checkout_completed', type: 'checkout.session.completed', data: { object: { id: 'cs_123', customer: 'cus_new', subscription: 'sub_new', metadata: { tenant_id: 'tenant-uuid-1', }, }, }, created: Math.floor(Date.now() / 1000), livemode: false, }; const mockEvent = createMockStripeEvent({ eventType: 'checkout.session.completed' }); const mockSubscription = createMockSubscription({ tenantId: 'tenant-uuid-1' }); mockEventRepository.findOne.mockResolvedValue(null); mockEventRepository.create.mockReturnValue(mockEvent); mockEventRepository.save.mockResolvedValue(mockEvent); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub)); await service.processWebhook(payload); expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( expect.objectContaining({ stripeCustomerId: 'cus_new', stripeSubscriptionId: 'sub_new', status: 'active', }) ); }); }); describe('retryProcessing', () => { it('should retry and succeed', async () => { const failedEvent = createMockStripeEvent({ processed: false, retryCount: 2, data: { object: { id: 'sub_retry', customer: 'cus_123', status: 'active', current_period_start: Math.floor(Date.now() / 1000), current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }, }); const mockSubscription = createMockSubscription(); mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); mockSubscriptionRepository.save.mockResolvedValue(mockSubscription); mockEventRepository.save.mockResolvedValue(failedEvent); const result = await service.retryProcessing(failedEvent as any); expect(result.success).toBe(true); expect(result.message).toBe('Event processed on retry'); }); it('should fail if max retries exceeded', async () => { const maxRetriedEvent = createMockStripeEvent({ processed: false, retryCount: 5, errorMessage: 'Previous error', }); const result = await service.retryProcessing(maxRetriedEvent as any); expect(result.success).toBe(false); expect(result.message).toBe('Max retries exceeded'); }); }); describe('getFailedEvents', () => { it('should return unprocessed events', async () => { const failedEvents = [ createMockStripeEvent({ processed: false }), createMockStripeEvent({ processed: false, stripeEventId: 'evt_2' }), ]; mockEventRepository.find.mockResolvedValue(failedEvents); const result = await service.getFailedEvents(); expect(result).toHaveLength(2); expect(mockEventRepository.find).toHaveBeenCalledWith({ where: { processed: false }, order: { createdAt: 'ASC' }, take: 100, }); }); it('should respect limit parameter', async () => { mockEventRepository.find.mockResolvedValue([]); await service.getFailedEvents(50); expect(mockEventRepository.find).toHaveBeenCalledWith( expect.objectContaining({ take: 50 }) ); }); }); describe('findByStripeEventId', () => { it('should find event by Stripe ID', async () => { const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_find' }); mockEventRepository.findOne.mockResolvedValue(mockEvent); const result = await service.findByStripeEventId('evt_find'); expect(result).toBeDefined(); expect(result?.stripeEventId).toBe('evt_find'); }); it('should return null if not found', async () => { mockEventRepository.findOne.mockResolvedValue(null); const result = await service.findByStripeEventId('evt_notfound'); expect(result).toBeNull(); }); }); describe('getRecentEvents', () => { it('should return recent events with default options', async () => { const mockEvents = [createMockStripeEvent()]; const mockQueryBuilder = { andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(mockEvents), }; mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); const result = await service.getRecentEvents(); expect(result).toHaveLength(1); expect(mockQueryBuilder.take).toHaveBeenCalledWith(50); }); it('should filter by event type', async () => { const mockQueryBuilder = { andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), }; mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); await service.getRecentEvents({ eventType: 'invoice.payment_succeeded' }); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( 'event.eventType = :eventType', { eventType: 'invoice.payment_succeeded' } ); }); it('should filter by processed status', async () => { const mockQueryBuilder = { andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), }; mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); await service.getRecentEvents({ processed: false }); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( 'event.processed = :processed', { processed: false } ); }); }); });