diff --git a/src/modules/analytics/__tests__/analytics.controller.spec.ts b/src/modules/analytics/__tests__/analytics.controller.spec.ts new file mode 100644 index 0000000..26b52ef --- /dev/null +++ b/src/modules/analytics/__tests__/analytics.controller.spec.ts @@ -0,0 +1,295 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsController } from '../analytics.controller'; +import { AnalyticsService } from '../analytics.service'; +import { + UserMetricsDto, + BillingMetricsDto, + UsageMetricsDto, + AnalyticsSummaryDto, + TrendDataDto, +} from '../dto'; +import { RequestUser } from '../../auth/strategies/jwt.strategy'; + +describe('AnalyticsController', () => { + let controller: AnalyticsController; + let service: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockUser: RequestUser = { + sub: mockUserId, + tenant_id: mockTenantId, + email: 'test@example.com', + role: 'admin', + }; + + const mockUserMetrics: UserMetricsDto = { + totalUsers: 100, + activeUsers: 80, + inactiveUsers: 10, + pendingVerification: 5, + suspendedUsers: 5, + newUsers: 20, + usersLoggedIn: 60, + growthRate: 15.5, + retentionRate: 75, + }; + + const mockBillingMetrics: BillingMetricsDto = { + subscriptionStatus: 'active', + currentPlan: 'Pro Plan', + totalRevenue: 1500.50, + paidInvoices: 15, + pendingInvoices: 2, + totalDue: 199.99, + averageInvoiceAmount: 100.03, + daysUntilExpiration: 15, + isTrialPeriod: false, + revenueTrend: 25.5, + }; + + const mockUsageMetrics: UsageMetricsDto = { + totalActions: 5000, + activeUsersCount: 50, + actionsByType: { CREATE: 1000, UPDATE: 2000, DELETE: 500, READ: 1500 }, + actionsByEntity: { User: 1000, Invoice: 2000, Subscription: 2000 }, + averageActionsPerUser: 100, + peakUsageHour: 14, + loginCount: 200, + avgSessionDuration: 30, + topEntities: [ + { entityType: 'Invoice', count: 2000 }, + { entityType: 'Subscription', count: 2000 }, + { entityType: 'User', count: 1000 }, + ], + }; + + const mockSummary: AnalyticsSummaryDto = { + totalUsers: 100, + activeUsers: 80, + mrr: 999.99, + subscriptionStatus: 'active', + actionsThisMonth: 5000, + actionsGrowth: 20.5, + userGrowth: 15.5, + pendingAmount: 199.99, + }; + + const mockTrends: TrendDataDto[] = [ + { + metric: 'new_users', + data: [ + { date: '2026-01-01', value: 10 }, + { date: '2026-01-02', value: 15 }, + ], + }, + { + metric: 'actions', + data: [ + { date: '2026-01-01', value: 100 }, + { date: '2026-01-02', value: 150 }, + ], + }, + { + metric: 'logins', + data: [ + { date: '2026-01-01', value: 20 }, + { date: '2026-01-02', value: 25 }, + ], + }, + { + metric: 'revenue', + data: [ + { date: '2026-01-01', value: 99.99 }, + { date: '2026-01-02', value: 149.99 }, + ], + }, + ]; + + beforeEach(async () => { + const mockAnalyticsService = { + getUserMetrics: jest.fn(), + getBillingMetrics: jest.fn(), + getUsageMetrics: jest.fn(), + getSummary: jest.fn(), + getTrends: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [ + { provide: AnalyticsService, useValue: mockAnalyticsService }, + ], + }).compile(); + + controller = module.get(AnalyticsController); + service = module.get(AnalyticsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getUserMetrics', () => { + it('should return user metrics for tenant', async () => { + service.getUserMetrics.mockResolvedValue(mockUserMetrics); + + const result = await controller.getUserMetrics(mockUser, { period: '30d' }); + + expect(result).toEqual(mockUserMetrics); + expect(service.getUserMetrics).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + + it('should use default period when not provided', async () => { + service.getUserMetrics.mockResolvedValue(mockUserMetrics); + + const result = await controller.getUserMetrics(mockUser, {}); + + expect(result).toEqual(mockUserMetrics); + expect(service.getUserMetrics).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + + it('should work with 7d period', async () => { + service.getUserMetrics.mockResolvedValue(mockUserMetrics); + + await controller.getUserMetrics(mockUser, { period: '7d' }); + + expect(service.getUserMetrics).toHaveBeenCalledWith(mockTenantId, '7d'); + }); + + it('should work with 90d period', async () => { + service.getUserMetrics.mockResolvedValue(mockUserMetrics); + + await controller.getUserMetrics(mockUser, { period: '90d' }); + + expect(service.getUserMetrics).toHaveBeenCalledWith(mockTenantId, '90d'); + }); + + it('should work with 1y period', async () => { + service.getUserMetrics.mockResolvedValue(mockUserMetrics); + + await controller.getUserMetrics(mockUser, { period: '1y' }); + + expect(service.getUserMetrics).toHaveBeenCalledWith(mockTenantId, '1y'); + }); + }); + + describe('getBillingMetrics', () => { + it('should return billing metrics for tenant', async () => { + service.getBillingMetrics.mockResolvedValue(mockBillingMetrics); + + const result = await controller.getBillingMetrics(mockUser, { period: '30d' }); + + expect(result).toEqual(mockBillingMetrics); + expect(service.getBillingMetrics).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + + it('should use default period when not provided', async () => { + service.getBillingMetrics.mockResolvedValue(mockBillingMetrics); + + const result = await controller.getBillingMetrics(mockUser, {}); + + expect(result).toEqual(mockBillingMetrics); + expect(service.getBillingMetrics).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + }); + + describe('getUsageMetrics', () => { + it('should return usage metrics for tenant', async () => { + service.getUsageMetrics.mockResolvedValue(mockUsageMetrics); + + const result = await controller.getUsageMetrics(mockUser, { period: '30d' }); + + expect(result).toEqual(mockUsageMetrics); + expect(service.getUsageMetrics).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + + it('should use default period when not provided', async () => { + service.getUsageMetrics.mockResolvedValue(mockUsageMetrics); + + const result = await controller.getUsageMetrics(mockUser, {}); + + expect(result).toEqual(mockUsageMetrics); + expect(service.getUsageMetrics).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + }); + + describe('getSummary', () => { + it('should return analytics summary for tenant', async () => { + service.getSummary.mockResolvedValue(mockSummary); + + const result = await controller.getSummary(mockUser); + + expect(result).toEqual(mockSummary); + expect(service.getSummary).toHaveBeenCalledWith(mockTenantId); + }); + + it('should not require period parameter', async () => { + service.getSummary.mockResolvedValue(mockSummary); + + await controller.getSummary(mockUser); + + expect(service.getSummary).toHaveBeenCalledWith(mockTenantId); + expect(service.getSummary).toHaveBeenCalledTimes(1); + }); + }); + + describe('getTrends', () => { + it('should return trend data for tenant', async () => { + service.getTrends.mockResolvedValue(mockTrends); + + const result = await controller.getTrends(mockUser, { period: '30d' }); + + expect(result).toEqual(mockTrends); + expect(service.getTrends).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + + it('should use default period when not provided', async () => { + service.getTrends.mockResolvedValue(mockTrends); + + const result = await controller.getTrends(mockUser, {}); + + expect(result).toEqual(mockTrends); + expect(service.getTrends).toHaveBeenCalledWith(mockTenantId, '30d'); + }); + + it('should return all 4 trend metrics', async () => { + service.getTrends.mockResolvedValue(mockTrends); + + const result = await controller.getTrends(mockUser, { period: '30d' }); + + expect(result).toHaveLength(4); + expect(result.map(t => t.metric)).toEqual(['new_users', 'actions', 'logins', 'revenue']); + }); + }); + + describe('Multi-tenant isolation', () => { + it('should always use tenant_id from authenticated user', async () => { + const anotherUser: RequestUser = { + sub: 'another-user-id', + tenant_id: 'another-tenant-id', + email: 'other@example.com', + role: 'user', + }; + + service.getUserMetrics.mockResolvedValue(mockUserMetrics); + service.getBillingMetrics.mockResolvedValue(mockBillingMetrics); + service.getUsageMetrics.mockResolvedValue(mockUsageMetrics); + service.getSummary.mockResolvedValue(mockSummary); + service.getTrends.mockResolvedValue(mockTrends); + + await controller.getUserMetrics(anotherUser, {}); + await controller.getBillingMetrics(anotherUser, {}); + await controller.getUsageMetrics(anotherUser, {}); + await controller.getSummary(anotherUser); + await controller.getTrends(anotherUser, {}); + + expect(service.getUserMetrics).toHaveBeenCalledWith('another-tenant-id', '30d'); + expect(service.getBillingMetrics).toHaveBeenCalledWith('another-tenant-id', '30d'); + expect(service.getUsageMetrics).toHaveBeenCalledWith('another-tenant-id', '30d'); + expect(service.getSummary).toHaveBeenCalledWith('another-tenant-id'); + expect(service.getTrends).toHaveBeenCalledWith('another-tenant-id', '30d'); + }); + }); +}); diff --git a/src/modules/analytics/__tests__/analytics.service.spec.ts b/src/modules/analytics/__tests__/analytics.service.spec.ts new file mode 100644 index 0000000..c6dca84 --- /dev/null +++ b/src/modules/analytics/__tests__/analytics.service.spec.ts @@ -0,0 +1,802 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { AnalyticsService } from '../analytics.service'; +import { User } from '../../auth/entities/user.entity'; +import { Subscription, SubscriptionStatus } from '../../billing/entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../../billing/entities/invoice.entity'; +import { AuditLog, AuditAction } from '../../audit/entities/audit-log.entity'; +import { AnalyticsPeriod } from '../dto'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let userRepo: jest.Mocked>; + let subscriptionRepo: jest.Mocked>; + let invoiceRepo: jest.Mocked>; + let auditLogRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + + // Mock query builder for complex queries + const createMockQueryBuilder = () => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue(null), + getRawMany: jest.fn().mockResolvedValue([]), + }); + + beforeEach(async () => { + const mockUserRepo = { + count: jest.fn(), + createQueryBuilder: jest.fn(() => createMockQueryBuilder()), + }; + + const mockSubscriptionRepo = { + findOne: jest.fn(), + }; + + const mockInvoiceRepo = { + find: jest.fn(), + createQueryBuilder: jest.fn(() => createMockQueryBuilder()), + }; + + const mockAuditLogRepo = { + count: jest.fn(), + createQueryBuilder: jest.fn(() => createMockQueryBuilder()), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo }, + { provide: getRepositoryToken(Invoice), useValue: mockInvoiceRepo }, + { provide: getRepositoryToken(AuditLog), useValue: mockAuditLogRepo }, + ], + }).compile(); + + service = module.get(AnalyticsService); + userRepo = module.get(getRepositoryToken(User)); + subscriptionRepo = module.get(getRepositoryToken(Subscription)); + invoiceRepo = module.get(getRepositoryToken(Invoice)); + auditLogRepo = module.get(getRepositoryToken(AuditLog)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== getUserMetrics Tests ==================== + + describe('getUserMetrics', () => { + const period: AnalyticsPeriod = '30d'; + + beforeEach(() => { + userRepo.count.mockResolvedValue(0); + }); + + it('should return user metrics with all counts', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // inactiveUsers + .mockResolvedValueOnce(5) // pendingVerification + .mockResolvedValueOnce(5) // suspendedUsers + .mockResolvedValueOnce(20) // newUsers + .mockResolvedValueOnce(15) // prevNewUsers + .mockResolvedValueOnce(60); // usersLoggedIn + + const result = await service.getUserMetrics(mockTenantId, period); + + expect(result.totalUsers).toBe(100); + expect(result.activeUsers).toBe(80); + expect(result.inactiveUsers).toBe(10); + expect(result.pendingVerification).toBe(5); + expect(result.suspendedUsers).toBe(5); + expect(result.newUsers).toBe(20); + expect(result.usersLoggedIn).toBe(60); + }); + + it('should calculate positive growth rate correctly', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // inactiveUsers + .mockResolvedValueOnce(5) // pendingVerification + .mockResolvedValueOnce(5) // suspendedUsers + .mockResolvedValueOnce(20) // newUsers (current) + .mockResolvedValueOnce(10) // prevNewUsers (previous) + .mockResolvedValueOnce(60); // usersLoggedIn + + const result = await service.getUserMetrics(mockTenantId, period); + + // Growth rate = ((20 - 10) / 10) * 100 = 100% + expect(result.growthRate).toBe(100); + }); + + it('should calculate negative growth rate correctly', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // inactiveUsers + .mockResolvedValueOnce(5) // pendingVerification + .mockResolvedValueOnce(5) // suspendedUsers + .mockResolvedValueOnce(5) // newUsers (current) + .mockResolvedValueOnce(10) // prevNewUsers (previous) + .mockResolvedValueOnce(60); // usersLoggedIn + + const result = await service.getUserMetrics(mockTenantId, period); + + // Growth rate = ((5 - 10) / 10) * 100 = -50% + expect(result.growthRate).toBe(-50); + }); + + it('should handle zero previous users (100% growth)', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // inactiveUsers + .mockResolvedValueOnce(5) // pendingVerification + .mockResolvedValueOnce(5) // suspendedUsers + .mockResolvedValueOnce(10) // newUsers (current) + .mockResolvedValueOnce(0) // prevNewUsers (previous = 0) + .mockResolvedValueOnce(60); // usersLoggedIn + + const result = await service.getUserMetrics(mockTenantId, period); + + // When prev = 0 and current > 0, growth = 100% + expect(result.growthRate).toBe(100); + }); + + it('should handle zero users in both periods (0% growth)', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // inactiveUsers + .mockResolvedValueOnce(5) // pendingVerification + .mockResolvedValueOnce(5) // suspendedUsers + .mockResolvedValueOnce(0) // newUsers (current = 0) + .mockResolvedValueOnce(0) // prevNewUsers (previous = 0) + .mockResolvedValueOnce(60); // usersLoggedIn + + const result = await service.getUserMetrics(mockTenantId, period); + + expect(result.growthRate).toBe(0); + }); + + it('should calculate retention rate correctly', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // inactiveUsers + .mockResolvedValueOnce(5) // pendingVerification + .mockResolvedValueOnce(5) // suspendedUsers + .mockResolvedValueOnce(20) // newUsers + .mockResolvedValueOnce(15) // prevNewUsers + .mockResolvedValueOnce(60); // usersLoggedIn + + const result = await service.getUserMetrics(mockTenantId, period); + + // Retention rate = (60 / 80) * 100 = 75% + expect(result.retentionRate).toBe(75); + }); + + it('should handle zero active users (0% retention)', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(0) // activeUsers = 0 + .mockResolvedValueOnce(10) // inactiveUsers + .mockResolvedValueOnce(5) // pendingVerification + .mockResolvedValueOnce(5) // suspendedUsers + .mockResolvedValueOnce(20) // newUsers + .mockResolvedValueOnce(15) // prevNewUsers + .mockResolvedValueOnce(0); // usersLoggedIn + + const result = await service.getUserMetrics(mockTenantId, period); + + expect(result.retentionRate).toBe(0); + }); + + it('should work with 7d period', async () => { + userRepo.count.mockResolvedValue(50); + + const result = await service.getUserMetrics(mockTenantId, '7d'); + + expect(result.totalUsers).toBe(50); + expect(userRepo.count).toHaveBeenCalledTimes(8); + }); + + it('should work with 90d period', async () => { + userRepo.count.mockResolvedValue(100); + + const result = await service.getUserMetrics(mockTenantId, '90d'); + + expect(result.totalUsers).toBe(100); + }); + + it('should work with 1y period', async () => { + userRepo.count.mockResolvedValue(200); + + const result = await service.getUserMetrics(mockTenantId, '1y'); + + expect(result.totalUsers).toBe(200); + }); + }); + + // ==================== getBillingMetrics Tests ==================== + + describe('getBillingMetrics', () => { + const period: AnalyticsPeriod = '30d'; + + const mockPlan = { + id: 'plan-001', + name: 'Pro Plan', + price_monthly: 99.99, + }; + + const mockSubscription: Partial = { + id: 'sub-001', + tenant_id: mockTenantId, + status: SubscriptionStatus.ACTIVE, + current_period_end: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000), // 15 days from now + plan: mockPlan as any, + }; + + const mockPaidInvoice: Partial = { + id: 'inv-001', + tenant_id: mockTenantId, + status: InvoiceStatus.PAID, + total: 99.99, + paid_at: new Date(), + }; + + const mockOpenInvoice: Partial = { + id: 'inv-002', + tenant_id: mockTenantId, + status: InvoiceStatus.OPEN, + total: 50.00, + }; + + beforeEach(() => { + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + invoiceRepo.find.mockResolvedValue([]); + }); + + it('should return billing metrics with subscription data', async () => { + invoiceRepo.find + .mockResolvedValueOnce([mockPaidInvoice as Invoice]) // paidInvoices + .mockResolvedValueOnce([]) // prevPaidInvoices + .mockResolvedValueOnce([mockOpenInvoice as Invoice]); // pendingInvoices + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.subscriptionStatus).toBe(SubscriptionStatus.ACTIVE); + expect(result.currentPlan).toBe('Pro Plan'); + expect(result.totalRevenue).toBe(99.99); + expect(result.paidInvoices).toBe(1); + expect(result.pendingInvoices).toBe(1); + expect(result.totalDue).toBe(50); + }); + + it('should calculate revenue trend correctly (positive)', async () => { + invoiceRepo.find + .mockResolvedValueOnce([{ ...mockPaidInvoice, total: 200 } as Invoice]) // current + .mockResolvedValueOnce([{ ...mockPaidInvoice, total: 100 } as Invoice]) // previous + .mockResolvedValueOnce([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + // Revenue trend = ((200 - 100) / 100) * 100 = 100% + expect(result.revenueTrend).toBe(100); + }); + + it('should calculate revenue trend correctly (negative)', async () => { + invoiceRepo.find + .mockResolvedValueOnce([{ ...mockPaidInvoice, total: 50 } as Invoice]) // current + .mockResolvedValueOnce([{ ...mockPaidInvoice, total: 100 } as Invoice]) // previous + .mockResolvedValueOnce([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + // Revenue trend = ((50 - 100) / 100) * 100 = -50% + expect(result.revenueTrend).toBe(-50); + }); + + it('should handle no previous revenue (100% growth)', async () => { + invoiceRepo.find + .mockResolvedValueOnce([{ ...mockPaidInvoice, total: 100 } as Invoice]) // current + .mockResolvedValueOnce([]) // no previous + .mockResolvedValueOnce([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.revenueTrend).toBe(100); + }); + + it('should handle no revenue in both periods (0% growth)', async () => { + invoiceRepo.find + .mockResolvedValueOnce([]) // no current + .mockResolvedValueOnce([]) // no previous + .mockResolvedValueOnce([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.revenueTrend).toBe(0); + }); + + it('should calculate days until expiration correctly', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); + + subscriptionRepo.findOne.mockResolvedValue({ + ...mockSubscription, + current_period_end: futureDate, + } as Subscription); + invoiceRepo.find.mockResolvedValue([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.daysUntilExpiration).toBe(10); + }); + + it('should return 0 days for expired subscription', async () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 5); + + subscriptionRepo.findOne.mockResolvedValue({ + ...mockSubscription, + current_period_end: pastDate, + } as Subscription); + invoiceRepo.find.mockResolvedValue([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.daysUntilExpiration).toBe(0); + }); + + it('should handle no subscription', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + invoiceRepo.find.mockResolvedValue([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.subscriptionStatus).toBe('none'); + expect(result.currentPlan).toBeNull(); + expect(result.daysUntilExpiration).toBe(0); + expect(result.isTrialPeriod).toBe(false); + }); + + it('should detect trial period', async () => { + subscriptionRepo.findOne.mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.TRIAL, + } as Subscription); + invoiceRepo.find.mockResolvedValue([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.isTrialPeriod).toBe(true); + }); + + it('should calculate average invoice amount correctly', async () => { + invoiceRepo.find + .mockResolvedValueOnce([ + { ...mockPaidInvoice, total: 100 } as Invoice, + { ...mockPaidInvoice, total: 200 } as Invoice, + { ...mockPaidInvoice, total: 150 } as Invoice, + ]) // current paid + .mockResolvedValueOnce([]) // previous + .mockResolvedValueOnce([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + // Average = (100 + 200 + 150) / 3 = 150 + expect(result.averageInvoiceAmount).toBe(150); + expect(result.totalRevenue).toBe(450); + }); + + it('should handle zero invoices for average calculation', async () => { + invoiceRepo.find.mockResolvedValue([]); + + const result = await service.getBillingMetrics(mockTenantId, period); + + expect(result.averageInvoiceAmount).toBe(0); + }); + }); + + // ==================== getUsageMetrics Tests ==================== + + describe('getUsageMetrics', () => { + const period: AnalyticsPeriod = '30d'; + + beforeEach(() => { + auditLogRepo.count.mockResolvedValue(0); + }); + + it('should return usage metrics with all counts', async () => { + const qb = createMockQueryBuilder(); + qb.getRawOne.mockResolvedValue({ count: '50' }); + qb.getRawMany + .mockResolvedValueOnce([ + { action: 'CREATE', count: '20' }, + { action: 'UPDATE', count: '30' }, + ]) + .mockResolvedValueOnce([ + { entity_type: 'User', count: '25' }, + { entity_type: 'Invoice', count: '25' }, + ]) + .mockResolvedValueOnce([ + { entityType: 'User', count: '25' }, + { entityType: 'Invoice', count: '25' }, + ]); + + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.count + .mockResolvedValueOnce(100) // totalActions + .mockResolvedValueOnce(15); // loginCount + + const result = await service.getUsageMetrics(mockTenantId, period); + + expect(result.totalActions).toBe(100); + expect(result.activeUsersCount).toBe(50); + expect(result.loginCount).toBe(15); + }); + + it('should handle empty usage data', async () => { + const qb = createMockQueryBuilder(); + qb.getRawOne.mockResolvedValue(null); + qb.getRawMany.mockResolvedValue([]); + + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.count.mockResolvedValue(0); + + const result = await service.getUsageMetrics(mockTenantId, period); + + expect(result.totalActions).toBe(0); + expect(result.activeUsersCount).toBe(0); + expect(result.actionsByType).toEqual({}); + expect(result.actionsByEntity).toEqual({}); + expect(result.topEntities).toEqual([]); + }); + + it('should calculate average actions per user correctly', async () => { + const qb = createMockQueryBuilder(); + qb.getRawOne.mockResolvedValue({ count: '10' }); // 10 active users + qb.getRawMany.mockResolvedValue([]); + + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.count + .mockResolvedValueOnce(100) // totalActions + .mockResolvedValueOnce(20); // loginCount + + const result = await service.getUsageMetrics(mockTenantId, period); + + // Average = 100 / 10 = 10 + expect(result.averageActionsPerUser).toBe(10); + }); + + it('should handle zero active users for average calculation', async () => { + const qb = createMockQueryBuilder(); + qb.getRawOne.mockResolvedValue({ count: '0' }); // 0 active users + qb.getRawMany.mockResolvedValue([]); + + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.count.mockResolvedValue(100); + + const result = await service.getUsageMetrics(mockTenantId, period); + + expect(result.averageActionsPerUser).toBe(0); + }); + + it('should return peak usage hour', async () => { + const qb = createMockQueryBuilder(); + qb.getRawOne + .mockResolvedValueOnce({ count: '50' }) // activeUsers + .mockResolvedValueOnce({ hour: '14', count: '200' }); // peak hour + qb.getRawMany.mockResolvedValue([]); + + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.count.mockResolvedValue(100); + + const result = await service.getUsageMetrics(mockTenantId, period); + + expect(result.peakUsageHour).toBe(14); + }); + + it('should return 0 for peak hour when no data', async () => { + const qb = createMockQueryBuilder(); + qb.getRawOne.mockResolvedValue(null); + qb.getRawMany.mockResolvedValue([]); + + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.count.mockResolvedValue(0); + + const result = await service.getUsageMetrics(mockTenantId, period); + + expect(result.peakUsageHour).toBe(0); + }); + }); + + // ==================== getSummary Tests ==================== + + describe('getSummary', () => { + const mockPlan = { + id: 'plan-001', + name: 'Pro Plan', + price_monthly: 99.99, + }; + + const mockSubscription: Partial = { + id: 'sub-001', + tenant_id: mockTenantId, + status: SubscriptionStatus.ACTIVE, + plan: mockPlan as any, + }; + + beforeEach(() => { + userRepo.count.mockResolvedValue(0); + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + auditLogRepo.count.mockResolvedValue(0); + invoiceRepo.find.mockResolvedValue([]); + }); + + it('should return summary with all KPIs', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // usersLastMonth + .mockResolvedValueOnce(15); // usersThisMonth + + auditLogRepo.count + .mockResolvedValueOnce(500) // actionsThisMonth + .mockResolvedValueOnce(400); // actionsLastMonth + + invoiceRepo.find.mockResolvedValue([ + { total: 50 } as Invoice, + { total: 25 } as Invoice, + ]); + + const result = await service.getSummary(mockTenantId); + + expect(result.totalUsers).toBe(100); + expect(result.activeUsers).toBe(80); + expect(result.mrr).toBe(99.99); + expect(result.subscriptionStatus).toBe(SubscriptionStatus.ACTIVE); + expect(result.actionsThisMonth).toBe(500); + expect(result.pendingAmount).toBe(75); + }); + + it('should calculate user growth correctly', async () => { + userRepo.count + .mockResolvedValueOnce(100) // totalUsers + .mockResolvedValueOnce(80) // activeUsers + .mockResolvedValueOnce(10) // usersLastMonth + .mockResolvedValueOnce(15); // usersThisMonth (+50%) + + auditLogRepo.count.mockResolvedValue(0); + + const result = await service.getSummary(mockTenantId); + + // User growth = ((15 - 10) / 10) * 100 = 50% + expect(result.userGrowth).toBe(50); + }); + + it('should calculate actions growth correctly', async () => { + userRepo.count.mockResolvedValue(100); + auditLogRepo.count + .mockResolvedValueOnce(600) // actionsThisMonth + .mockResolvedValueOnce(400); // actionsLastMonth + + const result = await service.getSummary(mockTenantId); + + // Actions growth = ((600 - 400) / 400) * 100 = 50% + expect(result.actionsGrowth).toBe(50); + }); + + it('should handle no subscription', async () => { + userRepo.count.mockResolvedValue(100); + subscriptionRepo.findOne.mockResolvedValue(null); + auditLogRepo.count.mockResolvedValue(0); + + const result = await service.getSummary(mockTenantId); + + expect(result.mrr).toBe(0); + expect(result.subscriptionStatus).toBe('none'); + }); + + it('should handle zero last month values (100% growth)', async () => { + userRepo.count + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(80) + .mockResolvedValueOnce(0) // usersLastMonth = 0 + .mockResolvedValueOnce(15); // usersThisMonth + + auditLogRepo.count + .mockResolvedValueOnce(100) // actionsThisMonth + .mockResolvedValueOnce(0); // actionsLastMonth = 0 + + const result = await service.getSummary(mockTenantId); + + expect(result.userGrowth).toBe(100); + expect(result.actionsGrowth).toBe(100); + }); + + it('should handle zero values in both periods (0% growth)', async () => { + userRepo.count + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(80) + .mockResolvedValueOnce(0) // usersLastMonth = 0 + .mockResolvedValueOnce(0); // usersThisMonth = 0 + + auditLogRepo.count + .mockResolvedValueOnce(0) // actionsThisMonth = 0 + .mockResolvedValueOnce(0); // actionsLastMonth = 0 + + const result = await service.getSummary(mockTenantId); + + expect(result.userGrowth).toBe(0); + expect(result.actionsGrowth).toBe(0); + }); + }); + + // ==================== getTrends Tests ==================== + + describe('getTrends', () => { + const period: AnalyticsPeriod = '30d'; + + it('should return trends for all metrics', async () => { + const mockTrendData = [ + { date: new Date('2026-01-01'), value: '10' }, + { date: new Date('2026-01-02'), value: '15' }, + ]; + + const qb = createMockQueryBuilder(); + qb.getRawMany.mockResolvedValue(mockTrendData); + + userRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + + const result = await service.getTrends(mockTenantId, period); + + expect(result).toHaveLength(4); + expect(result.map(t => t.metric)).toEqual(['new_users', 'actions', 'logins', 'revenue']); + }); + + it('should handle empty trend data', async () => { + const qb = createMockQueryBuilder(); + qb.getRawMany.mockResolvedValue([]); + + userRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + + const result = await service.getTrends(mockTenantId, period); + + expect(result).toHaveLength(4); + result.forEach(trend => { + expect(trend.data).toEqual([]); + }); + }); + + it('should use day truncation for 7d period', async () => { + const qb = createMockQueryBuilder(); + qb.getRawMany.mockResolvedValue([]); + + userRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + + await service.getTrends(mockTenantId, '7d'); + + expect(userRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should use week truncation for 90d period', async () => { + const qb = createMockQueryBuilder(); + qb.getRawMany.mockResolvedValue([]); + + userRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + + await service.getTrends(mockTenantId, '90d'); + + expect(userRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should use month truncation for 1y period', async () => { + const qb = createMockQueryBuilder(); + qb.getRawMany.mockResolvedValue([]); + + userRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + + await service.getTrends(mockTenantId, '1y'); + + expect(userRepo.createQueryBuilder).toHaveBeenCalled(); + }); + + it('should format dates correctly in trend data', async () => { + const mockDate = new Date('2026-01-15T10:30:00Z'); + const mockTrendData = [{ date: mockDate, value: '10' }]; + + const qb = createMockQueryBuilder(); + qb.getRawMany.mockResolvedValue(mockTrendData); + + userRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + + const result = await service.getTrends(mockTenantId, period); + + expect(result[0].data[0].date).toBe('2026-01-15'); + }); + + it('should handle null revenue values', async () => { + const mockTrendData = [ + { date: new Date('2026-01-01'), value: null }, + ]; + + const qb = createMockQueryBuilder(); + qb.getRawMany.mockResolvedValue(mockTrendData); + + userRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder); + + const result = await service.getTrends(mockTenantId, period); + + // Revenue trend should handle null as 0 + const revenueTrend = result.find(t => t.metric === 'revenue'); + expect(revenueTrend?.data[0].value).toBe(0); + }); + }); + + // ==================== Edge Cases ==================== + + describe('Edge Cases', () => { + it('should handle decimal precision for growth rates', async () => { + userRepo.count + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(80) + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(5) + .mockResolvedValueOnce(5) + .mockResolvedValueOnce(7) // newUsers + .mockResolvedValueOnce(3) // prevNewUsers + .mockResolvedValueOnce(60); + + const result = await service.getUserMetrics(mockTenantId, '30d'); + + // Growth rate = ((7 - 3) / 3) * 100 = 133.33...% + expect(result.growthRate).toBe(133.33); + }); + + it('should handle very large numbers', async () => { + userRepo.count.mockResolvedValue(1000000); + + const result = await service.getUserMetrics(mockTenantId, '30d'); + + expect(result.totalUsers).toBe(1000000); + }); + + it('should handle all periods correctly', async () => { + const periods: AnalyticsPeriod[] = ['7d', '30d', '90d', '1y']; + + for (const period of periods) { + userRepo.count.mockResolvedValue(100); + + const result = await service.getUserMetrics(mockTenantId, period); + + expect(result.totalUsers).toBe(100); + } + }); + }); +});