This commit is contained in:
2025-12-15 15:33:42 +01:00
parent c4001fe5d2
commit 8adf55e2ac
31 changed files with 2 additions and 1173 deletions

View File

@@ -1,33 +0,0 @@
import { Module, ConsoleLogger } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsService } from './analytics.service';
import { AnalyticsController } from '../../presentation/analytics.controller';
import { RecordPageViewUseCase } from './record-page-view.use-case';
import { RecordEngagementUseCase } from './record-engagement.use-case';
import { TypeOrmPageViewRepository } from '../../infrastructure/analytics/typeorm-page-view.repository';
import { InMemoryEngagementRepository } from '../../infrastructure/analytics/in-memory-engagement.repository';
import { PageViewEntity } from '../../infrastructure/analytics/typeorm-page-view.entity';
@Module({
imports: [TypeOrmModule.forFeature([PageViewEntity])],
controllers: [AnalyticsController],
providers: [
AnalyticsService,
RecordPageViewUseCase,
RecordEngagementUseCase,
{
provide: 'IPageViewRepository',
useClass: TypeOrmPageViewRepository,
},
{
provide: 'IEngagementRepository',
useClass: InMemoryEngagementRepository,
},
{
provide: 'ILogger',
useClass: ConsoleLogger, // Using ConsoleLogger for now
},
],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -1,91 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AnalyticsService } from './analytics.service';
import { RecordPageViewUseCase, RecordPageViewInput } from './record-page-view.use-case';
import {
RecordEngagementUseCase,
RecordEngagementInput,
RecordEngagementOutput,
} from './record-engagement.use-case';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
import { EngagementAction, EngagementEntityType } from '@gridpilot/analytics/domain/types/EngagementEvent';
describe('AnalyticsService', () => {
let service: AnalyticsService;
let recordPageViewUseCase: RecordPageViewUseCase;
let recordEngagementUseCase: RecordEngagementUseCase;
let logger: ILogger;
const mockRecordPageViewInput: RecordPageViewInput = {
entityType: EntityType.LEAGUE,
entityId: 'league-123',
visitorType: VisitorType.ANONYMOUS,
sessionId: 'session-abc',
};
const mockRecordEngagementInput: RecordEngagementInput = {
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-456',
actorType: 'driver',
actorId: 'driver-789',
sessionId: 'session-def',
metadata: { campaign: 'summer-promo' },
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AnalyticsService,
{
provide: RecordPageViewUseCase,
useValue: {
execute: jest.fn().mockResolvedValue({ pageViewId: 'new-pv-123' }),
},
},
{
provide: RecordEngagementUseCase,
useValue: {
execute: jest.fn().mockResolvedValue({ eventId: 'new-eng-456', engagementWeight: 10 }),
},
},
{
provide: 'ILogger',
useValue: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as ILogger,
},
],
}).compile();
service = module.get<AnalyticsService>(AnalyticsService);
recordPageViewUseCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
recordEngagementUseCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
logger = module.get<ILogger>('ILogger');
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should call recordPageViewUseCase.execute and return pageViewId', async () => {
const result = await service.recordPageView(mockRecordPageViewInput);
expect(recordPageViewUseCase.execute).toHaveBeenCalledTimes(1);
expect(recordPageViewUseCase.execute).toHaveBeenCalledWith(mockRecordPageViewInput);
expect(result).toEqual({ pageViewId: 'new-pv-123' });
expect(logger.debug).toHaveBeenCalledWith('AnalyticsService: Recording page view', { input: mockRecordPageViewInput });
});
it('should call recordEngagementUseCase.execute and return engagement details', async () => {
const result = await service.recordEngagement(mockRecordEngagementInput);
expect(recordEngagementUseCase.execute).toHaveBeenCalledTimes(1);
expect(recordEngagementUseCase.execute).toHaveBeenCalledWith(mockRecordEngagementInput);
expect(result).toEqual({ eventId: 'new-eng-456', engagementWeight: 10 });
expect(logger.debug).toHaveBeenCalledWith('AnalyticsService: Recording engagement', { input: mockRecordEngagementInput });
});
});

View File

@@ -1,27 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import { RecordPageViewUseCase, RecordPageViewInput } from './record-page-view.use-case';
import {
RecordEngagementUseCase,
RecordEngagementInput,
RecordEngagementOutput,
} from './record-engagement.use-case';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
@Injectable()
export class AnalyticsService {
constructor(
private readonly recordPageViewUseCase: RecordPageViewUseCase,
private readonly recordEngagementUseCase: RecordEngagementUseCase,
@Inject('ILogger') private readonly logger: ILogger,
) {}
async recordPageView(input: RecordPageViewInput): Promise<{ pageViewId: string }> {
this.logger.debug('AnalyticsService: Recording page view', { input });
return this.recordPageViewUseCase.execute(input);
}
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
this.logger.debug('AnalyticsService: Recording engagement', { input });
return this.recordEngagementUseCase.execute(input);
}
}

