analytics/services/api/test/analytics-api.e2e-spec.ts
2026-01-29 08:20:57 -08:00

1350 lines
42 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppModule } from '../src/app.module';
import { Segment } from '../src/segments/segment.entity';
import { AggregatedMetric } from '../src/entities/aggregated-metric.entity';
import {
createMockDataSource,
createMockRepository,
type MockDataSource,
type MockRepository,
} from './mocks';
import {
createAcquisitionOverviewResult,
createChannelResults,
createSourceResults,
createCampaignResults,
createReferrerResults,
createEngagementOverviewResult,
createUniqueViewsResult,
createPageResults,
createEventResults,
createScrollDepthResults,
createUserFlowResults,
createAudienceOverviewResult,
createDemographicsResults,
createDeviceResults,
createBrowserResults,
createOSResults,
createGeoResults,
createLanguageResults,
createNewVsReturningResults,
createSessionListResults,
createSessionCountResult,
createSessionMetricsResult,
createSegmentMetricsResult,
createAggregatedMetricSeries,
} from './fixtures/aggregated-metric.fixture';
import { SegmentOperator, ConditionOperator } from '../src/segments/dto/segment.dto';
describe('Analytics API (E2E)', () => {
let app: INestApplication;
let mockDataSource: MockDataSource;
let mockSegmentRepo: MockRepository<Segment>;
let mockMetricRepo: MockRepository<AggregatedMetric>;
beforeAll(async () => {
mockDataSource = createMockDataSource();
mockSegmentRepo = createMockRepository<Segment>();
mockMetricRepo = createMockRepository<AggregatedMetric>();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(DataSource)
.useValue(mockDataSource)
.overrideProvider(getRepositoryToken(Segment))
.useValue(mockSegmentRepo)
.overrideProvider(getRepositoryToken(AggregatedMetric))
.useValue(mockMetricRepo)
.compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('Health', () => {
it('should return health status', async () => {
const response = await request(app.getHttpServer())
.get('/health')
.expect(200);
expect(response.body).toHaveProperty('status');
});
});
describe('Acquisition', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('GET /acquisition/overview', () => {
it('should return acquisition overview', async () => {
mockDataSource.query.mockResolvedValue(createAcquisitionOverviewResult());
const response = await request(app.getHttpServer())
.get('/acquisition/overview')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(response.body).toHaveProperty('totalSessions');
expect(response.body).toHaveProperty('totalUsers');
expect(response.body).toHaveProperty('newUsers');
expect(response.body).toHaveProperty('returningUsers');
});
it('should reject missing startDate', async () => {
await request(app.getHttpServer())
.get('/acquisition/overview')
.query({ endDate: '2026-01-28' })
.expect(400);
});
it('should reject missing endDate', async () => {
await request(app.getHttpServer())
.get('/acquisition/overview')
.query({ startDate: '2026-01-01' })
.expect(400);
});
it('should reject invalid date format', async () => {
await request(app.getHttpServer())
.get('/acquisition/overview')
.query({ startDate: 'invalid', endDate: '2026-01-28' })
.expect(400);
});
});
describe('GET /acquisition/channels', () => {
it('should return channel breakdown', async () => {
mockDataSource.query.mockResolvedValue(createChannelResults());
const response = await request(app.getHttpServer())
.get('/acquisition/channels')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toHaveProperty('channel');
expect(response.body[0]).toHaveProperty('sessions');
expect(response.body[0]).toHaveProperty('users');
});
});
describe('GET /acquisition/sources', () => {
it('should return source breakdown', async () => {
mockDataSource.query.mockResolvedValue(createSourceResults());
const response = await request(app.getHttpServer())
.get('/acquisition/sources')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
expect(response.body[0]).toHaveProperty('source');
expect(response.body[0]).toHaveProperty('medium');
});
it('should filter by channel', async () => {
mockDataSource.query.mockResolvedValue(createSourceResults());
const response = await request(app.getHttpServer())
.get('/acquisition/sources')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
channel: 'organic',
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('GET /acquisition/campaigns', () => {
it('should return campaign metrics', async () => {
mockDataSource.query.mockResolvedValue(createCampaignResults());
const response = await request(app.getHttpServer())
.get('/acquisition/campaigns')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('campaign');
expect(response.body[0]).toHaveProperty('source');
expect(response.body[0]).toHaveProperty('medium');
}
});
});
describe('GET /acquisition/referrers', () => {
it('should return top referrers', async () => {
mockDataSource.query.mockResolvedValue(createReferrerResults());
const response = await request(app.getHttpServer())
.get('/acquisition/referrers')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should respect limit parameter', async () => {
mockDataSource.query.mockResolvedValue(createReferrerResults());
const response = await request(app.getHttpServer())
.get('/acquisition/referrers')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
limit: 5,
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('GET /acquisition/compare', () => {
it('should compare two periods', async () => {
mockDataSource.query
.mockResolvedValueOnce(createAcquisitionOverviewResult())
.mockResolvedValueOnce(createAcquisitionOverviewResult({
total_sessions: '1200',
total_users: '750',
}));
const response = await request(app.getHttpServer())
.get('/acquisition/compare')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
compareStartDate: '2025-12-01',
compareEndDate: '2025-12-28',
})
.expect(200);
expect(response.body).toHaveProperty('current');
expect(response.body).toHaveProperty('previous');
expect(response.body).toHaveProperty('change');
});
it('should reject missing compare dates', async () => {
await request(app.getHttpServer())
.get('/acquisition/compare')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
})
.expect(400);
});
});
});
describe('Engagement', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('GET /engagement/overview', () => {
it('should return engagement overview', async () => {
mockDataSource.query.mockResolvedValue(createEngagementOverviewResult());
const response = await request(app.getHttpServer())
.get('/engagement/overview')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(response.body).toHaveProperty('totalSessions');
expect(response.body).toHaveProperty('engagedSessions');
expect(response.body).toHaveProperty('avgDuration');
});
});
describe('GET /engagement/pages', () => {
it('should return page metrics', async () => {
mockDataSource.query
.mockResolvedValueOnce(createPageResults())
.mockResolvedValueOnce(createUniqueViewsResult());
const response = await request(app.getHttpServer())
.get('/engagement/pages')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('path');
expect(response.body[0]).toHaveProperty('views');
}
});
it('should accept sortBy parameter', async () => {
mockDataSource.query
.mockResolvedValueOnce(createPageResults())
.mockResolvedValueOnce(createUniqueViewsResult());
await request(app.getHttpServer())
.get('/engagement/pages')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
sortBy: 'views',
})
.expect(200);
});
});
describe('GET /engagement/events', () => {
it('should return event metrics', async () => {
mockDataSource.query.mockResolvedValue(createEventResults());
const response = await request(app.getHttpServer())
.get('/engagement/events')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should filter by category', async () => {
mockDataSource.query.mockResolvedValue(createEventResults());
await request(app.getHttpServer())
.get('/engagement/events')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
category: 'click',
})
.expect(200);
});
});
describe('GET /engagement/scroll-depth', () => {
it('should return scroll depth metrics', async () => {
mockDataSource.query.mockResolvedValue(createScrollDepthResults());
const response = await request(app.getHttpServer())
.get('/engagement/scroll-depth')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should filter by page', async () => {
mockDataSource.query.mockResolvedValue(createScrollDepthResults());
await request(app.getHttpServer())
.get('/engagement/scroll-depth')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
page: '/',
})
.expect(200);
});
});
describe('GET /engagement/user-flow', () => {
it('should return user flow data', async () => {
mockDataSource.query.mockResolvedValue(createUserFlowResults());
const response = await request(app.getHttpServer())
.get('/engagement/user-flow')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should filter by start page', async () => {
mockDataSource.query.mockResolvedValue(createUserFlowResults());
await request(app.getHttpServer())
.get('/engagement/user-flow')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
startPage: '/',
})
.expect(200);
});
});
});
describe('Audience', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('GET /audience/overview', () => {
it('should return audience overview', async () => {
mockDataSource.query.mockResolvedValue(createAudienceOverviewResult());
const response = await request(app.getHttpServer())
.get('/audience/overview')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(response.body).toHaveProperty('totalUsers');
expect(response.body).toHaveProperty('newUsers');
expect(response.body).toHaveProperty('returningUsers');
});
});
describe('GET /audience/demographics', () => {
it('should return demographic breakdown', async () => {
mockDataSource.query.mockResolvedValue(createDemographicsResults());
const response = await request(app.getHttpServer())
.get('/audience/demographics')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('GET /audience/devices', () => {
it('should return device breakdown', async () => {
mockDataSource.query.mockResolvedValue(createDeviceResults());
const response = await request(app.getHttpServer())
.get('/audience/devices')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('deviceType');
}
});
});
describe('GET /audience/browsers', () => {
it('should return browser breakdown', async () => {
mockDataSource.query.mockResolvedValue(createBrowserResults());
const response = await request(app.getHttpServer())
.get('/audience/browsers')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('browser');
}
});
});
describe('GET /audience/operating-systems', () => {
it('should return OS breakdown', async () => {
mockDataSource.query.mockResolvedValue(createOSResults());
const response = await request(app.getHttpServer())
.get('/audience/operating-systems')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('os');
}
});
});
describe('GET /audience/geography', () => {
it('should return geographic breakdown', async () => {
mockDataSource.query.mockResolvedValue(createGeoResults());
const response = await request(app.getHttpServer())
.get('/audience/geography')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should accept granularity parameter', async () => {
mockDataSource.query.mockResolvedValue(createGeoResults());
await request(app.getHttpServer())
.get('/audience/geography')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
granularity: 'country',
})
.expect(200);
});
});
describe('GET /audience/languages', () => {
it('should return language breakdown', async () => {
mockDataSource.query.mockResolvedValue(createLanguageResults());
const response = await request(app.getHttpServer())
.get('/audience/languages')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('GET /audience/new-vs-returning', () => {
it('should return new vs returning user trend', async () => {
mockDataSource.query.mockResolvedValue(createNewVsReturningResults());
const response = await request(app.getHttpServer())
.get('/audience/new-vs-returning')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
});
describe('Sessions', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('GET /sessions', () => {
it('should return paginated session list', async () => {
mockDataSource.query
.mockResolvedValueOnce(createSessionListResults())
.mockResolvedValueOnce(createSessionCountResult(150));
const response = await request(app.getHttpServer())
.get('/sessions')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
limit: 10,
offset: 0,
})
.expect(200);
expect(response.body).toHaveProperty('sessions');
expect(response.body).toHaveProperty('total');
expect(Array.isArray(response.body.sessions)).toBe(true);
});
it('should accept pagination parameters', async () => {
mockDataSource.query
.mockResolvedValueOnce(createSessionListResults())
.mockResolvedValueOnce(createSessionCountResult(150));
await request(app.getHttpServer())
.get('/sessions')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
limit: 20,
offset: 40,
})
.expect(200);
});
});
describe('GET /sessions/metrics', () => {
it('should return session metrics', async () => {
mockDataSource.query.mockResolvedValue(createSessionMetricsResult());
const response = await request(app.getHttpServer())
.get('/sessions/metrics')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(response.body).toHaveProperty('totalSessions');
expect(response.body).toHaveProperty('avgDuration');
expect(response.body).toHaveProperty('bounceRate');
});
});
describe('GET /sessions/:sessionId', () => {
it('should return single session details', async () => {
mockDataSource.query.mockResolvedValue(createSessionListResults().slice(0, 1));
const response = await request(app.getHttpServer())
.get('/sessions/sess-001')
.expect(200);
expect(response.body).toHaveProperty('sessionId');
});
it('should return 404 for non-existent session', async () => {
mockDataSource.query.mockResolvedValue([]);
await request(app.getHttpServer())
.get('/sessions/non-existent-id')
.expect(404);
});
});
});
describe('Segments', () => {
beforeEach(() => {
mockSegmentRepo.find.mockReset();
mockSegmentRepo.findOne.mockReset();
mockSegmentRepo.findOneBy.mockReset();
mockSegmentRepo.save.mockReset();
mockSegmentRepo.create.mockReset();
mockSegmentRepo.remove.mockReset();
mockDataSource.query.mockReset();
});
const mockSegment: Segment = {
id: 'segment-001',
name: 'Desktop Users',
description: 'Users on desktop devices',
conditions: [
{
dimension: 'deviceType',
operator: ConditionOperator.EQUALS,
values: ['desktop'],
},
],
operator: SegmentOperator.AND,
isBuiltIn: false,
createdBy: null,
createdAt: new Date('2026-01-15T10:00:00Z'),
updatedAt: new Date('2026-01-15T10:00:00Z'),
};
describe('GET /segments', () => {
it('should return all segments', async () => {
mockSegmentRepo.find.mockResolvedValue([mockSegment]);
const response = await request(app.getHttpServer())
.get('/segments')
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('id');
expect(response.body[0]).toHaveProperty('name');
expect(response.body[0]).toHaveProperty('conditions');
}
});
});
describe('GET /segments/:id', () => {
it('should return single segment', async () => {
mockSegmentRepo.findOneBy.mockResolvedValue(mockSegment);
const response = await request(app.getHttpServer())
.get('/segments/segment-001')
.expect(200);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('name');
});
it('should return 404 for non-existent segment', async () => {
mockSegmentRepo.findOneBy.mockResolvedValue(null);
await request(app.getHttpServer())
.get('/segments/non-existent')
.expect(404);
});
});
describe('POST /segments', () => {
it('should create new segment', async () => {
const newSegment = {
name: 'Mobile Users',
description: 'Users on mobile devices',
conditions: [
{
dimension: 'deviceType',
operator: ConditionOperator.EQUALS,
values: ['mobile'],
},
],
operator: SegmentOperator.AND,
};
mockSegmentRepo.create.mockReturnValue(mockSegment);
mockSegmentRepo.save.mockResolvedValue(mockSegment);
const response = await request(app.getHttpServer())
.post('/segments')
.send(newSegment)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('name');
});
it('should reject invalid segment data', async () => {
await request(app.getHttpServer())
.post('/segments')
.send({ name: 'Missing conditions' })
.expect(400);
});
it('should reject invalid condition operator', async () => {
await request(app.getHttpServer())
.post('/segments')
.send({
name: 'Test',
conditions: [
{
dimension: 'deviceType',
operator: 'invalid_operator',
values: ['mobile'],
},
],
})
.expect(400);
});
});
describe('PUT /segments/:id', () => {
it('should update segment', async () => {
const updatedSegment = { ...mockSegment, name: 'Updated Name' };
mockSegmentRepo.findOneBy.mockResolvedValue(mockSegment);
mockSegmentRepo.save.mockResolvedValue(updatedSegment);
const response = await request(app.getHttpServer())
.put('/segments/segment-001')
.send({ name: 'Updated Name' })
.expect(200);
expect(response.body).toHaveProperty('name', 'Updated Name');
});
it('should return 404 for non-existent segment', async () => {
mockSegmentRepo.findOneBy.mockResolvedValue(null);
await request(app.getHttpServer())
.put('/segments/non-existent')
.send({ name: 'Updated' })
.expect(404);
});
});
describe('DELETE /segments/:id', () => {
it('should delete segment', async () => {
mockSegmentRepo.findOneBy.mockResolvedValue(mockSegment);
mockSegmentRepo.remove.mockResolvedValue(mockSegment);
await request(app.getHttpServer())
.delete('/segments/segment-001')
.expect(200);
});
it('should return 404 for non-existent segment', async () => {
mockSegmentRepo.findOneBy.mockResolvedValue(null);
await request(app.getHttpServer())
.delete('/segments/non-existent')
.expect(404);
});
});
describe('GET /segments/:id/apply', () => {
it('should apply segment and return metrics', async () => {
mockSegmentRepo.findOneBy.mockResolvedValue(mockSegment);
mockDataSource.query.mockResolvedValue(createSegmentMetricsResult());
const response = await request(app.getHttpServer())
.get('/segments/segment-001/apply')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(response.body).toHaveProperty('sessions');
expect(response.body).toHaveProperty('users');
});
it('should reject missing date range', async () => {
await request(app.getHttpServer())
.get('/segments/segment-001/apply')
.expect(400);
});
});
describe('GET /segments/compare', () => {
it('should compare multiple segments', async () => {
mockSegmentRepo.findOneBy
.mockResolvedValueOnce(mockSegment)
.mockResolvedValueOnce({ ...mockSegment, id: 'segment-002' });
mockDataSource.query
.mockResolvedValueOnce(createSegmentMetricsResult())
.mockResolvedValueOnce(createSegmentMetricsResult());
const response = await request(app.getHttpServer())
.get('/segments/compare')
.query({
segmentIds: ['segment-001', 'segment-002'],
startDate: '2026-01-01',
endDate: '2026-01-28',
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should reject empty segment IDs', async () => {
await request(app.getHttpServer())
.get('/segments/compare')
.query({
segmentIds: [],
startDate: '2026-01-01',
endDate: '2026-01-28',
})
.expect(400);
});
});
});
describe('Trends', () => {
beforeEach(() => {
mockMetricRepo.queryBuilder.getRawMany.mockReset();
});
describe('GET /trends', () => {
it('should return trend data', async () => {
mockMetricRepo.queryBuilder.getRawMany.mockResolvedValue(
createAggregatedMetricSeries(7).map(m => ({
period: m.timestamp,
value: String(m.value),
})),
);
const response = await request(app.getHttpServer())
.get('/trends')
.query({
metric: 'page_views',
startDate: '2026-01-15',
endDate: '2026-01-22',
granularity: 'day',
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('period');
expect(response.body[0]).toHaveProperty('value');
}
});
it('should reject invalid metric', async () => {
await request(app.getHttpServer())
.get('/trends')
.query({
metric: 'invalid_metric',
startDate: '2026-01-15',
endDate: '2026-01-22',
granularity: 'day',
})
.expect(400);
});
it('should reject missing required params', async () => {
await request(app.getHttpServer())
.get('/trends')
.query({ metric: 'page_views' })
.expect(400);
});
});
describe('GET /trends/compare', () => {
it('should compare trends between periods', async () => {
mockMetricRepo.queryBuilder.getRawMany
.mockResolvedValueOnce(
createAggregatedMetricSeries(7).map(m => ({
period: m.timestamp,
value: String(m.value),
})),
)
.mockResolvedValueOnce(
createAggregatedMetricSeries(7).map(m => ({
period: m.timestamp,
value: String(m.value - 10),
})),
);
const response = await request(app.getHttpServer())
.get('/trends/compare')
.query({
metric: 'page_views',
startDate: '2026-01-15',
endDate: '2026-01-22',
compareStartDate: '2026-01-08',
compareEndDate: '2026-01-15',
granularity: 'day',
})
.expect(200);
expect(response.body).toHaveProperty('current');
expect(response.body).toHaveProperty('previous');
expect(Array.isArray(response.body.current)).toBe(true);
expect(Array.isArray(response.body.previous)).toBe(true);
});
});
});
describe('Funnels', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('POST /funnels/analyze', () => {
it('should analyze funnel conversion', async () => {
mockDataSource.query.mockResolvedValue([
{ step: 1, step_name: 'Landing', users: '1000', conversion_rate: '1.00' },
{ step: 2, step_name: 'Profile', users: '600', conversion_rate: '0.60' },
{ step: 3, step_name: 'Contact', users: '200', conversion_rate: '0.20' },
]);
const response = await request(app.getHttpServer())
.post('/funnels/analyze')
.send({
steps: [
{ name: 'Landing', eventType: 'pageview', filter: { pageUrl: '/' } },
{ name: 'Profile', eventType: 'pageview', filter: { pageUrl: '/profiles' } },
{ name: 'Contact', eventType: 'click', filter: { metadata: { action: 'contact' } } },
],
startDate: '2026-01-01',
endDate: '2026-01-28',
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
if (response.body.length > 0) {
expect(response.body[0]).toHaveProperty('step');
expect(response.body[0]).toHaveProperty('stepName');
expect(response.body[0]).toHaveProperty('users');
}
});
it('should reject invalid funnel steps', async () => {
await request(app.getHttpServer())
.post('/funnels/analyze')
.send({
steps: [],
startDate: '2026-01-01',
endDate: '2026-01-28',
})
.expect(400);
});
it('should reject missing date range', async () => {
await request(app.getHttpServer())
.post('/funnels/analyze')
.send({
steps: [
{ name: 'Landing', eventType: 'pageview', filter: { pageUrl: '/' } },
],
})
.expect(400);
});
});
describe('GET /funnels/presets', () => {
it('should return preset funnels', async () => {
const response = await request(app.getHttpServer())
.get('/funnels/presets')
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
});
describe('Cohorts', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('GET /cohorts/retention', () => {
it('should return retention cohort analysis', async () => {
mockDataSource.query.mockResolvedValue([
{ cohort: '2026-01-15', day_0: '100', day_1: '60', day_7: '40', day_30: '25' },
{ cohort: '2026-01-16', day_0: '110', day_1: '65', day_7: '42', day_30: null },
]);
const response = await request(app.getHttpServer())
.get('/cohorts/retention')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
granularity: 'day',
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should reject invalid granularity', async () => {
await request(app.getHttpServer())
.get('/cohorts/retention')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
granularity: 'invalid',
})
.expect(400);
});
});
describe('GET /cohorts/behavioral', () => {
it('should return behavioral cohorts', async () => {
mockDataSource.query.mockResolvedValue([
{ segment: 'desktop', users: '450', avg_sessions: '2.5', retention_rate: '0.65' },
{ segment: 'mobile', users: '350', avg_sessions: '1.8', retention_rate: '0.52' },
]);
const response = await request(app.getHttpServer())
.get('/cohorts/behavioral')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
segmentBy: 'device',
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
it('should reject missing segmentBy', async () => {
await request(app.getHttpServer())
.get('/cohorts/behavioral')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
})
.expect(400);
});
});
});
describe('Revenue', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('GET /revenue/summary', () => {
it('should return revenue summary', async () => {
mockDataSource.query.mockResolvedValue([{
total_revenue: '25000.00',
total_transactions: '500',
avg_order_value: '50.00',
revenue_per_user: '27.78',
}]);
const response = await request(app.getHttpServer())
.get('/revenue/summary')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(response.body).toHaveProperty('totalRevenue');
expect(response.body).toHaveProperty('totalTransactions');
expect(response.body).toHaveProperty('avgOrderValue');
});
});
describe('GET /revenue/ltv', () => {
it('should return lifetime value analysis', async () => {
mockDataSource.query.mockResolvedValue([
{ cohort: '2026-01', ltv: '150.00', users: '200' },
{ cohort: '2025-12', ltv: '280.00', users: '180' },
]);
const response = await request(app.getHttpServer())
.get('/revenue/ltv')
.query({ startDate: '2026-01-01', endDate: '2026-01-28' })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('GET /revenue/arpu', () => {
it('should return average revenue per user', async () => {
mockDataSource.query.mockResolvedValue([
{ period: new Date('2026-01-15'), arpu: '25.50' },
{ period: new Date('2026-01-16'), arpu: '27.80' },
]);
const response = await request(app.getHttpServer())
.get('/revenue/arpu')
.query({
startDate: '2026-01-01',
endDate: '2026-01-28',
granularity: 'day',
})
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('GET /revenue/mrr', () => {
it('should return monthly recurring revenue', async () => {
mockDataSource.query.mockResolvedValue([{
mrr: '15000.00',
subscribers: '300',
churn_rate: '0.05',
}]);
const response = await request(app.getHttpServer())
.get('/revenue/mrr')
.query({ month: '2026-01' })
.expect(200);
expect(response.body).toHaveProperty('mrr');
expect(response.body).toHaveProperty('subscribers');
});
it('should reject invalid month format', async () => {
await request(app.getHttpServer())
.get('/revenue/mrr')
.query({ month: 'invalid' })
.expect(400);
});
});
});
describe('GDPR', () => {
beforeEach(() => {
mockDataSource.query.mockReset();
});
describe('POST /gdpr/export/user/:userId', () => {
it('should export user data', async () => {
mockDataSource.query
.mockResolvedValueOnce([{ sessionId: 'sess-001', userId: 'user-001' }])
.mockResolvedValueOnce([{ eventType: 'pageview', timestamp: '2026-01-15T10:00:00Z' }]);
const response = await request(app.getHttpServer())
.post('/gdpr/export/user/user-001')
.expect(200);
expect(response.body).toHaveProperty('userId');
expect(response.body).toHaveProperty('sessions');
expect(response.body).toHaveProperty('events');
});
});
describe('POST /gdpr/export/session/:sessionId', () => {
it('should export session data', async () => {
mockDataSource.query
.mockResolvedValueOnce([{ sessionId: 'sess-001' }])
.mockResolvedValueOnce([{ eventType: 'pageview' }]);
const response = await request(app.getHttpServer())
.post('/gdpr/export/session/sess-001')
.expect(200);
expect(response.body).toHaveProperty('sessionId');
expect(response.body).toHaveProperty('events');
});
});
describe('DELETE /gdpr/erase/user/:userId', () => {
it('should erase user data', async () => {
const queryRunner = {
connect: vi.fn(),
startTransaction: vi.fn(),
commitTransaction: vi.fn(),
rollbackTransaction: vi.fn(),
release: vi.fn(),
manager: {
delete: vi.fn().mockResolvedValue({ affected: 1 }),
},
};
mockDataSource.createQueryRunner.mockReturnValue(queryRunner);
await request(app.getHttpServer())
.delete('/gdpr/erase/user/user-001')
.expect(200);
});
});
describe('DELETE /gdpr/erase/session/:sessionId', () => {
it('should erase session data', async () => {
const queryRunner = {
connect: vi.fn(),
startTransaction: vi.fn(),
commitTransaction: vi.fn(),
rollbackTransaction: vi.fn(),
release: vi.fn(),
manager: {
delete: vi.fn().mockResolvedValue({ affected: 1 }),
},
};
mockDataSource.createQueryRunner.mockReturnValue(queryRunner);
await request(app.getHttpServer())
.delete('/gdpr/erase/session/sess-001')
.expect(200);
});
});
describe('GET /gdpr/retention-policy', () => {
it('should return retention policy', async () => {
const response = await request(app.getHttpServer())
.get('/gdpr/retention-policy')
.expect(200);
expect(response.body).toHaveProperty('rawEvents');
expect(response.body).toHaveProperty('sessions');
expect(response.body).toHaveProperty('aggregatedMetrics');
});
});
describe('POST /gdpr/retention-policy', () => {
it('should update retention policy', async () => {
const response = await request(app.getHttpServer())
.post('/gdpr/retention-policy')
.send({
rawEvents: 90,
sessions: 365,
aggregatedMetrics: 730,
})
.expect(200);
expect(response.body).toHaveProperty('rawEvents', 90);
});
it('should reject invalid retention days', async () => {
await request(app.getHttpServer())
.post('/gdpr/retention-policy')
.send({
rawEvents: -1,
})
.expect(400);
});
});
describe('POST /gdpr/cleanup/execute', () => {
it('should execute cleanup', async () => {
const queryRunner = {
connect: vi.fn(),
startTransaction: vi.fn(),
commitTransaction: vi.fn(),
rollbackTransaction: vi.fn(),
release: vi.fn(),
manager: {
delete: vi.fn().mockResolvedValue({ affected: 100 }),
},
};
mockDataSource.createQueryRunner.mockReturnValue(queryRunner);
const response = await request(app.getHttpServer())
.post('/gdpr/cleanup/execute')
.expect(200);
expect(response.body).toHaveProperty('deletedRecords');
});
});
describe('GET /gdpr/cleanup/preview', () => {
it('should preview cleanup', async () => {
mockDataSource.query.mockResolvedValue([{ count: '150' }]);
const response = await request(app.getHttpServer())
.get('/gdpr/cleanup/preview')
.expect(200);
expect(response.body).toHaveProperty('rawEventsToDelete');
expect(response.body).toHaveProperty('sessionsToDelete');
});
});
describe('GET /gdpr/audit-log', () => {
it('should return audit log', async () => {
mockDataSource.query.mockResolvedValue([
{
id: 'log-001',
action: 'EXPORT',
targetType: 'USER',
targetId: 'user-001',
timestamp: '2026-01-15T10:00:00Z',
},
]);
const response = await request(app.getHttpServer())
.get('/gdpr/audit-log')
.query({ limit: 50, offset: 0 })
.expect(200);
expect(Array.isArray(response.body)).toBe(true);
});
});
describe('GET /gdpr/consent/:userId', () => {
it('should return user consent status', async () => {
mockDataSource.query.mockResolvedValue([
{
userId: 'user-001',
analytics: true,
marketing: false,
lastUpdated: '2026-01-15T10:00:00Z',
},
]);
const response = await request(app.getHttpServer())
.get('/gdpr/consent/user-001')
.expect(200);
expect(response.body).toHaveProperty('userId');
expect(response.body).toHaveProperty('analytics');
});
});
describe('POST /gdpr/consent/:userId', () => {
it('should update user consent', async () => {
mockDataSource.query.mockResolvedValue({ affected: 1 });
const response = await request(app.getHttpServer())
.post('/gdpr/consent/user-001')
.send({
analytics: true,
marketing: false,
})
.expect(200);
expect(response.body).toHaveProperty('userId');
expect(response.body).toHaveProperty('analytics', true);
});
it('should reject invalid consent data', async () => {
await request(app.getHttpServer())
.post('/gdpr/consent/user-001')
.send({
analytics: 'not-a-boolean',
})
.expect(400);
});
});
});
});