wip
This commit is contained in:
@@ -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 {}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
3
package-lock.json
generated
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"core/*",
|
||||
"apps/*"
|
||||
"apps/*",
|
||||
"testing/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@gridpilot/social": "file:core/social",
|
||||
|
||||
Reference in New Issue
Block a user