View File

@@ -1,119 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordEngagementUseCase, RecordEngagementInput } from './record-engagement.use-case';
import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
import { EngagementEvent } from '@gridpilot/analytics/domain/entities/EngagementEvent';
describe('RecordEngagementUseCase', () => {
let useCase: RecordEngagementUseCase;
let engagementRepository: IEngagementRepository;
let logger: ILogger;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordEngagementUseCase,
{
provide: 'IEngagementRepository',
useValue: {
save: jest.fn(),
findById: jest.fn(),
findByEntityId: jest.fn(),
findByAction: jest.fn(),
findByDateRange: jest.fn(),
countByAction: jest.fn(),
getSponsorClicksForEntity: jest.fn(),
},
},
{
provide: 'ILogger',
useValue: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
},
],
}).compile();
useCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
engagementRepository = module.get<IEngagementRepository>('IEngagementRepository');
logger = module.get<ILogger>('ILogger');
});
it('should be defined', () => {
expect(useCase).toBeDefined();
});
it('should record an engagement event and save it to the repository', async () => {
const input: RecordEngagementInput = {
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-123',
actorType: 'driver',
actorId: 'driver-456',
sessionId: 'session-789',
metadata: { campaign: 'spring-sale' },
};
const result = await useCase.execute(input);
expect(result).toBeDefined();
expect(result.eventId).toMatch(/^eng-\d{13}-[a-z0-9]{9}$/);
expect(result.engagementWeight).toBeGreaterThan(0);
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
const savedEvent: EngagementEvent = (engagementRepository.save as jest.Mock).mock.calls[0][0];
expect(savedEvent).toBeInstanceOf(EngagementEvent);
expect(savedEvent.action).toBe(input.action);
expect(savedEvent.entityType).toBe(input.entityType);
expect(savedEvent.entityId).toBe(input.entityId);
expect(savedEvent.actorType).toBe(input.actorType);
expect(savedEvent.actorId).toBe(input.actorId);
expect(savedEvent.sessionId).toBe(input.sessionId);
expect(savedEvent.metadata).toEqual(input.metadata);
expect(logger.info).toHaveBeenCalledWith(
'Engagement recorded successfully',
{ eventId: expect.any(String), input },
);
});
it('should handle engagement events without actorId or metadata', async () => {
const input: RecordEngagementInput = {
action: 'view_standings',
entityType: 'league',
entityId: 'home-page',
actorType: 'anonymous',
sessionId: 'session-abc',
};
const result = await useCase.execute(input);
expect(result).toBeDefined();
expect(result.eventId).toMatch(/^eng-\d{13}-[a-z0-9]{9}$/);
expect(result.engagementWeight).toBeGreaterThan(0);
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
const savedEvent: EngagementEvent = (engagementRepository.save as jest.Mock).mock.calls[0][0];
expect(savedEvent.actorId).toBeUndefined();
expect(savedEvent.metadata).toBeUndefined();
});
it('should log an error if saving to repository fails', async () => {
const input: RecordEngagementInput = {
action: 'click_sponsor_url',
entityType: 'sponsor',
entityId: 'ad-001',
actorType: 'anonymous',
sessionId: 'session-xyz',
};
const error = new Error('Repository save failed');
(engagementRepository.save as jest.Mock).mockRejectedValue(error);
await expect(useCase.execute(input)).rejects.toThrow(error);
expect(logger.error).toHaveBeenCalledWith(
'Error recording engagement',
error,
{ input },
);
});
});

View File

