[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:
parent
2ffe4864dd
commit
56b03ae509
295
src/modules/analytics/__tests__/analytics.controller.spec.ts
Normal file
295
src/modules/analytics/__tests__/analytics.controller.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
802
src/modules/analytics/__tests__/analytics.service.spec.ts
Normal file
802
src/modules/analytics/__tests__/analytics.service.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user