1350 lines
42 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|