@@ -1,68 +0,0 @@
import { Inject } from '@nestjs/common';
/**
* Use Case: RecordEngagementUseCase
*
* Records an engagement event when a visitor interacts with an entity.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '@gridpilot/analytics/domain/entities/EngagementEvent';
import type { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
export interface RecordEngagementInput {
action: EngagementAction;
entityType: EngagementEntityType;
entityId: string;
actorId?: string;
actorType: 'anonymous' | 'driver' | 'sponsor';
sessionId: string;
metadata?: Record<string, string | number | boolean>;
}
export interface RecordEngagementOutput {
eventId: string;
engagementWeight: number;
}
export class RecordEngagementUseCase
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
constructor(
@Inject('IEngagementRepository') private readonly engagementRepository: IEngagementRepository,
@Inject('ILogger') private readonly logger: ILogger,
) {}
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
this.logger.debug('Executing RecordEngagementUseCase', { input });
try {
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const baseProps: Omit<Parameters<typeof EngagementEvent.create>[0], 'timestamp'> = {
id: eventId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
actorType: input.actorType,
sessionId: input.sessionId,
};
const event = EngagementEvent.create({
...baseProps,
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
});
await this.engagementRepository.save(event);
this.logger.info('Engagement recorded successfully', { eventId, input });
return {
eventId,
engagementWeight: event.getEngagementWeight(),
};
} catch (error) {
this.logger.error('Error recording engagement', error, { input });
throw error;
}
}
}

View File

@@ -1,80 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordPageViewUseCase, RecordPageViewInput, RecordPageViewOutput } from './record-page-view.use-case';
import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
describe('RecordPageViewUseCase', () => {
let useCase: RecordPageViewUseCase;
let pageViewRepository: IPageViewRepository;
let logger: ILogger;
const mockRecordPageViewInput: RecordPageViewInput = {
entityType: EntityType.LEAGUE,
entityId: 'league-123',
visitorType: VisitorType.ANONYMOUS,
sessionId: 'session-abc',
referrer: 'https://example.com',
userAgent: 'test-agent',
country: 'US',
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordPageViewUseCase,
{
provide: 'IPageViewRepository',
useValue: {
save: jest.fn(),
findById: jest.fn(),
findByEntityId: jest.fn(),
findByDateRange: jest.fn(),
findBySession: jest.fn(),
countByEntityId: jest.fn(),
countUniqueVisitors: jest.fn(),
} as IPageViewRepository,
},
{
provide: 'ILogger',
useValue: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as ILogger,
},
],
}).compile();
useCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
pageViewRepository = module.get<IPageViewRepository>('IPageViewRepository');
logger = module.get<ILogger>('ILogger');
});
it('should be defined', () => {
expect(useCase).toBeDefined();
});
it('should record a page view and return its ID', async () => {
const result: RecordPageViewOutput = await useCase.execute(mockRecordPageViewInput);
expect(result).toHaveProperty('pageViewId');
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
const savedPageView: PageView = (pageViewRepository.save as jest.Mock).mock.calls[0][0];
expect(savedPageView.entityId).toBe(mockRecordPageViewInput.entityId);
expect(savedPageView.entityType).toBe(mockRecordPageViewInput.entityType);
expect(savedPageView.sessionId).toBe(mockRecordPageViewInput.sessionId);
expect(logger.debug).toHaveBeenCalledWith('Executing RecordPageViewUseCase', { input: mockRecordPageViewInput });
expect(logger.info).toHaveBeenCalledWith('Page view recorded successfully', { pageViewId: result.pageViewId, input: mockRecordPageViewInput });
});
it('should handle errors during page view recording', async () => {
const error = new Error('Repository error');
(pageViewRepository.save as jest.Mock).mockRejectedValue(error);
await expect(useCase.execute(mockRecordPageViewInput)).rejects.toThrow(error);
expect(logger.error).toHaveBeenCalledWith('Error recording page view', error, { input: mockRecordPageViewInput });
});
});

View File

@@ -1,60 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
import type { EntityType, VisitorType } from '@gridpilot/analytics/domain/entities/PageView'; // Re-exported there
import type { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
export interface RecordPageViewInput {
entityType: EntityType;
entityId: string;
visitorId?: string;
visitorType: VisitorType;
sessionId: string;
referrer?: string;
userAgent?: string;
country?: string;
}
export interface RecordPageViewOutput {
pageViewId: string;
}
@Injectable()
export class RecordPageViewUseCase
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
constructor(
@Inject('IPageViewRepository') private readonly pageViewRepository: IPageViewRepository,
@Inject('ILogger') private readonly logger: ILogger,
) {}
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
this.logger.debug('Executing RecordPageViewUseCase', { input });
try {
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const baseProps: Omit<Parameters<typeof PageView.create>[0], 'timestamp'> = {
id: pageViewId,
entityType: input.entityType,
entityId: input.entityId,
visitorType: input.visitorType,
sessionId: input.sessionId,
};
const pageView = PageView.create({
...baseProps,
...(input.visitorId !== undefined ? { visitorId: input.visitorId } : {}),
...(input.referrer !== undefined ? { referrer: input.referrer } : {}),
...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
...(input.country !== undefined ? { country: input.country } : {}),
});
await this.pageViewRepository.save(pageView);
this.logger.info('Page view recorded successfully', { pageViewId, input });
return { pageViewId };
} catch (error) {
this.logger.error('Error recording page view', error, { input });
throw error;
}
}
}

View File

@@ -1,81 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EngagementEvent, EngagementAction, EngagementEntityType } from '@gridpilot/analytics/domain/entities/EngagementEvent';
import type { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
@Injectable()
export class InMemoryEngagementRepository implements IEngagementRepository {
private readonly engagements: Map<string, EngagementEvent> = new Map();
async save(event: EngagementEvent): Promise<void> {
this.engagements.set(event.id, event);
}
async findById(id: string): Promise<EngagementEvent | null> {
return this.engagements.get(id) || null;
}
async findByEntityId(
entityType: EngagementEntityType,
entityId: string,
): Promise<EngagementEvent[]> {
return Array.from(this.engagements.values()).filter(
(e) => e.entityType === entityType && e.entityId === entityId,
);
}
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
return Array.from(this.engagements.values()).filter(
(e) => e.action === action,
);
}
async findByDateRange(
startDate: Date,
endDate: Date,
): Promise<EngagementEvent[]> {
return Array.from(this.engagements.values()).filter((e) => {
const eventDate = new Date(e.timestamp);
return eventDate >= startDate && eventDate <= endDate;
});
}
async countByAction(
action: EngagementAction,
entityId?: string,
since?: Date,
): Promise<number> {
let count = 0;
for (const event of this.engagements.values()) {
if (event.action === action) {
if (entityId && event.entityId !== entityId) {
continue;
}
if (since && new Date(event.timestamp) < since) {
continue;
}
count++;
}
}
return count;
}
async getSponsorClicksForEntity(
entityId: string,
since?: Date,
): Promise<number> {
let count = 0;
for (const event of this.engagements.values()) {
if (event.action === 'click_sponsor_url' && event.entityId === entityId) {
if (since && new Date(event.timestamp) < since) {
continue;
}
count++;
}
}
return count;
}
clear(): void {
this.engagements.clear();
}
}

View File

@@ -1,81 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PageView, EntityType, VisitorType } from '@gridpilot/analytics/domain/entities/PageView';
import type { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
@Injectable()
export class InMemoryPageViewRepository implements IPageViewRepository {
private readonly pageViews: Map<string, PageView> = new Map();
async save(pageView: PageView): Promise<void> {
this.pageViews.set(pageView.id, pageView);
}
async findById(id: string): Promise<PageView | null> {
return this.pageViews.get(id) || null;
}
async findByEntityId(
entityType: EntityType,
entityId: string,
): Promise<PageView[]> {
return Array.from(this.pageViews.values()).filter(
(pv) => pv.entityType === entityType && pv.entityId === entityId,
);
}
async findByDateRange(
startDate: Date,
endDate: Date,
): Promise<PageView[]> {
return Array.from(this.pageViews.values()).filter((pv) => {
const pageViewDate = new Date(pv.timestamp);
return pageViewDate >= startDate && pageViewDate <= endDate;
});
}
async findBySession(sessionId: string): Promise<PageView[]> {
return Array.from(this.pageViews.values()).filter(
(pv) => pv.sessionId === sessionId,
);
}
async countByEntityId(
entityType: EntityType,
entityId: string,
since?: Date,
): Promise<number> {
let count = 0;
for (const pageView of this.pageViews.values()) {
if (pageView.entityType === entityType && pageView.entityId === entityId) {
if (since && new Date(pageView.timestamp) < since) {
continue;
}
count++;
}
}
return count;
}
async countUniqueVisitors(
entityType: EntityType,
entityId: string,
since?: Date,
): Promise<number> {
const uniqueVisitorIds = new Set<string>();
for (const pageView of this.pageViews.values()) {
if (pageView.entityType === entityType && pageView.entityId === entityId) {
if (since && new Date(pageView.timestamp) < since) {
continue;
}
if (pageView.visitorId) {
uniqueVisitorIds.add(pageView.visitorId);
}
}
}
return uniqueVisitorIds.size;
}
clear(): void {
this.pageViews.clear();
}
}

View File

@@ -1,108 +0,0 @@
import { PageViewMapper } from './PageViewMapper';
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
import { PageViewEntity } from '../typeorm-page-view.entity';
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
describe('PageViewMapper', () => {
const now = new Date();
const pageViewProps = {
id: 'test-id',
entityType: EntityType.LEAGUE,
entityId: 'entity-id',
visitorType: VisitorType.ANONYMOUS,
sessionId: 'session-id',
timestamp: now,
visitorId: 'visitor-id',
referrer: 'fart.com',
userAgent: 'Mozilla',
country: 'US',
durationMs: 1000,
};
const pageViewDomain = PageView.create(pageViewProps);
it('should correctly map a PageView domain entity to a PageViewEntity persistence entity', () => {
const pageViewEntity = PageViewMapper.toPersistence(pageViewDomain);
expect(pageViewEntity).toBeInstanceOf(PageViewEntity);
expect(pageViewEntity.id).toEqual(pageViewDomain.id);
expect(pageViewEntity.entityType).toEqual(pageViewDomain.entityType);
expect(pageViewEntity.entityId).toEqual(pageViewDomain.entityId);
expect(pageViewEntity.visitorType).toEqual(pageViewDomain.visitorType);
expect(pageViewEntity.sessionId).toEqual(pageViewDomain.sessionId);
expect(pageViewEntity.timestamp.toISOString()).toEqual(pageViewDomain.timestamp.toISOString());
expect(pageViewEntity.visitorId).toEqual(pageViewDomain.visitorId);
expect(pageViewEntity.referrer).toEqual(pageViewDomain.referrer);
expect(pageViewEntity.userAgent).toEqual(pageViewDomain.userAgent);
expect(pageViewEntity.country).toEqual(pageViewDomain.country);
expect(pageViewEntity.durationMs).toEqual(pageViewDomain.durationMs);
});
it('should correctly map a PageViewEntity persistence entity to a PageView domain entity', () => {
const pageViewEntity = new PageViewEntity();
pageViewEntity.id = pageViewProps.id;
pageViewEntity.entityType = pageViewProps.entityType as any; // Cast as any because entityType is string in entity
pageViewEntity.entityId = pageViewProps.entityId;
pageViewEntity.visitorType = pageViewProps.visitorType as any;
pageViewEntity.sessionId = pageViewProps.sessionId;
pageViewEntity.timestamp = pageViewProps.timestamp;
pageViewEntity.visitorId = pageViewProps.visitorId;
pageViewEntity.referrer = pageViewProps.referrer;
pageViewEntity.userAgent = pageViewProps.userAgent;
pageViewEntity.country = pageViewProps.country;
pageViewEntity.durationMs = pageViewProps.durationMs;
const pageView = PageViewMapper.toDomain(pageViewEntity);
expect(pageView).toBeInstanceOf(PageView);
expect(pageView.id).toEqual(pageViewEntity.id);
expect(pageView.entityType).toEqual(pageViewEntity.entityType);
expect(pageView.entityId).toEqual(pageViewEntity.entityId);
expect(pageView.visitorType).toEqual(pageViewEntity.visitorType);
expect(pageView.sessionId).toEqual(pageViewEntity.sessionId);
expect(pageView.timestamp.toISOString()).toEqual(pageViewEntity.timestamp.toISOString());
expect(pageView.visitorId).toEqual(pageViewEntity.visitorId);
expect(pageView.referrer).toEqual(pageViewEntity.referrer);
expect(pageView.userAgent).toEqual(pageViewEntity.userAgent);
expect(pageView.country).toEqual(pageViewEntity.country);
expect(pageView.durationMs).toEqual(pageViewEntity.durationMs);
});
it('should handle optional properties correctly when mapping to persistence', () => {
const minimalProps = {
id: 'minimal-id',
entityType: EntityType.RACE,
entityId: 'minimal-entity',
visitorType: VisitorType.DRIVER,
sessionId: 'minimal-session',
timestamp: now,
};
const minimalDomain = PageView.create(minimalProps);
const entity = PageViewMapper.toPersistence(minimalDomain);
expect(entity.visitorId).toBeUndefined();
expect(entity.referrer).toBeUndefined();
expect(entity.userAgent).toBeUndefined();
expect(entity.country).toBeUndefined();
expect(entity.durationMs).toBeUndefined();
});
it('should handle optional properties correctly when mapping to domain', () => {
const minimalEntity = new PageViewEntity();
minimalEntity.id = 'minimal-id-entity';
minimalEntity.entityType = EntityType.RACE as any;
minimalEntity.entityId = 'minimal-entity-entity';
minimalEntity.visitorType = VisitorType.DRIVER as any;
minimalEntity.sessionId = 'minimal-session-entity';
minimalEntity.timestamp = now;
const domain = PageViewMapper.toDomain(minimalEntity);
expect(domain.visitorId).toBeUndefined();
expect(domain.referrer).toBeUndefined();
expect(domain.userAgent).toBeUndefined();
expect(domain.country).toBeUndefined();
expect(domain.durationMs).toBeUndefined();
});
});

View File

@@ -1,39 +0,0 @@
import { PageView } from '../../domain/entities/PageView';
import { PageViewEntity } from '../../../../apps/api/src/infrastructure/analytics/typeorm-page-view.entity';
import { EntityType, VisitorType } from '../../domain/types/PageView';
import { PageViewProps } from '../../domain/types/PageView';
export class PageViewMapper {
public static toDomain(entity: PageViewEntity): PageView {
const props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date } = {
id: entity.id,
entityType: entity.entityType as EntityType,
entityId: entity.entityId,
visitorType: entity.visitorType as VisitorType,
sessionId: entity.sessionId,
timestamp: entity.timestamp,
...(entity.visitorId !== undefined && entity.visitorId !== null ? { visitorId: entity.visitorId } : {}),
...(entity.referrer !== undefined && entity.referrer !== null ? { referrer: entity.referrer } : {}),
...(entity.userAgent !== undefined && entity.userAgent !== null ? { userAgent: entity.userAgent } : {}),
...(entity.country !== undefined && entity.country !== null ? { country: entity.country } : {}),
...(entity.durationMs !== undefined && entity.durationMs !== null ? { durationMs: entity.durationMs } : {}),
};
return PageView.create(props);
}
public static toPersistence(domain: PageView): PageViewEntity {
const entity = new PageViewEntity();
entity.id = domain.id;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.visitorType = domain.visitorType;
entity.sessionId = domain.sessionId;
entity.timestamp = domain.timestamp;
if (domain.visitorId !== undefined) entity.visitorId = domain.visitorId;
if (domain.referrer !== undefined) entity.referrer = domain.referrer;
if (domain.userAgent !== undefined) entity.userAgent = domain.userAgent;
if (domain.country !== undefined) entity.country = domain.country;
if (domain.durationMs !== undefined) entity.durationMs = domain.durationMs;
return entity;
}
}

View File

@@ -1,36 +0,0 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('page_views')
export class PageViewEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;
@Column({ type: 'enum', enum: ['league', 'driver', 'team', 'race', 'sponsor'] })
entityType: string;
@Column({ type: 'uuid' })
entityId: string;
@Column({ type: 'uuid', nullable: true })
visitorId?: string;
@Column({ type: 'enum', enum: ['anonymous', 'driver', 'sponsor'] })
visitorType: string;
@Column({ type: 'uuid' })
sessionId: string;
@Column({ type: 'varchar', length: 2048, nullable: true })
referrer?: string;
@Column({ type: 'varchar', length: 512, nullable: true })
userAgent?: string;
@Column({ type: 'varchar', length: 2, nullable: true })
country?: string;
@Column({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
timestamp: Date;
@Column({ type: 'integer', nullable: true })
durationMs?: number;
}

View File

@@ -1,275 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TypeOrmPageViewRepository } from './typeorm-page-view.repository';
import { PageViewEntity } from './typeorm-page-view.entity';
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
import { v4 as uuid } from 'uuid';
describe('TypeOrmPageViewRepository (Integration)', () => {
jest.setTimeout(30000); // Increase timeout for integration tests
let repository: IPageViewRepository;
let pageViewTypeOrmRepository: Repository<PageViewEntity>;
let module: TestingModule;
const mockPageViewEntities = new Map<string, PageViewEntity>();
const mockPageViewRepository = {
save: jest.fn(entity => {
mockPageViewEntities.set(entity.id, entity);
return entity;
}),
findOne: jest.fn(({ where: { id } }) => {
return Promise.resolve(mockPageViewEntities.get(id) || null);
}),
find: jest.fn((query: any) => {
const { where, order } = query;
let results = Array.from(mockPageViewEntities.values());
if (where) {
if (where.entityId) {
results = results.filter(pv => pv.entityId === where.entityId);
}
if (where.entityType) {
results = results.filter(pv => pv.entityType === where.entityType);
}
if (where.sessionId) {
results = results.filter(pv => pv.sessionId === where.sessionId);
}
// Handle Between operator for timestamp
if (where.timestamp && typeof where.timestamp === 'object') {
// TypeORM's Between operator passes an object like { type: 'between', value: [date1, date2] }
const timestampCondition = where.timestamp;
if (timestampCondition.type === 'between' && timestampCondition.value.length === 2) {
const [startDate, endDate] = timestampCondition.value;
results = results.filter(pv => pv.timestamp >= startDate && pv.timestamp <= endDate);
} else if (timestampCondition.type === 'moreThanOrEqual') {
results = results.filter(pv => pv.timestamp >= timestampCondition.value);
}
}
}
if (order && order.timestamp) {
results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // DESC order
}
return Promise.resolve(results);
}),
count: jest.fn((query: any) => {
const { where } = query;
let results = Array.from(mockPageViewEntities.values());
if (where) {
if (where.entityId) {
results = results.filter(pv => pv.entityId === where.entityId);
}
if (where.entityType) {
results = results.filter(pv => pv.entityType === where.entityType);
}
if (where.timestamp && where.timestamp.type === 'moreThanOrEqual') {
results = results.filter(pv => pv.timestamp >= where.timestamp.value);
}
}
return Promise.resolve(results.length);
}),
createQueryBuilder: jest.fn(() => {
let queryResult = Array.from(mockPageViewEntities.values());
let conditions: Array<(pv: PageViewEntity) => boolean> = [];
const queryBuilder: any = {
select: jest.fn().mockReturnThis(),
where: jest.fn((condition, parameters) => {
if (parameters.entityType) {
conditions.push(pv => pv.entityType === parameters.entityType);
}
if (parameters.entityId) {
conditions.push(pv => pv.entityId === parameters.entityId);
}
if (parameters.since) {
conditions.push(pv => pv.timestamp >= parameters.since);
}
return queryBuilder;
}),
andWhere: jest.fn((condition, parameters) => {
if (parameters.entityId) {
conditions.push(pv => pv.entityId === parameters.entityId);
}
if (parameters.until) {
conditions.push(pv => pv.timestamp <= parameters.until);
}
if (parameters.since) { // For countUniqueVisitors's second andWhere
conditions.push(pv => pv.timestamp >= parameters.since);
}
return queryBuilder;
}),
getRawOne: jest.fn(() => {
const filteredResult = queryResult.filter(pv => conditions.every(cond => cond(pv)));
return Promise.resolve({ count: new Set(filteredResult.map(pv => pv.visitorId)).size });
}),
getMany: jest.fn(() => {
const filteredResult = queryResult.filter(pv => conditions.every(cond => cond(pv)));
return Promise.resolve(filteredResult);
}),
};
return queryBuilder;
}),
query: jest.fn(() => Promise.resolve([])),
};
beforeAll(async () => {
module = await Test.createTestingModule({
providers: [
TypeOrmPageViewRepository,
{
provide: getRepositoryToken(PageViewEntity),
useValue: mockPageViewRepository,
},
{ provide: 'IPageViewRepository', useClass: TypeOrmPageViewRepository },
],
}).compile();
repository = module.get<IPageViewRepository>('IPageViewRepository');
pageViewTypeOrmRepository = module.get<Repository<PageViewEntity>>(
getRepositoryToken(PageViewEntity),
);
});
beforeEach(async () => {
// Clear the database before each test
mockPageViewEntities.clear(); // Clear the mock in-memory database
jest.clearAllMocks();
});
afterAll(async () => {
// await module.close();
});
it('should be defined', () => {
expect(repository).toBeDefined();
});
it('should save a page view', async () => {
const pageView = PageView.create({
id: uuid(),
entityType: EntityType.LEAGUE,
entityId: uuid(),
visitorType: VisitorType.ANONYMOUS,
sessionId: uuid(),
timestamp: new Date(),
});
await repository.save(pageView);
const foundPageView = await repository.findById(pageView.id);
expect(foundPageView).toBeDefined();
expect(foundPageView?.id).toBe(pageView.id);
expect(foundPageView?.entityType).toBe(pageView.entityType);
});
it('should find a page view by ID', async () => {
const pageView = PageView.create({
id: uuid(),
entityType: EntityType.DRIVER,
entityId: uuid(),
visitorType: VisitorType.DRIVER,
sessionId: uuid(),
timestamp: new Date(),
});
await repository.save(pageView);
const foundPageView = await repository.findById(pageView.id);
expect(foundPageView).toBeDefined();
expect(foundPageView?.id).toBe(pageView.id);
});
it('should return null if page view not found by ID', async () => {
const foundPageView = await repository.findById(uuid());
expect(foundPageView).toBeNull();
});
it('should find page views by entity ID', async () => {
const entityId = uuid();
const pageView1 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date() });
const pageView2 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date(Date.now() - 1000) });
await repository.save(pageView1);
await repository.save(pageView2);
const foundViews = await repository.findByEntityId(EntityType.LEAGUE, entityId);
expect(foundViews).toHaveLength(2);
expect(foundViews[0]?.id).toEqual(pageView1.id); // Should be ordered by timestamp DESC
});
it('should count page views by entity ID', async () => {
const entityId = uuid();
const pageView1 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date() });
const pageView2 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date(Date.now() - 1000 * 60 * 60) });
await repository.save(pageView1);
await repository.save(pageView2);
const count = await repository.countByEntityId(EntityType.LEAGUE, entityId);
expect(count).toBe(2);
});
it('should count unique visitors by entity ID', async () => {
const entityId = uuid();
const visitorId1 = uuid();
const visitorId2 = uuid();
// Two views from visitor1
await repository.save(PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorId: visitorId1, visitorType: VisitorType.DRIVER, sessionId: uuid(), timestamp: new Date() }));
await repository.save(PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorId: visitorId1, visitorType: VisitorType.DRIVER, sessionId: uuid(), timestamp: new Date() }));
// One view from visitor2
await repository.save(PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorId: visitorId2, visitorType: VisitorType.DRIVER, sessionId: uuid(), timestamp: new Date() }));
const count = await repository.countUniqueVisitors(EntityType.LEAGUE, entityId);
expect(count).toBe(2);
});
it('should return 0 for count unique visitors if no visitors', async () => {
const count = await repository.countUniqueVisitors(EntityType.LEAGUE, uuid());
expect(count).toBe(0);
});
it('should find page views by date range', async () => {
const entityId = uuid();
const today = new Date();
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const twoDaysAgo = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
const pvToday = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: today });
const pvYesterday = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: yesterday });
const pvTwoDaysAgo = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId, visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: twoDaysAgo });
await repository.save(pvToday);
await repository.save(pvYesterday);
await repository.save(pvTwoDaysAgo);
const result = await repository.findByDateRange(twoDaysAgo, today);
expect(result.length).toBe(3);
expect(result.map(p => p?.id)).toEqual(expect.arrayContaining([pvToday.id, pvYesterday.id, pvTwoDaysAgo.id]));
const resultToday = await repository.findByDateRange(today, today);
expect(resultToday.length).toBe(1);
expect(resultToday[0]?.id).toBe(pvToday.id);
});
it('should find page views by session ID', async () => {
const sessionId = uuid();
const entityId1 = uuid();
const entityId2 = uuid();
const pv1 = PageView.create({ id: uuid(), entityType: EntityType.LEAGUE, entityId: entityId1, visitorType: VisitorType.ANONYMOUS, sessionId, timestamp: new Date() });
const pv2 = PageView.create({ id: uuid(), entityType: EntityType.DRIVER, entityId: entityId2, visitorType: VisitorType.ANONYMOUS, sessionId, timestamp: new Date(Date.now() - 100) });
const pvOtherSession = PageView.create({ id: uuid(), entityType: EntityType.TEAM, entityId: uuid(), visitorType: VisitorType.ANONYMOUS, sessionId: uuid(), timestamp: new Date() });
await repository.save(pv1);
await repository.save(pv2);
await repository.save(pvOtherSession);
const foundViews = await repository.findBySession(sessionId);
expect(foundViews).toHaveLength(2);
expect(foundViews.map(p => p.id)).toEqual(expect.arrayContaining([pv1.id, pv2.id]));
// Should be ordered by timestamp DESC
expect(foundViews[0]?.id).toBe(pv1.id);
expect(foundViews[1]?.id).toBe(pv2.id);
});
});

View File

@@ -1,74 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThanOrEqual, Between } from 'typeorm';
import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
import { PageViewEntity } from './typeorm-page-view.entity';
import { PageViewMapper } from './mappers/PageViewMapper';
import { PageView } from '@gridpilot/analytics/domain/entities/PageView';
import { EntityType } from '@gridpilot/analytics/domain/types/PageView';
@Injectable()
export class TypeOrmPageViewRepository implements IPageViewRepository {
constructor(
@InjectRepository(PageViewEntity)
private readonly pageViewRepository: Repository<PageViewEntity>,
) {}
async save(pageView: PageView): Promise<void> {
const pageViewEntity = PageViewMapper.toPersistence(pageView);
await this.pageViewRepository.save(pageViewEntity);
}
async findById(id: string): Promise<PageView | null> {
const entity = await this.pageViewRepository.findOne({
where: { id },
});
return entity ? PageViewMapper.toDomain(entity) : null;
}
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
const entities = await this.pageViewRepository.find({
where: { entityType, entityId },
order: { timestamp: 'DESC' },
});
return entities.map(PageViewMapper.toDomain);
}
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
const entities = await this.pageViewRepository.find({
where: { timestamp: Between(startDate, endDate) },
order: { timestamp: 'DESC' },
});
return entities.map(PageViewMapper.toDomain);
}
async findBySession(sessionId: string): Promise<PageView[]> {
const entities = await this.pageViewRepository.find({
where: { sessionId },
order: { timestamp: 'DESC' },
});
return entities.map(PageViewMapper.toDomain);
}
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
const where: any = { entityType, entityId };
if (since) {
where.timestamp = MoreThanOrEqual(since);
}
return this.pageViewRepository.count({ where });
}
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
const query = this.pageViewRepository.createQueryBuilder('page_view')
.select('COUNT(DISTINCT "visitorId")', 'count')
.where('page_view.entityType = :entityType', { entityType })
.andWhere('page_view.entityId = :entityId', { entityId });
if (since) {
query.andWhere('page_view.timestamp >= :since', { since });
}
const result = await query.getRawOne();
return parseInt(result.count, 10) || 0;
}
}

3
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"workspaces": [
"core/*",
"apps/*"
"apps/*",
"testing/*"
],
"dependencies": {
"@gridpilot/social": "file:core/social",