presenter refactoring

This commit is contained in:
2025-12-20 17:06:11 +01:00
parent 92be9d2e1b
commit e9d6f90bb2
109 changed files with 4159 additions and 1283 deletions

View File

@@ -42,8 +42,8 @@ describe('AnalyticsController', () => {
userAgent: 'Mozilla/5.0',
country: 'US',
};
const output = { pageViewId: 'pv-123' };
service.recordPageView.mockResolvedValue(output);
const presenterMock = { viewModel: { pageViewId: 'pv-123' } };
service.recordPageView.mockResolvedValue(presenterMock as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -54,7 +54,7 @@ describe('AnalyticsController', () => {
expect(service.recordPageView).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(output);
expect(mockRes.json).toHaveBeenCalledWith(presenterMock.viewModel);
});
});
@@ -69,8 +69,8 @@ describe('AnalyticsController', () => {
actorId: 'actor-789',
metadata: { key: 'value' },
};
const output = { eventId: 'event-123', engagementWeight: 10 };
service.recordEngagement.mockResolvedValue(output);
const presenterMock = { eventId: 'event-123', engagementWeight: 10 };
service.recordEngagement.mockResolvedValue(presenterMock as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -81,41 +81,45 @@ describe('AnalyticsController', () => {
expect(service.recordEngagement).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(output);
expect(mockRes.json).toHaveBeenCalledWith((presenterMock as any).viewModel);
});
});
describe('getDashboardData', () => {
it('should return dashboard data', async () => {
const output = {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
const presenterMock = {
viewModel: {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
},
};
service.getDashboardData.mockResolvedValue(output);
service.getDashboardData.mockResolvedValue(presenterMock as any);
const result = await controller.getDashboardData();
expect(service.getDashboardData).toHaveBeenCalled();
expect(result).toEqual(output);
expect(result).toEqual(presenterMock.viewModel);
});
});
describe('getAnalyticsMetrics', () => {
it('should return analytics metrics', async () => {
const output = {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
const presenterMock = {
viewModel: {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
},
};
service.getAnalyticsMetrics.mockResolvedValue(output);
service.getAnalyticsMetrics.mockResolvedValue(presenterMock as any);
const result = await controller.getAnalyticsMetrics();
expect(service.getAnalyticsMetrics).toHaveBeenCalled();
expect(result).toEqual(output);
expect(result).toEqual(presenterMock.viewModel);
});
});
});

View File

@@ -10,11 +10,7 @@ import { GetAnalyticsMetricsOutputDTO } from './dtos/GetAnalyticsMetricsOutputDT
import { AnalyticsService } from './AnalyticsService';
type RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
type RecordEngagementInput = RecordEngagementInputDTO;
type RecordEngagementOutput = RecordEngagementOutputDTO;
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
@ApiTags('analytics')
@Controller('analytics')
@@ -31,8 +27,8 @@ export class AnalyticsController {
@Body() input: RecordPageViewInput,
@Res() res: Response,
): Promise<void> {
const output: RecordPageViewOutput = await this.analyticsService.recordPageView(input);
res.status(HttpStatus.CREATED).json(output);
const presenter = await this.analyticsService.recordPageView(input);
res.status(HttpStatus.CREATED).json(presenter.viewModel);
}
@Post('engagement')
@@ -43,21 +39,23 @@ export class AnalyticsController {
@Body() input: RecordEngagementInput,
@Res() res: Response,
): Promise<void> {
const output: RecordEngagementOutput = await this.analyticsService.recordEngagement(input);
res.status(HttpStatus.CREATED).json(output);
const presenter = await this.analyticsService.recordEngagement(input);
res.status(HttpStatus.CREATED).json(presenter.viewModel);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get analytics dashboard data' })
@ApiResponse({ status: 200, description: 'Dashboard data', type: GetDashboardDataOutputDTO })
async getDashboardData(): Promise<GetDashboardDataOutput> {
return await this.analyticsService.getDashboardData();
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
const presenter = await this.analyticsService.getDashboardData();
return presenter.viewModel;
}
@Get('metrics')
@ApiOperation({ summary: 'Get analytics metrics' })
@ApiResponse({ status: 200, description: 'Analytics metrics', type: GetAnalyticsMetricsOutputDTO })
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> {
return await this.analyticsService.getAnalyticsMetrics();
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
const presenter = await this.analyticsService.getAnalyticsMetrics();
return presenter.viewModel;
}
}

View File

