[TS-BACKEND] test: Add comprehensive analytics module test suite

Add unit tests for analytics module achieving 100% coverage:
- analytics.service.spec.ts: 43 tests covering all 5 public methods
  - getUserMetrics: growth rate, retention rate, edge cases
  - getBillingMetrics: revenue trends, subscription status, trial detection
  - getUsageMetrics: action counts, peak hours, averages
  - getSummary: KPIs, growth calculations
  - getTrends: date truncation, empty data handling
- analytics.controller.spec.ts: 15 tests for all endpoints
  - Period parameter handling (7d, 30d, 90d, 1y)
  - Multi-tenant isolation validation

Part of Fase 4: Test coverage improvement (80.89% overall)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 17:17:54 -06:00
parent 2ffe4864dd
commit 56b03ae509
2 changed files with 1097 additions and 0 deletions

View File

@ -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<AnalyticsService>;
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>(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');
});
});
});

View File

@ -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<Repository<User>>;
let subscriptionRepo: jest.Mocked<Repository<Subscription>>;
let invoiceRepo: jest.Mocked<Repository<Invoice>>;
let auditLogRepo: jest.Mocked<Repository<AuditLog>>;
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>(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<Subscription> = {
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<Invoice> = {
id: 'inv-001',
tenant_id: mockTenantId,
status: InvoiceStatus.PAID,
total: 99.99,
paid_at: new Date(),
};
const mockOpenInvoice: Partial<Invoice> = {
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<AuditLog>);
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<AuditLog>);
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<AuditLog>);
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<AuditLog>);
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<AuditLog>);
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<AuditLog>);
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<Subscription> = {
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<User>);
auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<AuditLog>);
invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<Invoice>);
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<User>);
auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<AuditLog>);
invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<Invoice>);
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<User>);
auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<AuditLog>);
invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<Invoice>);
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<User>);
auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<AuditLog>);
invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<Invoice>);
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<User>);
auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<AuditLog>);
invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<Invoice>);
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<User>);
auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<AuditLog>);
invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<Invoice>);
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<User>);
auditLogRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<AuditLog>);
invoiceRepo.createQueryBuilder.mockReturnValue(qb as unknown as SelectQueryBuilder<Invoice>);
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);
}
});
});
});