/** * Unit tests for src/services/BillingService.ts and src/services/UsageService.ts */ import { Pool, QueryResult } from 'pg'; import Stripe from 'stripe'; import { BillingService } from '../../../src/services/BillingService'; import { UsageService } from '../../../src/services/UsageService'; // ── Mock pg Pool ───────────────────────────────────────────────────────────── function makePool(queryFn: jest.Mock): Pool { return { query: queryFn } as unknown as Pool; } // ── Mock Stripe ─────────────────────────────────────────────────────────────── function makeStripe(overrides: Partial<{ checkoutUrl: string; constructEvent: () => Stripe.Event; retrieveCustomer: () => Stripe.Customer; }> = {}): Stripe { const checkoutUrl = overrides.checkoutUrl ?? 'https://checkout.stripe.com/session_test'; const fakeSubscription: Stripe.Subscription = { id: 'sub_1', object: 'subscription', status: 'active', customer: 'cus_1', billing_cycle_anchor: Math.floor(Date.now() / 1000) + 86400, ended_at: null, } as unknown as Stripe.Subscription; const fakeEvent: Stripe.Event = { id: 'evt_1', type: 'customer.subscription.created', object: 'event', data: { object: fakeSubscription }, api_version: '2026-03-25.dahlia', created: Math.floor(Date.now() / 1000), livemode: false, pending_webhooks: 0, request: null, } as unknown as Stripe.Event; const fakeCustomer: Stripe.Customer = { id: 'cus_1', object: 'customer', created: Math.floor(Date.now() / 1000), livemode: false, metadata: { tenant_id: 'tenant-uuid-123' }, deleted: undefined, } as unknown as Stripe.Customer; return { checkout: { sessions: { create: jest.fn().mockResolvedValue({ url: checkoutUrl, id: 'cs_1' }), }, }, webhooks: { constructEvent: overrides.constructEvent ? jest.fn(overrides.constructEvent) : jest.fn().mockReturnValue(fakeEvent), }, customers: { retrieve: overrides.retrieveCustomer ? jest.fn(overrides.retrieveCustomer) : jest.fn().mockResolvedValue(fakeCustomer), }, } as unknown as Stripe; } // ════════════════════════════════════════════════════════════════════════════ // UsageService // ════════════════════════════════════════════════════════════════════════════ describe('UsageService', () => { describe('getDailyUsage()', () => { it('should return correct IUsageSummary with api_calls from DB', async () => { const mockQuery = jest.fn() .mockResolvedValueOnce({ rows: [{ count: '42' }] } as unknown as QueryResult) .mockResolvedValueOnce({ rows: [{ count: '5' }] } as unknown as QueryResult); const service = new UsageService(makePool(mockQuery)); const result = await service.getDailyUsage('tenant-1', '2026-04-02'); expect(result).toEqual({ tenantId: 'tenant-1', date: '2026-04-02', apiCalls: 42, agentCount: 5, }); }); it('should default apiCalls to 0 when no usage_events row exists', async () => { const mockQuery = jest.fn() .mockResolvedValueOnce({ rows: [{ count: '0' }] } as unknown as QueryResult) .mockResolvedValueOnce({ rows: [{ count: '2' }] } as unknown as QueryResult); const service = new UsageService(makePool(mockQuery)); const result = await service.getDailyUsage('tenant-2', '2026-04-02'); expect(result.apiCalls).toBe(0); expect(result.agentCount).toBe(2); }); }); describe('getActiveAgentCount()', () => { it('should return the count of non-decommissioned agents', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [{ count: '7' }], } as unknown as QueryResult); const service = new UsageService(makePool(mockQuery)); const count = await service.getActiveAgentCount('tenant-1'); expect(count).toBe(7); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining("status != 'decommissioned'"), ['tenant-1'], ); }); it('should return 0 when no agents exist', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [{ count: '0' }], } as unknown as QueryResult); const service = new UsageService(makePool(mockQuery)); const count = await service.getActiveAgentCount('tenant-empty'); expect(count).toBe(0); }); }); }); // ════════════════════════════════════════════════════════════════════════════ // BillingService // ════════════════════════════════════════════════════════════════════════════ describe('BillingService', () => { describe('createCheckoutSession()', () => { it('should return the Stripe checkout URL', async () => { const mockQuery = jest.fn(); const stripe = makeStripe({ checkoutUrl: 'https://checkout.stripe.com/test' }); const service = new BillingService(makePool(mockQuery), stripe); const url = await service.createCheckoutSession( 'tenant-1', 'https://app.com/success', 'https://app.com/cancel', ); expect(url).toBe('https://checkout.stripe.com/test'); }); }); describe('getSubscriptionStatus()', () => { it('should return free status when no subscription row exists', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult); const stripe = makeStripe(); const service = new BillingService(makePool(mockQuery), stripe); const status = await service.getSubscriptionStatus('tenant-free'); expect(status).toEqual({ tenantId: 'tenant-free', status: 'free', currentPeriodEnd: null, stripeSubscriptionId: null, }); }); it('should return subscription data when a row exists', async () => { const periodEnd = new Date('2026-05-01T00:00:00Z'); const mockQuery = jest.fn().mockResolvedValue({ rows: [{ status: 'active', current_period_end: periodEnd, stripe_subscription_id: 'sub_abc123', }], } as unknown as QueryResult); const stripe = makeStripe(); const service = new BillingService(makePool(mockQuery), stripe); const status = await service.getSubscriptionStatus('tenant-paid'); expect(status.status).toBe('active'); expect(status.stripeSubscriptionId).toBe('sub_abc123'); expect(status.currentPeriodEnd).toEqual(periodEnd); }); }); describe('handleWebhookEvent()', () => { it('should process customer.subscription.created and upsert DB', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult); const stripe = makeStripe(); const service = new BillingService(makePool(mockQuery), stripe); await service.handleWebhookEvent( Buffer.from('{}'), 'stripe-sig-header', 'whsec_test', ); // constructEvent should have been called with raw body, sig, and secret expect(stripe.webhooks.constructEvent).toHaveBeenCalledWith( expect.any(Buffer), 'stripe-sig-header', 'whsec_test', ); // DB upsert should have been called expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('INSERT INTO tenant_subscriptions'), expect.any(Array), ); }); it('should throw when Stripe signature verification fails', async () => { const mockQuery = jest.fn(); const sigError = new Error('Stripe signature verification failed'); const stripe = makeStripe({ constructEvent: () => { throw sigError; }, }); const service = new BillingService(makePool(mockQuery), stripe); await expect( service.handleWebhookEvent(Buffer.from('{}'), 'bad-sig', 'whsec_test'), ).rejects.toThrow('Stripe signature verification failed'); }); it('should skip processing for unrecognised event types', async () => { const mockQuery = jest.fn(); const unknownEvent: unknown = { id: 'evt_unknown', type: 'payment_intent.created', object: 'event', data: { object: {} }, api_version: '2026-03-25.dahlia', created: Math.floor(Date.now() / 1000), livemode: false, pending_webhooks: 0, request: null, }; const stripe = makeStripe({ constructEvent: () => unknownEvent as Stripe.Event, }); const service = new BillingService(makePool(mockQuery), stripe); await service.handleWebhookEvent(Buffer.from('{}'), 'sig', 'whsec_test'); // No DB query for unrecognised events expect(mockQuery).not.toHaveBeenCalled(); }); }); });