@@ -9,13 +9,13 @@ import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/Rec
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import { GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
type RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
type RecordEngagementInput = RecordEngagementInputDTO;
type RecordEngagementOutput = RecordEngagementOutputDTO;
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
@@ -31,19 +31,27 @@ export class AnalyticsService {
@Inject(GET_ANALYTICS_METRICS_USE_CASE_TOKEN) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
) {}
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
return await this.recordPageViewUseCase.execute(input);
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewPresenter> {
const presenter = new RecordPageViewPresenter();
await this.recordPageViewUseCase.execute(input, presenter);
return presenter;
}
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
return await this.recordEngagementUseCase.execute(input);
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementPresenter> {
const presenter = new RecordEngagementPresenter();
await this.recordEngagementUseCase.execute(input, presenter);
return presenter;
}
async getDashboardData(): Promise<GetDashboardDataOutput> {
return await this.getDashboardDataUseCase.execute();
async getDashboardData(): Promise<GetDashboardDataPresenter> {
const presenter = new GetDashboardDataPresenter();
await this.getDashboardDataUseCase.execute(undefined, presenter);
return presenter;
}
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> {
return await this.getAnalyticsMetricsUseCase.execute();
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsPresenter> {
const presenter = new GetAnalyticsMetricsPresenter();
await this.getAnalyticsMetricsUseCase.execute(undefined, presenter);
return presenter;
}
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetAnalyticsMetricsPresenter } from './GetAnalyticsMetricsPresenter';
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
describe('GetAnalyticsMetricsPresenter', () => {
let presenter: GetAnalyticsMetricsPresenter;
beforeEach(() => {
presenter = new GetAnalyticsMetricsPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: GetAnalyticsMetricsOutput = {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
};
presenter.present(output);
expect(presenter.viewModel).toEqual({
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: GetAnalyticsMetricsOutput = {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
};
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,24 @@
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOutputDTO';
export class GetAnalyticsMetricsPresenter {
private result: GetAnalyticsMetricsOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: GetAnalyticsMetricsOutput): void {
this.result = {
pageViews: output.pageViews,
uniqueVisitors: output.uniqueVisitors,
averageSessionDuration: output.averageSessionDuration,
bounceRate: output.bounceRate,
};
}
get viewModel(): GetAnalyticsMetricsOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetDashboardDataPresenter } from './GetDashboardDataPresenter';
import type { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
describe('GetDashboardDataPresenter', () => {
let presenter: GetDashboardDataPresenter;
beforeEach(() => {
presenter = new GetDashboardDataPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: GetDashboardDataOutput = {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
};
presenter.present(output);
expect(presenter.viewModel).toEqual({
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: GetDashboardDataOutput = {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
};
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,24 @@
import type { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDTO';
export class GetDashboardDataPresenter {
private result: GetDashboardDataOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: GetDashboardDataOutput): void {
this.result = {
totalUsers: output.totalUsers,
activeUsers: output.activeUsers,
totalRaces: output.totalRaces,
totalLeagues: output.totalLeagues,
};
}
get viewModel(): GetDashboardDataOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RecordEngagementPresenter } from './RecordEngagementPresenter';
import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
describe('RecordEngagementPresenter', () => {
let presenter: RecordEngagementPresenter;
beforeEach(() => {
presenter = new RecordEngagementPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: RecordEngagementOutput = {
eventId: 'event-123',
engagementWeight: 10,
} as RecordEngagementOutput;
presenter.present(output);
expect(presenter.viewModel).toEqual({
eventId: 'event-123',
engagementWeight: 10,
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: RecordEngagementOutput = {
eventId: 'event-123',
engagementWeight: 10,
} as RecordEngagementOutput;
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,22 @@
import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDTO';
export class RecordEngagementPresenter {
private result: RecordEngagementOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: RecordEngagementOutput): void {
this.result = {
eventId: output.eventId,
engagementWeight: output.engagementWeight,
};
}
get viewModel(): RecordEngagementOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { RecordPageViewPresenter } from './RecordPageViewPresenter';
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
describe('RecordPageViewPresenter', () => {
let presenter: RecordPageViewPresenter;
beforeEach(() => {
presenter = new RecordPageViewPresenter();
});
it('maps use case output to DTO correctly', () => {
const output: RecordPageViewOutput = {
pageViewId: 'pv-123',
} as RecordPageViewOutput;
presenter.present(output);
expect(presenter.viewModel).toEqual({
pageViewId: 'pv-123',
});
});
it('reset clears state and causes viewModel to throw', () => {
const output: RecordPageViewOutput = {
pageViewId: 'pv-123',
} as RecordPageViewOutput;
presenter.present(output);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});

View File

@@ -0,0 +1,21 @@
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
export class RecordPageViewPresenter {
private result: RecordPageViewOutputDTO | null = null;
reset() {
this.result = null;
}
present(output: RecordPageViewOutput): void {
this.result = {
pageViewId: output.pageViewId,
};
}
get viewModel(): RecordPageViewOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}