Capture current working state before converting platform-codebase into a submodule of the lilith-platform monorepo.
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import request from 'supertest';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { ConfigModule } from '@nestjs/config';
|
|
|
|
import { RevenueModule } from '@/modules/revenue';
|
|
import { PnLModule } from '@/modules/pnl';
|
|
import { CostsModule } from '@/modules/costs';
|
|
import { PerformanceModule } from '@/modules/performance';
|
|
import { RealtimeModule } from '@/modules/realtime';
|
|
import {
|
|
Transaction,
|
|
TransactionType,
|
|
CostEntry,
|
|
CostType,
|
|
CostCategory,
|
|
ApiRequestMetric,
|
|
EngagementMetric,
|
|
} from '@/entities';
|
|
import {
|
|
createMockRepository,
|
|
createMockQueryBuilder,
|
|
type MockRepository,
|
|
type MockQueryBuilder,
|
|
} from './mocks';
|
|
import {
|
|
createTransactionAggregateResult,
|
|
createCostAggregateResult,
|
|
createPerformanceAggregateResult,
|
|
createTrendPoints,
|
|
} from './fixtures';
|
|
|
|
// Mock service registry
|
|
vi.mock('@lilith/service-registry', () => ({
|
|
buildDeploymentRegistry: () => ({
|
|
services: new Map(),
|
|
}),
|
|
}));
|
|
|
|
describe('Analytics API (E2E)', () => {
|
|
let app: INestApplication;
|
|
let transactionRepo: MockRepository<Transaction>;
|
|
let costRepo: MockRepository<CostEntry>;
|
|
let metricRepo: MockRepository<ApiRequestMetric>;
|
|
let engagementRepo: MockRepository<EngagementMetric>;
|
|
let transactionQb: MockQueryBuilder;
|
|
let costQb: MockQueryBuilder;
|
|
let metricQb: MockQueryBuilder;
|
|
let engagementQb: MockQueryBuilder;
|
|
|
|
beforeAll(async () => {
|
|
transactionRepo = createMockRepository<Transaction>();
|
|
costRepo = createMockRepository<CostEntry>();
|
|
metricRepo = createMockRepository<ApiRequestMetric>();
|
|
engagementRepo = createMockRepository<EngagementMetric>();
|
|
|
|
transactionQb = createMockQueryBuilder();
|
|
costQb = createMockQueryBuilder();
|
|
metricQb = createMockQueryBuilder();
|
|
engagementQb = createMockQueryBuilder();
|
|
|
|
transactionRepo.createQueryBuilder.mockReturnValue(transactionQb);
|
|
costRepo.createQueryBuilder.mockReturnValue(costQb);
|
|
metricRepo.createQueryBuilder.mockReturnValue(metricQb);
|
|
engagementRepo.createQueryBuilder.mockReturnValue(engagementQb);
|
|
|
|
// Setup default mock responses
|
|
transactionQb.getRawOne.mockResolvedValue(createTransactionAggregateResult());
|
|
transactionQb.getRawMany.mockResolvedValue([]);
|
|
costQb.getRawOne.mockResolvedValue(createCostAggregateResult());
|
|
costQb.getRawMany.mockResolvedValue([]);
|
|
metricQb.getRawOne.mockResolvedValue(createPerformanceAggregateResult());
|
|
metricQb.getRawMany.mockResolvedValue([]);
|
|
engagementQb.getRawOne.mockResolvedValue({ count: '100' });
|
|
engagementQb.getRawMany.mockResolvedValue([]);
|
|
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [
|
|
ConfigModule.forRoot({
|
|
isGlobal: true,
|
|
ignoreEnvFile: true,
|
|
load: [() => ({ REDIS_URL: '' })],
|
|
}),
|
|
RevenueModule,
|
|
PnLModule,
|
|
CostsModule,
|
|
PerformanceModule,
|
|
RealtimeModule,
|
|
],
|
|
})
|
|
.overrideProvider(getRepositoryToken(Transaction))
|
|
.useValue(transactionRepo)
|
|
.overrideProvider(getRepositoryToken(CostEntry))
|
|
.useValue(costRepo)
|
|
.overrideProvider(getRepositoryToken(ApiRequestMetric))
|
|
.useValue(metricRepo)
|
|
.overrideProvider(getRepositoryToken(EngagementMetric))
|
|
.useValue(engagementRepo)
|
|
.compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
|
await app.init();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Reset mock responses
|
|
transactionQb.getRawOne.mockResolvedValue(createTransactionAggregateResult());
|
|
transactionQb.getRawMany.mockResolvedValue([]);
|
|
costQb.getRawOne.mockResolvedValue(createCostAggregateResult());
|
|
costQb.getRawMany.mockResolvedValue([]);
|
|
metricQb.getRawOne.mockResolvedValue(createPerformanceAggregateResult());
|
|
metricQb.getRawMany.mockResolvedValue([]);
|
|
engagementQb.getRawOne.mockResolvedValue({ count: '100' });
|
|
engagementQb.getRawMany.mockResolvedValue([]);
|
|
});
|
|
|
|
describe('Revenue Endpoints', () => {
|
|
describe('GET /revenue/metrics', () => {
|
|
it('should return revenue metrics with default period', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/revenue/metrics')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('totalRevenue');
|
|
expect(response.body).toHaveProperty('recurringRevenue');
|
|
expect(response.body).toHaveProperty('oneTimeRevenue');
|
|
expect(response.body).toHaveProperty('averageTransactionValue');
|
|
expect(response.body).toHaveProperty('transactionCount');
|
|
expect(response.body).toHaveProperty('growthRate');
|
|
expect(response.body).toHaveProperty('cryptoRevenue');
|
|
expect(response.body).toHaveProperty('avgRevenuePerUser');
|
|
});
|
|
|
|
it('should accept period query parameter', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/revenue/metrics?period=7d')
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.get('/revenue/metrics?period=90d')
|
|
.expect(200);
|
|
});
|
|
|
|
it('should return numeric values', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/revenue/metrics')
|
|
.expect(200);
|
|
|
|
expect(typeof response.body.totalRevenue).toBe('number');
|
|
expect(typeof response.body.transactionCount).toBe('number');
|
|
expect(typeof response.body.growthRate).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('GET /revenue/trend', () => {
|
|
it('should return trend array', async () => {
|
|
transactionQb.getRawMany.mockResolvedValue(createTrendPoints(5, 'revenue'));
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/revenue/trend')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
});
|
|
|
|
it('should return trend point with correct shape', async () => {
|
|
transactionQb.getRawMany.mockResolvedValue(createTrendPoints(1, 'revenue'));
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/revenue/trend?period=30d')
|
|
.expect(200);
|
|
|
|
if (response.body.length > 0) {
|
|
expect(response.body[0]).toHaveProperty('date');
|
|
expect(response.body[0]).toHaveProperty('amount');
|
|
expect(response.body[0]).toHaveProperty('transactionCount');
|
|
expect(response.body[0]).toHaveProperty('recurring');
|
|
expect(response.body[0]).toHaveProperty('oneTime');
|
|
expect(response.body[0]).toHaveProperty('crypto');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('GET /revenue/breakdown', () => {
|
|
it('should return breakdown by type and source', async () => {
|
|
const byTypeData = [
|
|
{ type: TransactionType.SUBSCRIPTION, amount: '6000.00' },
|
|
];
|
|
const bySourceData = [{ source: 'web', amount: '7000.00' }];
|
|
|
|
let callCount = 0;
|
|
transactionQb.getRawMany.mockImplementation(() => {
|
|
callCount++;
|
|
if (callCount === 1) return Promise.resolve(byTypeData);
|
|
return Promise.resolve(bySourceData);
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/revenue/breakdown')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('byType');
|
|
expect(response.body).toHaveProperty('bySource');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('P&L Endpoints', () => {
|
|
describe('GET /pnl/statement', () => {
|
|
it('should return complete P&L statement', async () => {
|
|
const revenueByType = [
|
|
{ type: TransactionType.SUBSCRIPTION, amount: '5000.00' },
|
|
];
|
|
const costData = [
|
|
{ category: 'INFRASTRUCTURE', costType: CostType.FIXED, amount: '1000.00' },
|
|
];
|
|
|
|
transactionQb.getRawMany.mockResolvedValue(revenueByType);
|
|
transactionQb.getRawOne.mockResolvedValue({ amount: '100.00' });
|
|
costQb.getRawMany.mockResolvedValue(costData);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/pnl/statement')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('revenue');
|
|
expect(response.body).toHaveProperty('costs');
|
|
expect(response.body).toHaveProperty('grossProfit');
|
|
expect(response.body).toHaveProperty('operatingExpenses');
|
|
expect(response.body).toHaveProperty('ebitda');
|
|
expect(response.body).toHaveProperty('netIncome');
|
|
expect(response.body).toHaveProperty('margins');
|
|
expect(response.body).toHaveProperty('breakdown');
|
|
});
|
|
|
|
it('should accept period query parameter', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/pnl/statement?period=90d')
|
|
.expect(200);
|
|
});
|
|
});
|
|
|
|
describe('GET /pnl/trend', () => {
|
|
it('should return P&L trend array', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/pnl/trend')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /pnl/reserve', () => {
|
|
it('should return reserve progress', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/pnl/reserve')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('current');
|
|
expect(response.body).toHaveProperty('goal');
|
|
expect(response.body).toHaveProperty('progress');
|
|
expect(response.body).toHaveProperty('monthlyContribution');
|
|
expect(response.body).toHaveProperty('projectedDate');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Costs Endpoints', () => {
|
|
describe('GET /costs/metrics', () => {
|
|
it('should return cost metrics', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/costs/metrics')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('totalCosts');
|
|
expect(response.body).toHaveProperty('fixedCosts');
|
|
expect(response.body).toHaveProperty('variableCosts');
|
|
expect(response.body).toHaveProperty('cogs');
|
|
expect(response.body).toHaveProperty('averageMonthlyCost');
|
|
});
|
|
});
|
|
|
|
describe('GET /costs/breakdown', () => {
|
|
it('should return cost breakdown', async () => {
|
|
const byCategoryData = [
|
|
{ category: CostCategory.INFRASTRUCTURE, amount: '2000.00' },
|
|
];
|
|
const byTypeData = [{ type: CostType.FIXED, amount: '2000.00' }];
|
|
|
|
let callCount = 0;
|
|
costQb.getRawMany.mockImplementation(() => {
|
|
callCount++;
|
|
if (callCount === 1) return Promise.resolve(byCategoryData);
|
|
return Promise.resolve(byTypeData);
|
|
});
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/costs/breakdown')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('byCategory');
|
|
expect(response.body).toHaveProperty('byType');
|
|
expect(Array.isArray(response.body.byCategory)).toBe(true);
|
|
expect(Array.isArray(response.body.byType)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /costs/trend', () => {
|
|
it('should return cost trend array', async () => {
|
|
costQb.getRawMany.mockResolvedValue(createTrendPoints(5, 'cost'));
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/costs/trend')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /costs/budget', () => {
|
|
it('should return budget comparison', async () => {
|
|
const budgetData = [
|
|
{ category: CostCategory.INFRASTRUCTURE, actual: '1800.00', budgeted: '2000.00' },
|
|
];
|
|
costQb.getRawMany.mockResolvedValue(budgetData);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/costs/budget')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('budgeted');
|
|
expect(response.body).toHaveProperty('actual');
|
|
expect(response.body).toHaveProperty('variance');
|
|
expect(response.body).toHaveProperty('variancePercentage');
|
|
expect(response.body).toHaveProperty('byCategory');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Performance Endpoints', () => {
|
|
describe('GET /performance/metrics', () => {
|
|
it('should return performance metrics', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/performance/metrics')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('avgResponseTime');
|
|
expect(response.body).toHaveProperty('p50ResponseTime');
|
|
expect(response.body).toHaveProperty('p95ResponseTime');
|
|
expect(response.body).toHaveProperty('p99ResponseTime');
|
|
expect(response.body).toHaveProperty('requestsPerSecond');
|
|
expect(response.body).toHaveProperty('errorRate');
|
|
expect(response.body).toHaveProperty('uptime');
|
|
expect(response.body).toHaveProperty('totalRequests');
|
|
expect(response.body).toHaveProperty('totalErrors');
|
|
});
|
|
});
|
|
|
|
describe('GET /performance/trend', () => {
|
|
it('should return performance trend array', async () => {
|
|
metricQb.getRawMany.mockResolvedValue(createTrendPoints(5, 'performance'));
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/performance/trend')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('GET /performance/endpoints', () => {
|
|
it('should return endpoint performance list', async () => {
|
|
const endpointData = [
|
|
{
|
|
endpoint: '/api/v1/users',
|
|
method: 'GET',
|
|
avgResponseTime: '45.00',
|
|
p95ResponseTime: '120.00',
|
|
requestCount: '5000',
|
|
errorCount: '50',
|
|
},
|
|
];
|
|
metricQb.getRawMany.mockResolvedValue(endpointData);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/performance/endpoints')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
if (response.body.length > 0) {
|
|
expect(response.body[0]).toHaveProperty('endpoint');
|
|
expect(response.body[0]).toHaveProperty('method');
|
|
expect(response.body[0]).toHaveProperty('avgResponseTime');
|
|
expect(response.body[0]).toHaveProperty('errorRate');
|
|
}
|
|
});
|
|
|
|
it('should accept limit and sortBy parameters', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/performance/endpoints?limit=10&sortBy=error_rate')
|
|
.expect(200);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Realtime Endpoints', () => {
|
|
describe('GET /realtime/metrics', () => {
|
|
it('should return realtime metrics snapshot', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/realtime/metrics')
|
|
.expect(200);
|
|
|
|
expect(response.body).toHaveProperty('activeUsers');
|
|
expect(response.body).toHaveProperty('activeCreators');
|
|
expect(response.body).toHaveProperty('liveTransactions');
|
|
expect(response.body).toHaveProperty('revenuePerMinute');
|
|
expect(response.body).toHaveProperty('systemLoad');
|
|
expect(response.body).toHaveProperty('responseTime');
|
|
expect(response.body).toHaveProperty('requestsPerSecond');
|
|
expect(response.body).toHaveProperty('errorRate');
|
|
expect(response.body).toHaveProperty('timestamp');
|
|
});
|
|
});
|
|
|
|
describe('GET /realtime/activity', () => {
|
|
it('should return activity feed', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.get('/realtime/activity')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
});
|
|
|
|
it('should accept limit and type parameters', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/realtime/activity?limit=20&type=transaction')
|
|
.expect(200);
|
|
});
|
|
});
|
|
|
|
describe('GET /realtime/active-users', () => {
|
|
it('should return active users time series', async () => {
|
|
engagementQb.getRawMany.mockResolvedValue([
|
|
{ minute: '2026-01-15T12:00:00Z', count: '50' },
|
|
]);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get('/realtime/active-users')
|
|
.expect(200);
|
|
|
|
expect(Array.isArray(response.body)).toBe(true);
|
|
});
|
|
|
|
it('should accept minutes parameter', async () => {
|
|
await request(app.getHttpServer())
|
|
.get('/realtime/active-users?minutes=30')
|
|
.expect(200);
|
|
});
|
|
});
|
|
});
|
|
});
|