This commit is contained in:
2025-12-15 14:44:42 +01:00
parent 5c22f8820c
commit 95d0bf5aee
28 changed files with 185 additions and 67 deletions

View File

@@ -1,16 +1,15 @@
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 { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
import { IEngagementRepository } from '@gridpilot/analytics/domain/repositories/IEngagementRepository';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
import { InMemoryPageViewRepository } from '../../infrastructure/analytics/in-memory-page-view.repository';
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: [], // Removed TypeOrmModule as we are using in-memory repositories
imports: [TypeOrmModule.forFeature([PageViewEntity])],
controllers: [AnalyticsController],
providers: [
AnalyticsService,
@@ -18,7 +17,7 @@ import { InMemoryEngagementRepository } from '../../infrastructure/analytics/in-
RecordEngagementUseCase,
{
provide: 'IPageViewRepository',
useClass: InMemoryPageViewRepository,
useClass: TypeOrmPageViewRepository,
},
{
provide: 'IEngagementRepository',

View File

@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordPageViewUseCase, RecordPageViewInput, RecordPageViewOutput } from './record-page-view.use-case';
import { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
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';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PageView, EntityType, VisitorType } from '@gridpilot/analytics/domain/entities/PageView';
import type { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
import type { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
@Injectable()
export class InMemoryPageViewRepository implements IPageViewRepository {

View File

@@ -0,0 +1,108 @@
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

@@ -0,0 +1,39 @@
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,13 +1,11 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
import { EntityType, VisitorType } from '@gridpilot/analytics/domain/types/PageView';
@Entity('page_views')
export class PageViewEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;
@Column({ type: 'enum', enum: EntityType })
entityType: EntityType;
@Column({ type: 'enum', enum: ['league', 'driver', 'team', 'race', 'sponsor'] })
entityType: string;
@Column({ type: 'uuid' })
entityId: string;
@@ -15,8 +13,8 @@ export class PageViewEntity {
@Column({ type: 'uuid', nullable: true })
visitorId?: string;
@Column({ type: 'enum', enum: VisitorType })
visitorType: VisitorType;
@Column({ type: 'enum', enum: ['anonymous', 'driver', 'sponsor'] })
visitorType: string;
@Column({ type: 'uuid' })
sessionId: string;

View File

@@ -5,7 +5,7 @@ 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/domain/repositories/IPageViewRepository';
import { IPageViewRepository } from '@gridpilot/analytics/application/repositories/IPageViewRepository';
import { v4 as uuid } from 'uuid';
describe('TypeOrmPageViewRepository (Integration)', () => {

View File

@@ -1,9 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThanOrEqual, Between } from 'typeorm';
import { PageView, EntityType } from '@gridpilot/analytics/domain/entities/PageView';
import { IPageViewRepository } from '@gridpilot/analytics/domain/repositories/IPageViewRepository';
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 {
@@ -13,7 +15,7 @@ export class TypeOrmPageViewRepository implements IPageViewRepository {
) {}
async save(pageView: PageView): Promise<void> {
const pageViewEntity = this.toPageViewEntity(pageView);
const pageViewEntity = PageViewMapper.toPersistence(pageView);
await this.pageViewRepository.save(pageViewEntity);
}
@@ -21,7 +23,7 @@ export class TypeOrmPageViewRepository implements IPageViewRepository {
const entity = await this.pageViewRepository.findOne({
where: { id },
});
return entity ? this.toPageView(entity) : null;
return entity ? PageViewMapper.toDomain(entity) : null;
}
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
@@ -29,7 +31,7 @@ export class TypeOrmPageViewRepository implements IPageViewRepository {
where: { entityType, entityId },
order: { timestamp: 'DESC' },
});
return entities.map(this.toPageView);
return entities.map(PageViewMapper.toDomain);
}
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
@@ -37,7 +39,7 @@ export class TypeOrmPageViewRepository implements IPageViewRepository {
where: { timestamp: Between(startDate, endDate) },
order: { timestamp: 'DESC' },
});
return entities.map(this.toPageView);
return entities.map(PageViewMapper.toDomain);
}
async findBySession(sessionId: string): Promise<PageView[]> {
@@ -45,7 +47,7 @@ export class TypeOrmPageViewRepository implements IPageViewRepository {
where: { sessionId },
order: { timestamp: 'DESC' },
});
return entities.map(this.toPageView);
return entities.map(PageViewMapper.toDomain);
}
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
@@ -69,36 +71,4 @@ export class TypeOrmPageViewRepository implements IPageViewRepository {
const result = await query.getRawOne();
return parseInt(result.count, 10) || 0;
}
public toPageViewEntity(pageView: PageView): PageViewEntity {
const entity = new PageViewEntity();
entity.id = pageView.id;
entity.entityType = pageView.entityType;
entity.entityId = pageView.entityId;
entity.visitorId = pageView.visitorId;
entity.visitorType = pageView.visitorType;
entity.sessionId = pageView.sessionId;
entity.referrer = pageView.referrer;
entity.userAgent = pageView.userAgent;
entity.country = pageView.country;
entity.timestamp = pageView.timestamp;
entity.durationMs = pageView.durationMs;
return entity;
}
private toPageView(entity: PageViewEntity): PageView {
return PageView.create({
id: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
visitorType: entity.visitorType,
sessionId: entity.sessionId,
timestamp: entity.timestamp,
visitorId: entity.visitorId,
referrer: entity.referrer,
userAgent: entity.userAgent,
country: entity.country,
durationMs: entity.durationMs,
});
}
}

View File

@@ -1,4 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "es2017",
"module": "commonjs",

View File

@@ -1,10 +1,5 @@
/**
* Repository Interface: IPageViewRepository
*
* Defines persistence operations for PageView entities.
*/
import type { PageView, EntityType } from '../entities/PageView';
import { PageView } from '../../domain/entities/PageView';
import { EntityType } from '../../domain/types/PageView';
export interface IPageViewRepository {
save(pageView: PageView): Promise<void>;
@@ -14,4 +9,4 @@ export interface IPageViewRepository {
findBySession(sessionId: string): Promise<PageView[]>;
countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
}
}

View File

@@ -1,11 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": false,
"declaration": true,
"declarationMap": false
},
"include": ["src"]
"include": ["**/*.ts"]
}

View File

@@ -8,7 +8,8 @@
},
"workspaces": [
"core/*",
"apps/*"
"apps/*",
"testing/*"
],
"scripts": {
"dev": "echo 'Development server placeholder - to be configured'",

View File

@@ -1,5 +1,5 @@
{
"name": "@gridpilot/testing-support",
"name": "@gridpilot/testing",
"version": "0.1.0",
"private": true,
"main": "./index.ts",

9
testing/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": false
},
"include": ["**/*.ts"]
}