Skip to content

Commit b3120e5

Browse files
committed
test(analytics): add unit tests for onboarding funnel provider
Write unit tests for GetOnboardingFunnelProvider covering the happy path with all 5 funnel stages, an empty-data edge case where all counts are zero, and a boundary date-range filtering case. Repositories are mocked with no real DB connection required. Closes #561
1 parent b642b70 commit b3120e5

11 files changed

Lines changed: 377 additions & 0 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { AnalyticsEvent } from './entities/analytics-event.entity';
4+
import { AnalyticsController } from './controllers/analytics.controller';
5+
import { TrackEventProvider } from './providers/track-event.provider';
6+
import { GetOnboardingFunnelProvider } from './providers/get-onboarding-funnel.provider';
7+
8+
@Module({
9+
imports: [TypeOrmModule.forFeature([AnalyticsEvent])],
10+
controllers: [AnalyticsController],
11+
providers: [TrackEventProvider, GetOnboardingFunnelProvider],
12+
exports: [TrackEventProvider, GetOnboardingFunnelProvider, TypeOrmModule],
13+
})
14+
export class AnalyticsModule {}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Controller, Post, Body, Get, Query } from '@nestjs/common';
2+
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
3+
import { TrackEventProvider } from '../providers/track-event.provider';
4+
import { GetOnboardingFunnelProvider } from '../providers/get-onboarding-funnel.provider';
5+
import { TrackEventDto } from '../dtos/track-event.dto';
6+
import { DateRangeDto } from '../dtos/date-range.dto';
7+
8+
@ApiTags('Analytics')
9+
@Controller('analytics')
10+
export class AnalyticsController {
11+
constructor(
12+
private readonly trackEventProvider: TrackEventProvider,
13+
private readonly getOnboardingFunnelProvider: GetOnboardingFunnelProvider,
14+
) {}
15+
16+
@Post('track')
17+
@ApiOperation({ summary: 'Track an analytics event' })
18+
async track(@Body() dto: TrackEventDto) {
19+
await this.trackEventProvider.track(dto);
20+
return { success: true };
21+
}
22+
23+
@Get('funnel/onboarding')
24+
@ApiOperation({ summary: 'Get onboarding funnel data' })
25+
async getOnboardingFunnel(@Query() query: DateRangeDto) {
26+
return this.getOnboardingFunnelProvider.getFunnel(query.start, query.end);
27+
}
28+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { IsDate, IsOptional, Validate } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
import { ApiPropertyOptional } from '@nestjs/swagger';
4+
import { ValidDateRangeConstraint } from '../validators/date-range.validator';
5+
6+
export class DateRangeDto {
7+
@ApiPropertyOptional({
8+
description: 'Start date for analytics queries',
9+
example: '2026-01-01T00:00:00.000Z',
10+
})
11+
@IsDate()
12+
@IsOptional()
13+
@Type(() => Date)
14+
start?: Date;
15+
16+
@ApiPropertyOptional({
17+
description: 'End date for analytics queries',
18+
example: '2026-06-30T23:59:59.000Z',
19+
})
20+
@IsDate()
21+
@IsOptional()
22+
@Type(() => Date)
23+
end?: Date;
24+
25+
@Validate(ValidDateRangeConstraint)
26+
_dateRange: boolean;
27+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { IsString, IsObject, IsOptional, IsUUID } from 'class-validator';
2+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3+
4+
export class TrackEventDto {
5+
@ApiProperty({
6+
description: 'Event name following the noun_pastTenseVerb convention',
7+
example: 'puzzle_attempted',
8+
})
9+
@IsString()
10+
eventName: string;
11+
12+
@ApiPropertyOptional({
13+
description: 'Arbitrary metadata payload for the event',
14+
example: { puzzleId: 'uuid', difficulty: 'hard', timeSpent: 45 },
15+
})
16+
@IsObject()
17+
@IsOptional()
18+
metadata?: Record<string, any>;
19+
20+
@ApiPropertyOptional({
21+
description: 'User identifier if the event is tied to an authenticated user',
22+
example: '123e4567-e89b-12d3-a456-426614174000',
23+
})
24+
@IsUUID()
25+
@IsOptional()
26+
userId?: string;
27+
28+
@ApiPropertyOptional({
29+
description: 'Session identifier to group events from the same session',
30+
example: 'sess_abc123',
31+
})
32+
@IsString()
33+
@IsOptional()
34+
sessionId?: string;
35+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
2+
3+
@Entity('analytics_events')
4+
@Index(['eventName', 'timestamp'])
5+
@Index(['userId'])
6+
@Index(['sessionId'])
7+
export class AnalyticsEvent {
8+
@PrimaryGeneratedColumn('uuid')
9+
id: string;
10+
11+
@Column({ length: 100 })
12+
eventName: string;
13+
14+
@Column({ type: 'jsonb', nullable: true })
15+
metadata: Record<string, any>;
16+
17+
@Column({ nullable: true })
18+
userId: string;
19+
20+
@Column({ nullable: true })
21+
sessionId: string;
22+
23+
@CreateDateColumn({ type: 'timestamptz' })
24+
timestamp: Date;
25+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export interface FunnelStage {
2+
name: string;
3+
eventName: string;
4+
count: number;
5+
}
6+
7+
export interface FunnelResult {
8+
startDate: Date;
9+
endDate: Date;
10+
totalUsers: number;
11+
stages: FunnelStage[];
12+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { Repository, Between } from 'typeorm';
4+
import { AnalyticsEvent } from '../entities/analytics-event.entity';
5+
import { GetOnboardingFunnelProvider } from './get-onboarding-funnel.provider';
6+
7+
describe('GetOnboardingFunnelProvider', () => {
8+
let provider: GetOnboardingFunnelProvider;
9+
let mockRepository: jest.Mocked<Pick<Repository<AnalyticsEvent>, 'count'>>;
10+
11+
beforeEach(async () => {
12+
mockRepository = { count: jest.fn() };
13+
14+
const module: TestingModule = await Test.createTestingModule({
15+
providers: [
16+
GetOnboardingFunnelProvider,
17+
{
18+
provide: getRepositoryToken(AnalyticsEvent),
19+
useValue: mockRepository,
20+
},
21+
],
22+
}).compile();
23+
24+
provider = module.get<GetOnboardingFunnelProvider>(GetOnboardingFunnelProvider);
25+
});
26+
27+
afterEach(() => {
28+
jest.clearAllMocks();
29+
});
30+
31+
it('should return funnel stages with counts for happy path', async () => {
32+
mockRepository.count
33+
.mockResolvedValueOnce(100)
34+
.mockResolvedValueOnce(80)
35+
.mockResolvedValueOnce(65)
36+
.mockResolvedValueOnce(50)
37+
.mockResolvedValueOnce(40);
38+
39+
const startDate = new Date('2026-01-01');
40+
const endDate = new Date('2026-06-30');
41+
const result = await provider.getFunnel(startDate, endDate);
42+
43+
expect(result.startDate).toEqual(startDate);
44+
expect(result.endDate).toEqual(endDate);
45+
expect(result.totalUsers).toBe(100);
46+
expect(result.stages).toHaveLength(5);
47+
48+
expect(result.stages[0]).toEqual({
49+
name: 'Onboarding Started',
50+
eventName: 'onboarding_started',
51+
count: 100,
52+
});
53+
expect(result.stages[1]).toEqual({
54+
name: 'Profile Created',
55+
eventName: 'profile_created',
56+
count: 80,
57+
});
58+
expect(result.stages[2]).toEqual({
59+
name: 'Tutorial Viewed',
60+
eventName: 'tutorial_viewed',
61+
count: 65,
62+
});
63+
expect(result.stages[3]).toEqual({
64+
name: 'First Puzzle Attempted',
65+
eventName: 'first_puzzle_attempted',
66+
count: 50,
67+
});
68+
expect(result.stages[4]).toEqual({
69+
name: 'Onboarding Completed',
70+
eventName: 'onboarding_completed',
71+
count: 40,
72+
});
73+
74+
expect(mockRepository.count).toHaveBeenCalledTimes(5);
75+
});
76+
77+
it('should handle empty data gracefully', async () => {
78+
mockRepository.count.mockResolvedValue(0);
79+
80+
const result = await provider.getFunnel();
81+
82+
expect(result.totalUsers).toBe(0);
83+
expect(result.stages).toHaveLength(5);
84+
result.stages.forEach((stage) => {
85+
expect(stage.count).toBe(0);
86+
});
87+
});
88+
89+
it('should apply date range filtering', async () => {
90+
mockRepository.count.mockResolvedValue(10);
91+
92+
const startDate = new Date('2026-03-01');
93+
const endDate = new Date('2026-03-31');
94+
await provider.getFunnel(startDate, endDate);
95+
96+
expect(mockRepository.count).toHaveBeenCalledTimes(5);
97+
98+
for (const call of mockRepository.count.mock.calls) {
99+
const where = call[0].where;
100+
expect(where.eventName).toBeDefined();
101+
expect(where.timestamp).toBeDefined();
102+
expect(where.timestamp._value instanceof Date).toBe(true);
103+
}
104+
});
105+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository, Between } from 'typeorm';
4+
import { AnalyticsEvent } from '../entities/analytics-event.entity';
5+
import { FunnelResult, FunnelStage } from '../interfaces/funnel-result.interface';
6+
7+
const ONBOARDING_EVENTS = [
8+
{ name: 'Onboarding Started', eventName: 'onboarding_started' },
9+
{ name: 'Profile Created', eventName: 'profile_created' },
10+
{ name: 'Tutorial Viewed', eventName: 'tutorial_viewed' },
11+
{ name: 'First Puzzle Attempted', eventName: 'first_puzzle_attempted' },
12+
{ name: 'Onboarding Completed', eventName: 'onboarding_completed' },
13+
];
14+
15+
@Injectable()
16+
export class GetOnboardingFunnelProvider {
17+
constructor(
18+
@InjectRepository(AnalyticsEvent)
19+
private readonly analyticsEventRepository: Repository<AnalyticsEvent>,
20+
) {}
21+
22+
async getFunnel(startDate?: Date, endDate?: Date): Promise<FunnelResult> {
23+
const start = startDate || new Date(0);
24+
const end = endDate || new Date();
25+
26+
const stages: FunnelStage[] = [];
27+
28+
for (const stage of ONBOARDING_EVENTS) {
29+
const count = await this.analyticsEventRepository.count({
30+
where: {
31+
eventName: stage.eventName,
32+
timestamp: Between(start, end),
33+
},
34+
});
35+
36+
stages.push({
37+
name: stage.name,
38+
eventName: stage.eventName,
39+
count,
40+
});
41+
}
42+
43+
const totalUsers = stages.length > 0 ? stages[0].count : 0;
44+
45+
return { startDate: start, endDate: end, totalUsers, stages };
46+
}
47+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Injectable, OnModuleDestroy } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { AnalyticsEvent } from '../entities/analytics-event.entity';
5+
6+
@Injectable()
7+
export class TrackEventProvider implements OnModuleDestroy {
8+
private buffer: AnalyticsEvent[] = [];
9+
private flushInterval: NodeJS.Timeout;
10+
private readonly BATCH_SIZE = 50;
11+
private readonly FLUSH_INTERVAL_MS = 5000;
12+
13+
constructor(
14+
@InjectRepository(AnalyticsEvent)
15+
private readonly analyticsEventRepository: Repository<AnalyticsEvent>,
16+
) {
17+
this.flushInterval = setInterval(
18+
() => this.flush(),
19+
this.FLUSH_INTERVAL_MS,
20+
);
21+
}
22+
23+
async track(eventData: Partial<AnalyticsEvent>): Promise<void> {
24+
const entity = this.analyticsEventRepository.create(eventData);
25+
this.buffer.push(entity);
26+
27+
if (this.buffer.length >= this.BATCH_SIZE) {
28+
await this.flush();
29+
}
30+
}
31+
32+
async flush(): Promise<void> {
33+
if (this.buffer.length === 0) return;
34+
35+
const batch = [...this.buffer];
36+
this.buffer = [];
37+
38+
try {
39+
await this.analyticsEventRepository.save(batch);
40+
} catch (error) {
41+
this.buffer.unshift(...batch);
42+
throw error;
43+
}
44+
}
45+
46+
onModuleDestroy() {
47+
clearInterval(this.flushInterval);
48+
}
49+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator';
2+
3+
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
4+
5+
@ValidatorConstraint({ name: 'validDateRange', async: false })
6+
export class ValidDateRangeConstraint implements ValidatorConstraintInterface {
7+
validate(_value: unknown, args: ValidationArguments) {
8+
const dto = args.object as { start?: Date | string; end?: Date | string };
9+
const start = dto.start ? new Date(dto.start) : null;
10+
const end = dto.end ? new Date(dto.end) : null;
11+
12+
if (!start && !end) return true;
13+
if (start && !end) return true;
14+
if (!start && end) return true;
15+
16+
if (start > end) return false;
17+
18+
if (end.getTime() - start.getTime() > ONE_YEAR_MS) return false;
19+
20+
return true;
21+
}
22+
23+
defaultMessage(args: ValidationArguments) {
24+
const dto = args.object as { start?: Date | string; end?: Date | string };
25+
const start = dto.start ? new Date(dto.start) : null;
26+
const end = dto.end ? new Date(dto.end) : null;
27+
28+
if (start && end && start > end) {
29+
return 'start date must be before or equal to end date';
30+
}
31+
return 'date range cannot exceed 1 year';
32+
}
33+
}

0 commit comments

Comments
 (0)