module cleanup

This commit is contained in:
2025-12-19 01:22:45 +01:00
parent d617654928
commit d0fac9e6c1
135 changed files with 5104 additions and 1315 deletions

View File

@@ -0,0 +1,121 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { AnalyticsController } from './AnalyticsController';
import { AnalyticsService } from './AnalyticsService';
import type { Response } from 'express';
import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
describe('AnalyticsController', () => {
let controller: AnalyticsController;
let service: ReturnType<typeof vi.mocked<AnalyticsService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AnalyticsController],
providers: [
{
provide: AnalyticsService,
useValue: {
recordPageView: vi.fn(),
recordEngagement: vi.fn(),
getDashboardData: vi.fn(),
getAnalyticsMetrics: vi.fn(),
},
},
],
}).compile();
controller = module.get<AnalyticsController>(AnalyticsController);
service = vi.mocked(module.get(AnalyticsService));
});
describe('recordPageView', () => {
it('should record a page view and return 201', async () => {
const input = {
entityType: EntityType.RACE,
entityId: 'race-123',
visitorType: VisitorType.ANONYMOUS,
sessionId: 'session-456',
visitorId: 'visitor-789',
referrer: 'https://example.com',
userAgent: 'Mozilla/5.0',
country: 'US',
};
const output = { pageViewId: 'pv-123' };
service.recordPageView.mockResolvedValue(output);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.recordPageView(input, mockRes);
expect(service.recordPageView).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(output);
});
});
describe('recordEngagement', () => {
it('should record an engagement and return 201', async () => {
const input = {
action: EngagementAction.CLICK_SPONSOR_LOGO,
entityType: EngagementEntityType.RACE,
entityId: 'race-123',
actorType: 'driver' as const,
sessionId: 'session-456',
actorId: 'actor-789',
metadata: { key: 'value' },
};
const output = { eventId: 'event-123', engagementWeight: 10 };
service.recordEngagement.mockResolvedValue(output);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.recordEngagement(input, mockRes);
expect(service.recordEngagement).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(output);
});
});
describe('getDashboardData', () => {
it('should return dashboard data', async () => {
const output = {
totalUsers: 100,
activeUsers: 50,
totalRaces: 20,
totalLeagues: 5,
};
service.getDashboardData.mockResolvedValue(output);
const result = await controller.getDashboardData();
expect(service.getDashboardData).toHaveBeenCalled();
expect(result).toEqual(output);
});
});
describe('getAnalyticsMetrics', () => {
it('should return analytics metrics', async () => {
const output = {
pageViews: 1000,
uniqueVisitors: 500,
averageSessionDuration: 300,
bounceRate: 0.4,
};
service.getAnalyticsMetrics.mockResolvedValue(output);
const result = await controller.getAnalyticsMetrics();
expect(service.getAnalyticsMetrics).toHaveBeenCalled();
expect(result).toEqual(output);
});
});
});

View File

@@ -1,12 +1,12 @@
import { Controller, Get, Post, Body, Res, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger';
import type { Response } from 'express';
import type { RecordPageViewInputDTO } from './dtos/RecordPageViewInputDTO';
import type { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
import type { RecordEngagementInputDTO } from './dtos/RecordEngagementInputDTO';
import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO';
import type { GetDashboardDataOutputDTO } from './dtos/GetDashboardDataOutputDTO';
import type { GetAnalyticsMetricsOutputDTO } from './dtos/GetAnalyticsMetricsOutputDTO';
import { RecordPageViewInputDTO } from './dtos/RecordPageViewInputDTO';
import { RecordPageViewOutputDTO } from './dtos/RecordPageViewOutputDTO';
import { RecordEngagementInputDTO } from './dtos/RecordEngagementInputDTO';
import { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO';
import { GetDashboardDataOutputDTO } from './dtos/GetDashboardDataOutputDTO';
import { GetAnalyticsMetricsOutputDTO } from './dtos/GetAnalyticsMetricsOutputDTO';
import { AnalyticsService } from './AnalyticsService';
type RecordPageViewInput = RecordPageViewInputDTO;

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AnalyticsModule } from './AnalyticsModule';
import { AnalyticsController } from './AnalyticsController';
import { AnalyticsService } from './AnalyticsService';
describe('AnalyticsModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [AnalyticsModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide AnalyticsController', () => {
const controller = module.get<AnalyticsController>(AnalyticsController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(AnalyticsController);
});
it('should provide AnalyticsService', () => {
const service = module.get<AnalyticsService>(AnalyticsService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(AnalyticsService);
});
});

View File

@@ -1,26 +1,27 @@
import { Provider } from '@nestjs/common';
import { AnalyticsService } from './AnalyticsService';
import { RecordPageViewUseCase } from './use-cases/RecordPageViewUseCase';
import { RecordEngagementUseCase } from './use-cases/RecordEngagementUseCase';
import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
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 type { IPageViewRepository } from '@core/analytics/domain/repositories/IPageViewRepository';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { Logger } from '@core/shared/application';
const Logger_TOKEN = 'Logger_TOKEN';
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN';
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
const GET_DASHBOARD_DATA_USE_CASE_TOKEN = 'GetDashboardDataUseCase_TOKEN';
const GET_ANALYTICS_METRICS_USE_CASE_TOKEN = 'GetAnalyticsMetricsUseCase_TOKEN';
import type { Logger } from '@core/shared/application';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
import { InMemoryEngagementRepository } from '@adapters/analytics/persistence/inmemory/InMemoryEngagementRepository';
import { InMemoryPageViewRepository } from '@adapters/analytics/persistence/inmemory/InMemoryPageViewRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
export const AnalyticsProviders: Provider[] = [
AnalyticsService,
RecordPageViewUseCase,
RecordEngagementUseCase,
{
provide: Logger_TOKEN,
useClass: ConsoleLogger,
@@ -35,10 +36,22 @@ export const AnalyticsProviders: Provider[] = [
},
{
provide: RECORD_PAGE_VIEW_USE_CASE_TOKEN,
useClass: RecordPageViewUseCase,
useFactory: (repo: IPageViewRepository, logger: Logger) => new RecordPageViewUseCase(repo, logger),
inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN],
},
{
provide: RECORD_ENGAGEMENT_USE_CASE_TOKEN,
useClass: RecordEngagementUseCase,
useFactory: (repo: IEngagementRepository, logger: Logger) => new RecordEngagementUseCase(repo, logger),
inject: [IENGAGEMENT_REPO_TOKEN, Logger_TOKEN],
},
{
provide: GET_DASHBOARD_DATA_USE_CASE_TOKEN,
useFactory: (logger: Logger) => new GetDashboardDataUseCase(logger),
inject: [Logger_TOKEN],
},
{
provide: GET_ANALYTICS_METRICS_USE_CASE_TOKEN,
useFactory: (repo: IPageViewRepository, logger: Logger) => new GetAnalyticsMetricsUseCase(repo, logger),
inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN],
},
];

View File

@@ -5,9 +5,10 @@ import type { RecordEngagementInputDTO } from './dtos/RecordEngagementInputDTO';
import type { RecordEngagementOutputDTO } from './dtos/RecordEngagementOutputDTO';
import type { GetDashboardDataOutputDTO } from './dtos/GetDashboardDataOutputDTO';
import type { GetAnalyticsMetricsOutputDTO } from './dtos/GetAnalyticsMetricsOutputDTO';
import type { Logger } from '@core/shared/application';
import { RecordPageViewUseCase } from './use-cases/RecordPageViewUseCase';
import { RecordEngagementUseCase } from './use-cases/RecordEngagementUseCase';
import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
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';
type RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
@@ -16,16 +17,18 @@ type RecordEngagementOutput = RecordEngagementOutputDTO;
type GetDashboardDataOutput = GetDashboardDataOutputDTO;
type GetAnalyticsMetricsOutput = GetAnalyticsMetricsOutputDTO;
const Logger_TOKEN = 'Logger_TOKEN';
const RECORD_PAGE_VIEW_USE_CASE_TOKEN = 'RecordPageViewUseCase_TOKEN';
const RECORD_ENGAGEMENT_USE_CASE_TOKEN = 'RecordEngagementUseCase_TOKEN';
const GET_DASHBOARD_DATA_USE_CASE_TOKEN = 'GetDashboardDataUseCase_TOKEN';
const GET_ANALYTICS_METRICS_USE_CASE_TOKEN = 'GetAnalyticsMetricsUseCase_TOKEN';
@Injectable()
export class AnalyticsService {
constructor(
@Inject(RECORD_PAGE_VIEW_USE_CASE_TOKEN) private readonly recordPageViewUseCase: RecordPageViewUseCase,
@Inject(RECORD_ENGAGEMENT_USE_CASE_TOKEN) private readonly recordEngagementUseCase: RecordEngagementUseCase,
@Inject(Logger_TOKEN) private readonly logger: Logger,
@Inject(GET_DASHBOARD_DATA_USE_CASE_TOKEN) private readonly getDashboardDataUseCase: GetDashboardDataUseCase,
@Inject(GET_ANALYTICS_METRICS_USE_CASE_TOKEN) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
) {}
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
@@ -37,22 +40,10 @@ export class AnalyticsService {
}
async getDashboardData(): Promise<GetDashboardDataOutput> {
// TODO: Implement actual dashboard data retrieval
return {
totalUsers: 0,
activeUsers: 0,
totalRaces: 0,
totalLeagues: 0,
};
return await this.getDashboardDataUseCase.execute();
}
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutput> {
// TODO: Implement actual analytics metrics retrieval
return {
pageViews: 0,
uniqueVisitors: 0,
averageSessionDuration: 0,
bounceRate: 0,
};
return await this.getAnalyticsMetricsUseCase.execute();
}
}

View File

@@ -1,12 +0,0 @@
// From core/analytics/domain/types/EngagementEvent.ts
export enum EngagementAction {
CLICK_SPONSOR_LOGO = 'click_sponsor_logo',
CLICK_SPONSOR_URL = 'click_sponsor_url',
DOWNLOAD_LIVERY_PACK = 'download_livery_pack',
JOIN_LEAGUE = 'join_league',
REGISTER_RACE = 'register_race',
VIEW_STANDINGS = 'view_standings',
VIEW_SCHEDULE = 'view_schedule',
SHARE_SOCIAL = 'share_social',
CONTACT_SPONSOR = 'contact_sponsor',
}

View File

@@ -1,9 +0,0 @@
// From core/analytics/domain/types/EngagementEvent.ts
export enum EngagementEntityType {
LEAGUE = 'league',
DRIVER = 'driver',
TEAM = 'team',
RACE = 'race',
SPONSOR = 'sponsor',
SPONSORSHIP = 'sponsorship',
}

View File

@@ -1,8 +0,0 @@
// From core/analytics/domain/types/PageView.ts
export enum EntityType {
LEAGUE = 'league',
DRIVER = 'driver',
TEAM = 'team',
RACE = 'race',
SPONSOR = 'sponsor',
}

View File

@@ -1,7 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsEnum, IsObject } from 'class-validator';
import { EngagementAction } from './EngagementAction';
import { EngagementEntityType } from './EngagementEntityType';
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
export class RecordEngagementInputDTO {
@ApiProperty({ enum: EngagementAction })

View File

@@ -1,7 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { EntityType } from './EntityType';
import { VisitorType } from './VisitorType';
import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
export class RecordPageViewInputDTO {
@ApiProperty({ enum: EntityType })

View File

@@ -1,6 +0,0 @@
// From core/analytics/domain/types/PageView.ts
export enum VisitorType {
ANONYMOUS = 'anonymous',
DRIVER = 'driver',
SPONSOR = 'sponsor',
}

View File

@@ -1,88 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordEngagementUseCase } from './RecordEngagementUseCase';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { Logger } from '@core/shared/application';
describe('RecordEngagementUseCase', () => {
let useCase: RecordEngagementUseCase;
let engagementRepository: jest.Mocked<IEngagementRepository>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordEngagementUseCase,
{
provide: 'IEngagementRepository_TOKEN',
useValue: {
save: jest.fn(),
},
},
{
provide: 'Logger_TOKEN',
useValue: {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
},
},
],
}).compile();
useCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
engagementRepository = module.get('IEngagementRepository_TOKEN');
logger = module.get('Logger_TOKEN');
});
describe('execute', () => {
it('should save the engagement event and return the eventId and engagementWeight', async () => {
const input = {
action: 'like' as any,
entityType: 'race' as any,
entityId: 'race-123',
actorType: 'driver',
sessionId: 'session-456',
actorId: 'actor-789',
metadata: { some: 'data' },
};
const mockEvent = {
getEngagementWeight: jest.fn().mockReturnValue(10),
};
// Mock the create function to return the mock event
const originalCreate = require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create;
require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create = jest.fn().mockReturnValue(mockEvent);
engagementRepository.save.mockResolvedValue(undefined);
const result = await useCase.execute(input);
expect(logger.debug).toHaveBeenCalledWith('Executing RecordEngagementUseCase', { input });
expect(engagementRepository.save).toHaveBeenCalledWith(mockEvent);
expect(logger.info).toHaveBeenCalledWith('Engagement recorded successfully', expect.objectContaining({ eventId: expect.any(String), input }));
expect(result).toHaveProperty('eventId');
expect(result).toHaveProperty('engagementWeight', 10);
expect(typeof result.eventId).toBe('string');
// Restore original
require('@gridpilot/analytics/domain/entities/EngagementEvent').EngagementEvent.create = originalCreate;
});
it('should handle errors and throw them', async () => {
const input = {
action: 'like' as any,
entityType: 'race' as any,
entityId: 'race-123',
actorType: 'driver',
sessionId: 'session-456',
};
const error = new Error('Save failed');
engagementRepository.save.mockRejectedValue(error);
await expect(useCase.execute(input)).rejects.toThrow('Save failed');
expect(logger.error).toHaveBeenCalledWith('Error recording engagement', error, { input });
});
});
});

View File

@@ -1,53 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import type { RecordEngagementInputDTO } from '../dtos/RecordEngagementInputDTO';
import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDTO';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { Logger } from '@core/shared/application';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
type RecordEngagementInput = RecordEngagementInputDTO;
type RecordEngagementOutput = RecordEngagementOutputDTO;
const Logger_TOKEN = 'Logger_TOKEN';
const IENGAGEMENT_REPO_TOKEN = 'IEngagementRepository_TOKEN';
@Injectable()
export class RecordEngagementUseCase {
constructor(
@Inject(IENGAGEMENT_REPO_TOKEN) private readonly engagementRepository: IEngagementRepository,
@Inject(Logger_TOKEN) private readonly logger: Logger,
) {}
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 as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
entityType: input.entityType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
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,76 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordPageViewUseCase } from './RecordPageViewUseCase';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { Logger } from '@core/shared/application';
describe('RecordPageViewUseCase', () => {
let useCase: RecordPageViewUseCase;
let pageViewRepository: jest.Mocked<IPageViewRepository>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordPageViewUseCase,
{
provide: 'IPageViewRepository_TOKEN',
useValue: {
save: jest.fn(),
},
},
{
provide: 'Logger_TOKEN',
useValue: {
debug: jest.fn(),
info: jest.fn(),
error: jest.fn(),
},
},
],
}).compile();
useCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
pageViewRepository = module.get('IPageViewRepository_TOKEN');
logger = module.get('Logger_TOKEN');
});
describe('execute', () => {
it('should save the page view and return the pageViewId', async () => {
const input = {
entityType: 'race' as any,
entityId: 'race-123',
visitorType: 'anonymous' as any,
sessionId: 'session-456',
visitorId: 'visitor-789',
referrer: 'https://example.com',
userAgent: 'Mozilla/5.0',
country: 'US',
};
pageViewRepository.save.mockResolvedValue(undefined);
const result = await useCase.execute(input);
expect(logger.debug).toHaveBeenCalledWith('Executing RecordPageViewUseCase', { input });
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith('Page view recorded successfully', expect.objectContaining({ pageViewId: expect.any(String), input }));
expect(result).toHaveProperty('pageViewId');
expect(typeof result.pageViewId).toBe('string');
});
it('should handle errors and throw them', async () => {
const input = {
entityType: 'race' as any,
entityId: 'race-123',
visitorType: 'anonymous' as any,
sessionId: 'session-456',
};
const error = new Error('Save failed');
pageViewRepository.save.mockRejectedValue(error);
await expect(useCase.execute(input)).rejects.toThrow('Save failed');
expect(logger.error).toHaveBeenCalledWith('Error recording page view', error, { input });
});
});
});

View File

@@ -1,50 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import type { RecordPageViewInputDTO } from '../dtos/RecordPageViewInputDTO';
import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { Logger } from '@core/shared/application';
import { PageView } from '@core/analytics/domain/entities/PageView';
type RecordPageViewInput = RecordPageViewInputDTO;
type RecordPageViewOutput = RecordPageViewOutputDTO;
const Logger_TOKEN = 'Logger_TOKEN';
const IPAGE_VIEW_REPO_TOKEN = 'IPageViewRepository_TOKEN';
@Injectable()
export class RecordPageViewUseCase {
constructor(
@Inject(IPAGE_VIEW_REPO_TOKEN) private readonly pageViewRepository: IPageViewRepository,
@Inject(Logger_TOKEN) private readonly logger: Logger,
) {}
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 as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
entityId: input.entityId,
visitorType: input.visitorType as any, // Cast to any to bypass strict type checking, will resolve with proper domain layer alignment
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

@@ -0,0 +1,108 @@
import { vi } from 'vitest';
import { AuthController } from './AuthController';
import { AuthService } from './AuthService';
import { SignupParams, LoginParams, AuthSessionDTO } from './dtos/AuthDto';
describe('AuthController', () => {
let controller: AuthController;
let service: ReturnType<typeof vi.mocked<AuthService>>;
beforeEach(() => {
service = vi.mocked<AuthService>({
signupWithEmail: vi.fn(),
loginWithEmail: vi.fn(),
getCurrentSession: vi.fn(),
logout: vi.fn(),
});
controller = new AuthController(service);
});
describe('signup', () => {
it('should call service.signupWithEmail and return session', async () => {
const params: SignupParams = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
iracingCustomerId: '12345',
primaryDriverId: 'driver1',
avatarUrl: 'http://example.com/avatar.jpg',
};
const session: AuthSessionDTO = {
token: 'token123',
user: {
userId: 'user1',
email: 'test@example.com',
displayName: 'Test User',
},
};
service.signupWithEmail.mockResolvedValue(session);
const result = await controller.signup(params);
expect(service.signupWithEmail).toHaveBeenCalledWith(params);
expect(result).toEqual(session);
});
});
describe('login', () => {
it('should call service.loginWithEmail and return session', async () => {
const params: LoginParams = {
email: 'test@example.com',
password: 'password123',
};
const session: AuthSessionDTO = {
token: 'token123',
user: {
userId: 'user1',
email: 'test@example.com',
displayName: 'Test User',
},
};
service.loginWithEmail.mockResolvedValue(session);
const result = await controller.login(params);
expect(service.loginWithEmail).toHaveBeenCalledWith(params);
expect(result).toEqual(session);
});
});
describe('getSession', () => {
it('should call service.getCurrentSession and return session', async () => {
const session: AuthSessionDTO = {
token: 'token123',
user: {
userId: 'user1',
email: 'test@example.com',
displayName: 'Test User',
},
};
service.getCurrentSession.mockResolvedValue(session);
const result = await controller.getSession();
expect(service.getCurrentSession).toHaveBeenCalled();
expect(result).toEqual(session);
});
it('should return null if no session', async () => {
service.getCurrentSession.mockResolvedValue(null);
const result = await controller.getSession();
expect(result).toBeNull();
});
});
describe('logout', () => {
it('should call service.logout', async () => {
service.logout.mockResolvedValue(undefined);
await controller.logout();
expect(service.logout).toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Body, Query, Res, Redirect, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { Controller, Get, Post, Body } from '@nestjs/common';
import { AuthService } from './AuthService';
import { LoginParams, SignupParams, LoginWithIracingCallbackParams, AuthSessionDTO, IracingAuthRedirectResult } from './dto/AuthDto';
import { LoginParams, SignupParams, AuthSessionDTO } from './dtos/AuthDto';
@Controller('auth')
export class AuthController {
@@ -27,16 +26,4 @@ export class AuthController {
return this.authService.logout();
}
@Get('iracing/start')
async startIracingAuthRedirect(@Query('returnTo') returnTo?: string, @Res() res?: Response): Promise<void> {
const { redirectUrl, state } = await this.authService.startIracingAuthRedirect(returnTo);
// In real application, you might want to store 'state' in a secure cookie or session.
// For this example, we'll just redirect.
res.redirect(HttpStatus.FOUND, redirectUrl);
}
@Get('iracing/callback')
async loginWithIracingCallback(@Query('code') code: string, @Query('state') state: string, @Query('returnTo') returnTo?: string): Promise<AuthSessionDTO> {
return this.authService.loginWithIracingCallback({ code, state, returnTo });
}
}

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthModule } from './AuthModule';
import { AuthController } from './AuthController';
import { AuthService } from './AuthService';
describe('AuthModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [AuthModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide AuthController', () => {
const controller = module.get<AuthController>(AuthController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(AuthController);
});
it('should provide AuthService', () => {
const service = module.get<AuthService>(AuthService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(AuthService);
});
});

View File

@@ -5,7 +5,7 @@ import { AuthProviders } from './AuthProviders';
@Module({
controllers: [AuthController],
providers: AuthProviders,
providers: [AuthService, ...AuthProviders],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -1,9 +1,7 @@
import { Provider } from '@nestjs/common';
import { AuthService } from './AuthService';
// Import interfaces and concrete implementations
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
import { IUserRepository, StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import { StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { Logger } from '@core/shared/application';
@@ -11,7 +9,6 @@ import { InMemoryAuthRepository } from '@adapters/identity/persistence/inmemory/
import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/InMemoryUserRepository';
import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieIdentitySessionAdapter';
// Define the tokens for dependency injection
@@ -22,10 +19,9 @@ export const LOGGER_TOKEN = 'Logger';
export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort';
export const AuthProviders: Provider[] = [
AuthService, // Provide the service itself
{
provide: AUTH_REPOSITORY_TOKEN,
useFactory: (userRepository: IUserRepository, passwordHashingService: IPasswordHashingService, logger: Logger) => {
useFactory: (passwordHashingService: IPasswordHashingService, logger: Logger) => {
// Seed initial users for InMemoryUserRepository
const initialUsers: StoredUser[] = [
// Example user (replace with actual test users as needed)
@@ -41,7 +37,7 @@ export const AuthProviders: Provider[] = [
const inMemoryUserRepository = new InMemoryUserRepository(logger, initialUsers);
return new InMemoryAuthRepository(inMemoryUserRepository, passwordHashingService, logger);
},
inject: [USER_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
inject: [PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: USER_REPOSITORY_TOKEN,

View File

@@ -1,33 +1,26 @@
import { Injectable, Inject, InternalServerErrorException } from '@nestjs/common';
import type { AuthenticatedUserDTO, AuthSessionDTO, SignupParams, LoginParams, IracingAuthRedirectResult, LoginWithIracingCallbackParams } from './dto/AuthDto';
import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
// Core Use Cases
import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase';
import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase';
import { GetCurrentSessionUseCase } from '@core/identity/application/use-cases/GetCurrentSessionUseCase';
import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase';
import { StartIracingAuthRedirectUseCase } from '@core/identity/application/use-cases/StartIracingAuthRedirectUseCase';
import { LoginWithIracingCallbackUseCase } from '@core/identity/application/use-cases/LoginWithIracingCallbackUseCase';
import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase';
// Core Interfaces and Tokens
import { AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, IDENTITY_SESSION_PORT_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import { AuthenticatedUserDTO as CoreAuthenticatedUserDTO } from '@core/identity/application/dto/AuthenticatedUserDTO';
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { User } from '@core/identity/domain/entities/User';
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { Logger } from "@core/shared/application";
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { UserId } from '@core/identity/domain/value-objects/UserId';
import { User } from '@core/identity/domain/entities/User';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import { AuthenticatedUserDTO as CoreAuthenticatedUserDTO } from '@core/identity/application/dto/AuthenticatedUserDTO';
import { AUTH_REPOSITORY_TOKEN, IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import { AuthSessionDTO, LoginParams, SignupParams, AuthenticatedUserDTO } from './dtos/AuthDto';
@Injectable()
export class AuthService {
private readonly loginUseCase: LoginUseCase;
private readonly signupUseCase: SignupUseCase;
private readonly getCurrentSessionUseCase: GetCurrentSessionUseCase;
private readonly logoutUseCase: LogoutUseCase;
private readonly startIracingAuthRedirectUseCase: StartIracingAuthRedirectUseCase;
private readonly loginWithIracingCallbackUseCase: LoginWithIracingCallbackUseCase;
constructor(
@Inject(AUTH_REPOSITORY_TOKEN) private authRepository: IAuthRepository,
@@ -38,10 +31,7 @@ export class AuthService {
) {
this.loginUseCase = new LoginUseCase(this.authRepository, this.passwordHashingService);
this.signupUseCase = new SignupUseCase(this.authRepository, this.passwordHashingService);
this.getCurrentSessionUseCase = new GetCurrentSessionUseCase(); // Doesn't have constructor parameters normally
this.logoutUseCase = new LogoutUseCase(this.identitySessionPort);
this.startIracingAuthRedirectUseCase = new StartIracingAuthRedirectUseCase();
this.loginWithIracingCallbackUseCase = new LoginWithIracingCallbackUseCase();
}
private mapUserToAuthenticatedUserDTO(user: User): AuthenticatedUserDTO {
@@ -49,10 +39,14 @@ export class AuthService {
userId: user.getId().value,
email: user.getEmail() ?? '',
displayName: user.getDisplayName() ?? '',
// Map other fields as necessary
iracingCustomerId: user.getIracingCustomerId() ?? undefined,
primaryDriverId: user.getPrimaryDriverId() ?? undefined,
avatarUrl: user.getAvatarUrl() ?? undefined,
};
}
private mapToCoreAuthenticatedUserDTO(apiDto: AuthenticatedUserDTO): CoreAuthenticatedUserDTO {
return {
id: apiDto.userId,
displayName: apiDto.displayName,
email: apiDto.email,
};
}
@@ -85,7 +79,8 @@ export class AuthService {
// Create session after successful signup
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const session = await this.identitySessionPort.createSession(authenticatedUserDTO as CoreAuthenticatedUserDTO);
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto);
return {
token: session.token,
@@ -99,7 +94,8 @@ export class AuthService {
const user = await this.loginUseCase.execute(params.email, params.password);
// Create session after successful login
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const session = await this.identitySessionPort.createSession(authenticatedUserDTO as CoreAuthenticatedUserDTO);
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto);
return {
token: session.token,
@@ -111,27 +107,6 @@ export class AuthService {
}
}
async startIracingAuthRedirect(returnTo?: string): Promise<IracingAuthRedirectResult> {
this.logger.debug('[AuthService] Starting iRacing auth redirect.');
// Note: The StartIracingAuthRedirectUseCase takes optional returnTo, but the DTO doesnt
const result = await this.startIracingAuthRedirectUseCase.execute(returnTo);
// Map core IracingAuthRedirectResult to AuthDto's IracingAuthRedirectResult
return { redirectUrl: result.redirectUrl, state: result.state };
}
async loginWithIracingCallback(params: LoginWithIracingCallbackParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Handling iRacing callback for code: ${params.code}`);
const user = await this.loginWithIracingCallbackUseCase.execute(params); // Pass params as is
// Create session after successful iRacing login
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const session = await this.identitySessionPort.createSession(authenticatedUserDTO as CoreAuthenticatedUserDTO);
return {
token: session.token,
user: authenticatedUserDTO,
};
}
async logout(): Promise<void> {
this.logger.debug('[AuthService] Attempting logout.');

View File

@@ -0,0 +1,45 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { DashboardController } from './DashboardController';
import { DashboardService } from './DashboardService';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
describe('DashboardController', () => {
let controller: DashboardController;
let mockService: { getDashboardOverview: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockService = {
getDashboardOverview: vi.fn(),
};
controller = new DashboardController(mockService as any);
});
describe('getDashboardOverview', () => {
it('should call service.getDashboardOverview and return overview', async () => {
const driverId = 'driver-123';
const overview: DashboardOverviewDTO = {
currentDriver: null,
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [],
activeLeaguesCount: 5,
nextRace: null,
recentResults: [],
leagueStandingsSummaries: [],
feedSummary: {
notificationCount: 0,
items: [],
},
friends: [],
};
mockService.getDashboardOverview.mockResolvedValue(overview);
const result = await controller.getDashboardOverview(driverId);
expect(mockService.getDashboardOverview).toHaveBeenCalledWith(driverId);
expect(result).toEqual(overview);
});
});
});

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DashboardModule } from './DashboardModule';
import { DashboardController } from './DashboardController';
import { DashboardService } from './DashboardService';
describe('DashboardModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [DashboardModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide DashboardController', () => {
const controller = module.get<DashboardController>(DashboardController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(DashboardController);
});
it('should provide DashboardService', () => {
const service = module.get<DashboardService>(DashboardService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(DashboardService);
});
});

View File

@@ -5,7 +5,7 @@ import { DashboardProviders } from './DashboardProviders';
@Module({
controllers: [DashboardController],
providers: DashboardProviders,
providers: [DashboardService, ...DashboardProviders],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -3,21 +3,114 @@ import { DashboardService } from './DashboardService';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
// Import concrete implementations
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryResultRepository } from '@adapters/racing/persistence/inmemory/InMemoryResultRepository';
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemory/InMemoryStandingRepository';
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
// Import use cases
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
// Simple mock implementations for missing adapters
class MockFeedRepository implements IFeedRepository {
async getFeedForDriver(driverId: string, limit?: number) {
return [];
}
async getGlobalFeed(limit?: number) {
return [];
}
}
class MockSocialGraphRepository implements ISocialGraphRepository {
async getFriends(driverId: string) {
return [];
}
async getFriendIds(driverId: string) {
return [];
}
async getSuggestedFriends(driverId: string, limit?: number) {
return [];
}
}
// Define injection tokens
export const LOGGER_TOKEN = 'Logger';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const FEED_REPOSITORY_TOKEN = 'IFeedRepository';
export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const DashboardProviders: Provider[] = [
DashboardService,
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
DashboardOverviewUseCase,
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RACE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RESULT_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryResultRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: STANDING_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryStandingRepository(logger, {}),
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueMembershipRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RACE_REGISTRATION_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRegistrationRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: FEED_REPOSITORY_TOKEN,
useFactory: () => new MockFeedRepository(),
},
{
provide: SOCIAL_GRAPH_REPOSITORY_TOKEN,
useFactory: () => new MockSocialGraphRepository(),
},
{
provide: IMAGE_SERVICE_TOKEN,
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
inject: [LOGGER_TOKEN],
},
];

View File

@@ -1,28 +1,76 @@
import { Injectable, Inject } from '@nestjs/common';
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
import type { DashboardOverviewViewModel } from '@core/racing/application/presenters/IDashboardOverviewPresenter';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
// Tokens
import { LOGGER_TOKEN } from './DashboardProviders';
import {
LOGGER_TOKEN,
DRIVER_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
RACE_REGISTRATION_REPOSITORY_TOKEN,
FEED_REPOSITORY_TOKEN,
SOCIAL_GRAPH_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN,
} from './DashboardProviders';
@Injectable()
export class DashboardService {
constructor(
private readonly dashboardOverviewUseCase: DashboardOverviewUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
private readonly dashboardOverviewUseCase: DashboardOverviewUseCase;
async getDashboardOverview(driverId: string): Promise<any> {
constructor(
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository?: IDriverRepository,
@Inject(RACE_REPOSITORY_TOKEN) private readonly raceRepository?: IRaceRepository,
@Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository?: IResultRepository,
@Inject(LEAGUE_REPOSITORY_TOKEN) private readonly leagueRepository?: ILeagueRepository,
@Inject(STANDING_REPOSITORY_TOKEN) private readonly standingRepository?: IStandingRepository,
@Inject(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN) private readonly leagueMembershipRepository?: ILeagueMembershipRepository,
@Inject(RACE_REGISTRATION_REPOSITORY_TOKEN) private readonly raceRegistrationRepository?: IRaceRegistrationRepository,
@Inject(FEED_REPOSITORY_TOKEN) private readonly feedRepository?: IFeedRepository,
@Inject(SOCIAL_GRAPH_REPOSITORY_TOKEN) private readonly socialRepository?: ISocialGraphRepository,
@Inject(IMAGE_SERVICE_TOKEN) private readonly imageService?: IImageServicePort,
) {
this.dashboardOverviewUseCase = new DashboardOverviewUseCase(
driverRepository,
raceRepository,
resultRepository,
leagueRepository,
standingRepository,
leagueMembershipRepository,
raceRegistrationRepository,
feedRepository,
socialRepository,
imageService,
() => null, // getDriverStats
);
}
async getDashboardOverview(driverId: string): Promise<DashboardOverviewViewModel> {
this.logger.debug('[DashboardService] Getting dashboard overview:', { driverId });
const result = await this.dashboardOverviewUseCase.execute({ driverId });
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to get dashboard overview');
throw new Error(result.error?.message || 'Failed to get dashboard overview');
}
return result.value;
return result.value!;
}
}

View File

@@ -1,11 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsOptional } from 'class-validator';
import { DashboardDriverSummaryDTO } from '../../../race/dtos/DashboardDriverSummaryDTO';
import { DashboardRaceSummaryDTO } from '../../../race/dtos/DashboardRaceSummaryDTO';
import { DashboardRecentResultDTO } from '../../../race/dtos/DashboardRecentResultDTO';
import { DashboardLeagueStandingSummaryDTO } from '../../../race/dtos/DashboardLeagueStandingSummaryDTO';
import { DashboardFeedSummaryDTO } from '../../../race/dtos/DashboardFeedSummaryDTO';
import { DashboardFriendSummaryDTO } from '../../../race/dtos/DashboardFriendSummaryDTO';
import { IsNumber } from 'class-validator';
import { DashboardDriverSummaryDTO } from './DashboardDriverSummaryDTO';
import { DashboardRaceSummaryDTO } from './DashboardRaceSummaryDTO';
import { DashboardRecentResultDTO } from './DashboardRecentResultDTO';
import { DashboardLeagueStandingSummaryDTO } from './DashboardLeagueStandingSummaryDTO';
import { DashboardFeedSummaryDTO } from './DashboardFeedSummaryDTO';
import { DashboardFriendSummaryDTO } from './DashboardFriendSummaryDTO';
export class DashboardOverviewDTO {
@ApiProperty({ nullable: true })

View File

@@ -0,0 +1,163 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { DriverController } from './DriverController';
import { DriverService } from './DriverService';
import type { Request } from 'express';
interface AuthenticatedRequest extends Request {
user?: { userId: string };
}
import { CompleteOnboardingInputDTO } from './dtos/CompleteOnboardingInputDTO';
import { CompleteOnboardingOutputDTO } from './dtos/CompleteOnboardingOutputDTO';
import { DriversLeaderboardDTO } from './dtos/DriversLeaderboardDTO';
import { DriverStatsDTO } from './dtos/DriverStatsDTO';
import { GetDriverOutputDTO } from './dtos/GetDriverOutputDTO';
import { GetDriverProfileOutputDTO } from './dtos/GetDriverProfileOutputDTO';
import { DriverRegistrationStatusDTO } from './dtos/DriverRegistrationStatusDTO';
describe('DriverController', () => {
let controller: DriverController;
let service: ReturnType<typeof vi.mocked<DriverService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DriverController],
providers: [
{
provide: DriverService,
useValue: {
getDriversLeaderboard: vi.fn(),
getTotalDrivers: vi.fn(),
getCurrentDriver: vi.fn(),
completeOnboarding: vi.fn(),
getDriverRegistrationStatus: vi.fn(),
getDriver: vi.fn(),
getDriverProfile: vi.fn(),
updateDriverProfile: vi.fn(),
},
},
],
}).compile();
controller = module.get<DriverController>(DriverController);
service = vi.mocked(module.get(DriverService));
});
describe('getDriversLeaderboard', () => {
it('should return drivers leaderboard', async () => {
const leaderboard: DriversLeaderboardDTO = { items: [] };
service.getDriversLeaderboard.mockResolvedValue(leaderboard);
const result = await controller.getDriversLeaderboard();
expect(service.getDriversLeaderboard).toHaveBeenCalled();
expect(result).toEqual(leaderboard);
});
});
describe('getTotalDrivers', () => {
it('should return total drivers stats', async () => {
const stats: DriverStatsDTO = { totalDrivers: 100 };
service.getTotalDrivers.mockResolvedValue(stats);
const result = await controller.getTotalDrivers();
expect(service.getTotalDrivers).toHaveBeenCalled();
expect(result).toEqual(stats);
});
});
describe('getCurrentDriver', () => {
it('should return current driver if userId exists', async () => {
const userId = 'user-123';
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' };
service.getCurrentDriver.mockResolvedValue(driver);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
const result = await controller.getCurrentDriver(mockReq as AuthenticatedRequest);
expect(service.getCurrentDriver).toHaveBeenCalledWith(userId);
expect(result).toEqual(driver);
});
it('should return null if no userId', async () => {
const mockReq: Partial<AuthenticatedRequest> = {};
const result = await controller.getCurrentDriver(mockReq as AuthenticatedRequest);
expect(service.getCurrentDriver).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('completeOnboarding', () => {
it('should complete onboarding', async () => {
const userId = 'user-123';
const input: CompleteOnboardingInputDTO = { someField: 'value' };
const output: CompleteOnboardingOutputDTO = { success: true };
service.completeOnboarding.mockResolvedValue(output);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
const result = await controller.completeOnboarding(input, mockReq as AuthenticatedRequest);
expect(service.completeOnboarding).toHaveBeenCalledWith(userId, input);
expect(result).toEqual(output);
});
});
describe('getDriverRegistrationStatus', () => {
it('should return registration status', async () => {
const driverId = 'driver-123';
const raceId = 'race-456';
const status: DriverRegistrationStatusDTO = { registered: true };
service.getDriverRegistrationStatus.mockResolvedValue(status);
const result = await controller.getDriverRegistrationStatus(driverId, raceId);
expect(service.getDriverRegistrationStatus).toHaveBeenCalledWith({ driverId, raceId });
expect(result).toEqual(status);
});
});
describe('getDriver', () => {
it('should return driver by id', async () => {
const driverId = 'driver-123';
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' };
service.getDriver.mockResolvedValue(driver);
const result = await controller.getDriver(driverId);
expect(service.getDriver).toHaveBeenCalledWith(driverId);
expect(result).toEqual(driver);
});
});
describe('getDriverProfile', () => {
it('should return driver profile', async () => {
const driverId = 'driver-123';
const profile: GetDriverProfileOutputDTO = { id: driverId, bio: 'Bio' };
service.getDriverProfile.mockResolvedValue(profile);
const result = await controller.getDriverProfile(driverId);
expect(service.getDriverProfile).toHaveBeenCalledWith(driverId);
expect(result).toEqual(profile);
});
});
describe('updateDriverProfile', () => {
it('should update driver profile', async () => {
const driverId = 'driver-123';
const body = { bio: 'New bio', country: 'US' };
const updated: GetDriverOutputDTO = { id: driverId, name: 'Driver' };
service.updateDriverProfile.mockResolvedValue(updated);
const result = await controller.updateDriverProfile(driverId, body);
expect(service.updateDriverProfile).toHaveBeenCalledWith(driverId, body.bio, body.country);
expect(result).toEqual(updated);
});
});
});

View File

@@ -1,6 +1,10 @@
import { Controller, Get, Post, Body, Req, Param } from '@nestjs/common';
import { Request } from 'express';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
interface AuthenticatedRequest extends Request {
user?: { userId: string };
}
import { DriverService } from './DriverService';
import { DriversLeaderboardDTO } from './dtos/DriversLeaderboardDTO';
import { DriverStatsDTO } from './dtos/DriverStatsDTO';
@@ -35,9 +39,9 @@ export class DriverController {
@ApiOperation({ summary: 'Get current authenticated driver' })
@ApiResponse({ status: 200, description: 'Current driver data', type: GetDriverOutputDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getCurrentDriver(@Req() req: Request): Promise<GetDriverOutputDTO | null> {
async getCurrentDriver(@Req() req: AuthenticatedRequest): Promise<GetDriverOutputDTO | null> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req['user']?.userId;
const userId = req.user?.userId;
if (!userId) {
return null;
}
@@ -49,10 +53,10 @@ export class DriverController {
@ApiResponse({ status: 200, description: 'Onboarding complete', type: CompleteOnboardingOutputDTO })
async completeOnboarding(
@Body() input: CompleteOnboardingInputDTO,
@Req() req: Request,
@Req() req: AuthenticatedRequest,
): Promise<CompleteOnboardingOutputDTO> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req['user'].userId; // Placeholder for actual user extraction
const userId = req.user!.userId; // Placeholder for actual user extraction
return this.driverService.completeOnboarding(userId, input);
}

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DriverModule } from './DriverModule';
import { DriverController } from './DriverController';
import { DriverService } from './DriverService';
describe('DriverModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [DriverModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide DriverController', () => {
const controller = module.get<DriverController>(DriverController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(DriverController);
});
it('should provide DriverService', () => {
const service = module.get<DriverService>(DriverService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(DriverService);
});
});

View File

@@ -9,25 +9,24 @@ import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatin
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
import type { Logger } from "@gridpilot/core/shared/application";
import type { Logger } from "@core/shared/application";
// Import use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { GetProfileOverviewUseCase } from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
// Import concrete in-memory implementations
import { InMemoryDriverRepository } from '../../..//racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRankingService } from '../../..//racing/services/InMemoryRankingService';
import { InMemoryDriverStatsService } from '../../..//racing/services/InMemoryDriverStatsService';
import { InMemoryDriverRatingProvider } from '../../..//racing/ports/InMemoryDriverRatingProvider';
import { InMemoryImageServiceAdapter } from '../../..//media/ports/InMemoryImageServiceAdapter';
import { InMemoryRaceRegistrationRepository } from '../../..//racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
import { InMemoryNotificationPreferenceRepository } from '../../..//notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
import { ConsoleLogger } from '../../..//logging/ConsoleLogger';
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRankingService } from '@adapters/racing/services/InMemoryRankingService';
import { InMemoryDriverStatsService } from '@adapters/racing/services/InMemoryDriverStatsService';
import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider';
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Define injection tokens
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
@@ -44,8 +43,6 @@ export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseC
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';
export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase';
export const IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN = 'IsDriverRegisteredForRaceUseCase';
export const GET_PROFILE_OVERVIEW_USE_CASE_TOKEN = 'GetProfileOverviewUseCase';
export const GET_DRIVER_TEAM_USE_CASE_TOKEN = 'GetDriverTeamUseCase';
export const UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN = 'UpdateDriverProfileUseCase';
export const DriverProviders: Provider[] = [
@@ -92,14 +89,14 @@ export const DriverProviders: Provider[] = [
// Use cases
{
provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, rankingService: IRankingService, driverStatsService: IDriverStatsService, imageService: IImageServicePort) =>
new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN],
useFactory: (driverRepo: IDriverRepository, rankingService: IRankingService, driverStatsService: IDriverStatsService, imageService: IImageServicePort, logger: Logger) =>
new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService, logger),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository) => new GetTotalDriversUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
useFactory: (driverRepo: IDriverRepository, logger: Logger) => new GetTotalDriversUseCase(driverRepo, logger),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
@@ -108,8 +105,8 @@ export const DriverProviders: Provider[] = [
},
{
provide: IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
useFactory: (registrationRepo: IRaceRegistrationRepository) => new IsDriverRegisteredForRaceUseCase(registrationRepo),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN],
useFactory: (registrationRepo: IRaceRegistrationRepository, logger: Logger) => new IsDriverRegisteredForRaceUseCase(registrationRepo, logger),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,

View File

@@ -1,18 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { DriverService } from './DriverService';
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
import type { Logger } from '@core/shared/application';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
describe('DriverService', () => {
let service: DriverService;
let getDriversLeaderboardUseCase: jest.Mocked<GetDriversLeaderboardUseCase>;
let getTotalDriversUseCase: jest.Mocked<GetTotalDriversUseCase>;
let completeDriverOnboardingUseCase: jest.Mocked<CompleteDriverOnboardingUseCase>;
let isDriverRegisteredForRaceUseCase: jest.Mocked<IsDriverRegisteredForRaceUseCase>;
let logger: jest.Mocked<Logger>;
let getDriversLeaderboardUseCase: ReturnType<typeof vi.mocked<GetDriversLeaderboardUseCase>>;
let getTotalDriversUseCase: ReturnType<typeof vi.mocked<GetTotalDriversUseCase>>;
let completeDriverOnboardingUseCase: ReturnType<typeof vi.mocked<CompleteDriverOnboardingUseCase>>;
let isDriverRegisteredForRaceUseCase: ReturnType<typeof vi.mocked<IsDriverRegisteredForRaceUseCase>>;
let updateDriverProfileUseCase: ReturnType<typeof vi.mocked<UpdateDriverProfileUseCase>>;
let driverRepository: ReturnType<typeof vi.mocked<IDriverRepository>>;
let logger: ReturnType<typeof vi.mocked<Logger>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -21,42 +26,57 @@ describe('DriverService', () => {
{
provide: 'GetDriversLeaderboardUseCase',
useValue: {
execute: jest.fn(),
execute: vi.fn(),
},
},
{
provide: 'GetTotalDriversUseCase',
useValue: {
execute: jest.fn(),
execute: vi.fn(),
},
},
{
provide: 'CompleteDriverOnboardingUseCase',
useValue: {
execute: jest.fn(),
execute: vi.fn(),
},
},
{
provide: 'IsDriverRegisteredForRaceUseCase',
useValue: {
execute: jest.fn(),
execute: vi.fn(),
},
},
{
provide: 'UpdateDriverProfileUseCase',
useValue: {
execute: vi.fn(),
},
},
{
provide: 'IDriverRepository',
useValue: {
findById: vi.fn(),
},
},
{
provide: 'Logger',
useValue: {
debug: jest.fn(),
debug: vi.fn(),
error: vi.fn(),
},
},
],
}).compile();
service = module.get<DriverService>(DriverService);
getDriversLeaderboardUseCase = module.get('GetDriversLeaderboardUseCase');
getTotalDriversUseCase = module.get('GetTotalDriversUseCase');
completeDriverOnboardingUseCase = module.get('CompleteDriverOnboardingUseCase');
isDriverRegisteredForRaceUseCase = module.get('IsDriverRegisteredForRaceUseCase');
logger = module.get('Logger');
getDriversLeaderboardUseCase = vi.mocked(module.get('GetDriversLeaderboardUseCase'));
getTotalDriversUseCase = vi.mocked(module.get('GetTotalDriversUseCase'));
completeDriverOnboardingUseCase = vi.mocked(module.get('CompleteDriverOnboardingUseCase'));
isDriverRegisteredForRaceUseCase = vi.mocked(module.get('IsDriverRegisteredForRaceUseCase'));
updateDriverProfileUseCase = vi.mocked(module.get('UpdateDriverProfileUseCase'));
driverRepository = vi.mocked(module.get('IDriverRepository'));
logger = vi.mocked(module.get('Logger'));
});
describe('getDriversLeaderboard', () => {

View File

@@ -143,7 +143,12 @@ export class DriverProfileSocialSummaryDTO {
export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export enum DriverProfileAchievementRarity {
COMMON = 'common',
RARE = 'rare',
EPIC = 'epic',
LEGENDARY = 'legendary',
}
export class DriverProfileAchievementDTO {
@ApiProperty()

View File

@@ -1,37 +1,34 @@
import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger';
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { LeagueService } from './LeagueService';
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { LeagueJoinRequestDTO } from './dtos/LeagueJoinRequestDTO';
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
import { ApproveJoinRequestOutputDTO } from './dtos/ApproveJoinRequestOutputDTO';
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from './dtos/CreateLeagueOutputDTO';
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
import { LeagueAdminPermissionsDTO } from './dtos/LeagueAdminPermissionsDTO';
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
import { LeagueJoinRequestDTO } from './dtos/LeagueJoinRequestDTO';
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { RejectJoinRequestInputDTO } from './dtos/RejectJoinRequestInputDTO';
import { RejectJoinRequestOutputDTO } from './dtos/RejectJoinRequestOutputDTO';
import { LeagueAdminPermissionsDTO } from './dtos/LeagueAdminPermissionsDTO';
import { RemoveLeagueMemberInputDTO } from './dtos/RemoveLeagueMemberInputDTO';
import { RemoveLeagueMemberOutputDTO } from './dtos/RemoveLeagueMemberOutputDTO';
import { UpdateLeagueMemberRoleInputDTO } from './dtos/UpdateLeagueMemberRoleInputDTO';
import { UpdateLeagueMemberRoleOutputDTO } from './dtos/UpdateLeagueMemberRoleOutputDTO';
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from './dtos/CreateLeagueOutputDTO';
import { GetLeagueAdminPermissionsInputDTO } from './dtos/GetLeagueAdminPermissionsInputDTO';
import { GetLeagueJoinRequestsQueryDTO } from './dtos/GetLeagueJoinRequestsQueryDTO';
import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQueryDTO';
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQueryDTO';
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
@ApiTags('leagues')
@Controller('leagues')
@@ -104,8 +101,7 @@ export class LeagueController {
async removeLeagueMember(
@Param('leagueId') leagueId: string,
@Param('performerDriverId') performerDriverId: string,
@Param('targetDriverId') targetDriverId: string,
@Body() input: RemoveLeagueMemberInputDTO, // Body content for a patch often includes IDs
@Param('targetDriverId') targetDriverId: string, // Body content for a patch often includes IDs
): Promise<RemoveLeagueMemberOutputDTO> {
return this.leagueService.removeLeagueMember({ leagueId, performerDriverId, targetDriverId });
}
@@ -133,27 +129,27 @@ export class LeagueController {
@Param('leagueId') leagueId: string,
@Param('ownerId') ownerId: string,
): Promise<LeagueOwnerSummaryDTO | null> {
const query: GetLeagueOwnerSummaryQuery = { ownerId, leagueId };
const query: GetLeagueOwnerSummaryQueryDTO = { ownerId, leagueId };
return this.leagueService.getLeagueOwnerSummary(query);
}
@Get(':leagueId/config')
@ApiOperation({ summary: 'Get league full configuration' })
@ApiResponse({ status: 200, description: 'League configuration form model', type: LeagueConfigFormModelDTO })
async getLeagueFullConfig(
@Param('leagueId') leagueId: string,
): Promise<LeagueConfigFormModelDTO | null> {
const query: GetLeagueAdminConfigQuery = { leagueId };
return this.leagueService.getLeagueFullConfig(query);
}
@ApiOperation({ summary: 'Get league full configuration' })
@ApiResponse({ status: 200, description: 'League configuration form model', type: LeagueConfigFormModelDTO })
async getLeagueFullConfig(
@Param('leagueId') leagueId: string,
): Promise<LeagueConfigFormModelDTO | null> {
const query: GetLeagueAdminConfigQueryDTO = { leagueId };
return this.leagueService.getLeagueFullConfig(query);
}
@Get(':leagueId/protests')
@ApiOperation({ summary: 'Get protests for a league' })
@ApiResponse({ status: 200, description: 'List of protests for the league', type: LeagueAdminProtestsDTO })
async getLeagueProtests(@Param('leagueId') leagueId: string): Promise<LeagueAdminProtestsDTO> {
const query: GetLeagueProtestsQuery = { leagueId };
return this.leagueService.getLeagueProtests(query);
}
@ApiOperation({ summary: 'Get protests for a league' })
@ApiResponse({ status: 200, description: 'List of protests for the league', type: LeagueAdminProtestsDTO })
async getLeagueProtests(@Param('leagueId') leagueId: string): Promise<LeagueAdminProtestsDTO> {
const query: GetLeagueProtestsQueryDTO = { leagueId };
return this.leagueService.getLeagueProtests(query);
}
@Get(':leagueId/protests/:protestId')
@ApiOperation({ summary: 'Get a specific protest for a league' })
@@ -162,7 +158,7 @@ export class LeagueController {
@Param('leagueId') leagueId: string,
@Param('protestId') protestId: string,
): Promise<LeagueAdminProtestsDTO> {
const query: GetLeagueProtestsQuery = { leagueId };
const query: GetLeagueProtestsQueryDTO = { leagueId };
const allProtests = await this.leagueService.getLeagueProtests(query);
// Filter to only include the specific protest
@@ -187,12 +183,12 @@ export class LeagueController {
}
@Get(':leagueId/seasons')
@ApiOperation({ summary: 'Get seasons for a league' })
@ApiResponse({ status: 200, description: 'List of seasons for the league', type: [LeagueSeasonSummaryDTO] })
async getLeagueSeasons(@Param('leagueId') leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
const query: GetLeagueSeasonsQuery = { leagueId };
return this.leagueService.getLeagueSeasons(query);
}
@ApiOperation({ summary: 'Get seasons for a league' })
@ApiResponse({ status: 200, description: 'List of seasons for the league', type: [LeagueSeasonSummaryDTO] })
async getLeagueSeasons(@Param('leagueId') leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
const query: GetLeagueSeasonsQueryDTO = { leagueId };
return this.leagueService.getLeagueSeasons(query);
}
@Get(':leagueId/memberships')
@ApiOperation({ summary: 'Get league memberships' })

View File

@@ -19,7 +19,8 @@ import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Import use cases
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStandingsUseCaseImpl } from '@core/league/application/use-cases/GetLeagueStandingsUseCaseImpl';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
@@ -49,6 +50,7 @@ export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
export const GET_LEAGUE_STANDINGS_USE_CASE = 'GetLeagueStandingsUseCase';
export const LeagueProviders: Provider[] = [
LeagueService, // Provide the service itself
@@ -108,7 +110,10 @@ export const LeagueProviders: Provider[] = [
},
// Use cases
GetAllLeaguesWithCapacityUseCase,
GetLeagueStandingsUseCase,
{
provide: GET_LEAGUE_STANDINGS_USE_CASE,
useClass: GetLeagueStandingsUseCaseImpl,
},
GetLeagueStatsUseCase,
GetLeagueFullConfigUseCase,
CreateLeagueWithSeasonAndScoringUseCase,

View File

@@ -1,81 +1,111 @@
import { Injectable, Inject } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { LeagueJoinRequestDTO } from './dtos/LeagueJoinRequestDTO';
import { ApproveJoinRequestInputDTO } from './dtos/ApproveJoinRequestInputDTO';
import { ApproveJoinRequestOutputDTO } from './dtos/ApproveJoinRequestOutputDTO';
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from './dtos/CreateLeagueOutputDTO';
import { JoinLeagueOutputDTO } from './dtos/JoinLeagueOutputDTO';
import { TransferLeagueOwnershipOutputDTO } from './dtos/TransferLeagueOwnershipOutputDTO';
import { LeagueJoinRequestWithDriverDTO } from './dtos/LeagueJoinRequestWithDriverDTO';
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
import { GetLeagueAdminPermissionsInputDTO } from './dtos/GetLeagueAdminPermissionsInputDTO';
import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQueryDTO';
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
import { LeagueJoinRequestDTO } from './dtos/LeagueJoinRequestDTO';
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { RejectJoinRequestInputDTO } from './dtos/RejectJoinRequestInputDTO';
import { RejectJoinRequestOutputDTO } from './dtos/RejectJoinRequestOutputDTO';
import { LeagueAdminPermissionsDTO } from './dtos/LeagueAdminPermissionsDTO';
import { RemoveLeagueMemberInputDTO } from './dtos/RemoveLeagueMemberInputDTO';
import { RemoveLeagueMemberOutputDTO } from './dtos/RemoveLeagueMemberOutputDTO';
import { UpdateLeagueMemberRoleInputDTO } from './dtos/UpdateLeagueMemberRoleInputDTO';
import { UpdateLeagueMemberRoleOutputDTO } from './dtos/UpdateLeagueMemberRoleOutputDTO';
import { LeagueOwnerSummaryDTO } from './dtos/LeagueOwnerSummaryDTO';
import { LeagueConfigFormModelDTO } from './dtos/LeagueConfigFormModelDTO';
import { LeagueAdminProtestsDTO } from './dtos/LeagueAdminProtestsDTO';
import { LeagueSeasonSummaryDTO } from './dtos/LeagueSeasonSummaryDTO';
import { GetLeagueAdminPermissionsInputDTO } from './dtos/GetLeagueAdminPermissionsInputDTO';
import { GetLeagueProtestsQueryDTO } from './dtos/GetLeagueProtestsQueryDTO';
import { GetLeagueSeasonsQueryDTO } from './dtos/GetLeagueSeasonsQueryDTO';
import { GetLeagueAdminConfigQueryDTO } from './dtos/GetLeagueAdminConfigQueryDTO';
import { GetLeagueOwnerSummaryQueryDTO } from './dtos/GetLeagueOwnerSummaryQueryDTO';
import { LeagueMembershipsDTO } from './dtos/LeagueMembershipsDTO';
import { LeagueStandingsDTO } from './dtos/LeagueStandingsDTO';
import { LeagueScheduleDTO } from './dtos/LeagueScheduleDTO';
import { LeagueStatsDTO } from './dtos/LeagueStatsDTO';
import { LeagueAdminDTO } from './dtos/LeagueAdminDTO';
import { CreateLeagueInputDTO } from './dtos/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from './dtos/CreateLeagueOutputDTO';
import { GetSeasonSponsorshipsOutputDTO } from './dtos/GetSeasonSponsorshipsOutputDTO';
import { GetLeagueRacesOutputDTO } from './dtos/GetLeagueRacesOutputDTO';
// Core imports for entities
import type { League } from '@core/racing/domain/entities/League';
// Core imports for view models
import type { LeagueScoringConfigViewModel } from '@core/racing/application/presenters/ILeagueScoringConfigPresenter';
import type { LeagueScoringPresetsViewModel } from '@core/racing/application/presenters/ILeagueScoringPresetsPresenter';
import type { AllLeaguesWithCapacityViewModel } from '@core/racing/application/presenters/IAllLeaguesWithCapacityPresenter';
import type { GetTotalLeaguesViewModel } from '@core/racing/application/presenters/IGetTotalLeaguesPresenter';
import type { GetLeagueJoinRequestsViewModel } from '@core/racing/application/presenters/IGetLeagueJoinRequestsPresenter';
import type { ApproveLeagueJoinRequestViewModel } from '@core/racing/application/presenters/IApproveLeagueJoinRequestPresenter';
import type { RejectLeagueJoinRequestViewModel } from '@core/racing/application/presenters/IRejectLeagueJoinRequestPresenter';
import type { GetLeagueAdminPermissionsViewModel } from '@core/racing/application/presenters/IGetLeagueAdminPermissionsPresenter';
import type { RemoveLeagueMemberViewModel } from '@core/racing/application/presenters/IRemoveLeagueMemberPresenter';
import type { UpdateLeagueMemberRoleViewModel } from '@core/racing/application/presenters/IUpdateLeagueMemberRolePresenter';
import type { GetLeagueOwnerSummaryViewModel } from '@core/racing/application/presenters/IGetLeagueOwnerSummaryPresenter';
import type { GetLeagueProtestsViewModel } from '@core/racing/application/presenters/IGetLeagueProtestsPresenter';
import type { GetLeagueSeasonsViewModel } from '@core/racing/application/presenters/IGetLeagueSeasonsPresenter';
import type { GetLeagueMembershipsViewModel } from '@core/racing/application/presenters/IGetLeagueMembershipsPresenter';
import type { LeagueStandingsViewModel } from '@core/racing/application/presenters/ILeagueStandingsPresenter';
import type { LeagueScheduleViewModel } from '@core/racing/application/presenters/ILeagueSchedulePresenter';
import type { LeagueStatsViewModel } from '@core/racing/application/presenters/ILeagueStatsPresenter';
import type { LeagueConfigFormViewModel } from '@core/racing/application/presenters/ILeagueFullConfigPresenter';
import type { CreateLeagueViewModel } from '@core/racing/application/presenters/ICreateLeaguePresenter';
import type { JoinLeagueViewModel } from '@core/racing/application/presenters/IJoinLeaguePresenter';
import type { TransferLeagueOwnershipViewModel } from '@core/racing/application/presenters/ITransferLeagueOwnershipPresenter';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
import { GetLeagueStandingsUseCase } from '@core/racing/application/use-cases/GetLeagueStandingsUseCase';
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetLeagueScoringConfigUseCase } from '@core/racing/application/use-cases/GetLeagueScoringConfigUseCase';
import { ListLeagueScoringPresetsUseCase } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase';
import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase';
import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { GetLeagueStandingsUseCase } from '@core/league/application/use-cases/GetLeagueStandingsUseCase';
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase';
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { CreateLeagueWithSeasonAndScoringUseCase } from '@core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase';
import { GetAllLeaguesWithCapacityUseCase } from '@core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase';
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase';
import { GetLeagueOwnerSummaryUseCase } from '@core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase';
import { GetLeagueProtestsUseCase } from '@core/racing/application/use-cases/GetLeagueProtestsUseCase';
import { GetLeagueSeasonsUseCase } from '@core/racing/application/use-cases/GetLeagueSeasonsUseCase';
import { GetLeagueMembershipsUseCase } from '@core/racing/application/use-cases/GetLeagueMembershipsUseCase';
import { GetLeagueScheduleUseCase } from '@core/racing/application/use-cases/GetLeagueScheduleUseCase';
import { GetLeagueAdminPermissionsUseCase } from '@core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase';
import { GetLeagueScoringConfigUseCase } from '@core/racing/application/use-cases/GetLeagueScoringConfigUseCase';
import { GetLeagueSeasonsUseCase } from '@core/racing/application/use-cases/GetLeagueSeasonsUseCase';
import { GetLeagueStatsUseCase } from '@core/racing/application/use-cases/GetLeagueStatsUseCase';
import { GetRaceProtestsUseCase } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import { GetTotalLeaguesUseCase } from '@core/racing/application/use-cases/GetTotalLeaguesUseCase';
import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase';
import { ListLeagueScoringPresetsUseCase } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase';
import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase';
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
import { TransferLeagueOwnershipUseCase } from '@core/racing/application/use-cases/TransferLeagueOwnershipUseCase';
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
// API Presenters
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
import { AllLeaguesWithCapacityPresenter } from './presenters/AllLeaguesWithCapacityPresenter';
import { TotalLeaguesPresenter } from './presenters/TotalLeaguesPresenter';
import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPresenter';
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
import { ApproveLeagueJoinRequestPresenter } from './presenters/ApproveLeagueJoinRequestPresenter';
import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter';
import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter';
import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
import { GetLeagueOwnerSummaryPresenter } from './presenters/GetLeagueOwnerSummaryPresenter';
import { GetLeagueProtestsPresenter } from './presenters/GetLeagueProtestsPresenter';
import { GetLeagueSeasonsPresenter } from './presenters/GetLeagueSeasonsPresenter';
import { GetLeagueMembershipsPresenter } from './presenters/GetLeagueMembershipsPresenter';
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
import { LeagueSchedulePresenter } from './presenters/LeagueSchedulePresenter';
import { TotalLeaguesPresenter } from './presenters/TotalLeaguesPresenter';
import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter';
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
import { LeagueStatsPresenter } from './presenters/LeagueStatsPresenter';
import { GetLeagueAdminPermissionsPresenter } from './presenters/GetLeagueAdminPermissionsPresenter';
import { RejectLeagueJoinRequestPresenter } from './presenters/RejectLeagueJoinRequestPresenter';
import { RemoveLeagueMemberPresenter } from './presenters/RemoveLeagueMemberPresenter';
import { UpdateLeagueMemberRolePresenter } from './presenters/UpdateLeagueMemberRolePresenter';
import { CreateLeaguePresenter } from './presenters/CreateLeaguePresenter';
import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter';
import { TransferLeagueOwnershipPresenter } from './presenters/TransferLeagueOwnershipPresenter';
// Tokens
import { LOGGER_TOKEN } from './LeagueProviders';
@@ -111,127 +141,173 @@ export class LeagueService {
async getAllLeaguesWithCapacity(): Promise<AllLeaguesWithCapacityViewModel> {
this.logger.debug('[LeagueService] Fetching all leagues with capacity.');
const result = await this.getAllLeaguesWithCapacityUseCase.execute();
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new AllLeaguesWithCapacityPresenter();
await this.getAllLeaguesWithCapacityUseCase.execute(undefined, presenter);
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getTotalLeagues(): Promise<LeagueStatsDto> {
async getTotalLeagues(): Promise<GetTotalLeaguesViewModel> {
this.logger.debug('[LeagueService] Fetching total leagues count.');
const result = await this.getTotalLeaguesUseCase.execute();
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new TotalLeaguesPresenter();
await this.getTotalLeaguesUseCase.execute({}, presenter);
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
async getLeagueJoinRequests(leagueId: string): Promise<GetLeagueJoinRequestsViewModel> {
this.logger.debug(`[LeagueService] Fetching join requests for league: ${leagueId}.`);
const result = await this.getLeagueJoinRequestsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new LeagueJoinRequestsPresenter();
await this.getLeagueJoinRequestsUseCase.execute({ leagueId }, presenter);
return presenter.getViewModel()!.joinRequests;
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async approveLeagueJoinRequest(input: ApproveJoinRequestInput): Promise<ApproveJoinRequestOutput> {
async approveLeagueJoinRequest(input: ApproveJoinRequestInputDTO): Promise<ApproveLeagueJoinRequestViewModel> {
this.logger.debug('Approving join request:', input);
const result = await this.approveLeagueJoinRequestUseCase.execute({ leagueId: input.leagueId, requestId: input.requestId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new ApproveLeagueJoinRequestPresenter();
await this.approveLeagueJoinRequestUseCase.execute({ leagueId: input.leagueId, requestId: input.requestId }, presenter);
return presenter.getViewModel()!;
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async rejectLeagueJoinRequest(input: RejectJoinRequestInput): Promise<RejectJoinRequestOutput> {
async rejectLeagueJoinRequest(input: RejectJoinRequestInputDTO): Promise<RejectLeagueJoinRequestViewModel> {
this.logger.debug('Rejecting join request:', input);
const result = await this.rejectLeagueJoinRequestUseCase.execute({ requestId: input.requestId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new RejectLeagueJoinRequestPresenter();
await this.rejectLeagueJoinRequestUseCase.execute({ requestId: input.requestId }, presenter);
return presenter.getViewModel()!;
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInput): Promise<LeagueAdminPermissionsViewModel> {
async getLeagueAdminPermissions(query: GetLeagueAdminPermissionsInputDTO): Promise<GetLeagueAdminPermissionsViewModel> {
this.logger.debug('Getting league admin permissions', { query });
const result = await this.getLeagueAdminPermissionsUseCase.execute({ leagueId: query.leagueId, performerDriverId: query.performerDriverId });
// This use case never errors
const presenter = new GetLeagueAdminPermissionsPresenter();
await this.getLeagueAdminPermissionsUseCase.execute(
{ leagueId: query.leagueId, performerDriverId: query.performerDriverId },
presenter
);
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async removeLeagueMember(input: RemoveLeagueMemberInput): Promise<RemoveLeagueMemberOutput> {
async removeLeagueMember(input: RemoveLeagueMemberInputDTO): Promise<RemoveLeagueMemberViewModel> {
this.logger.debug('Removing league member', { leagueId: input.leagueId, targetDriverId: input.targetDriverId });
const result = await this.removeLeagueMemberUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new RemoveLeagueMemberPresenter();
await this.removeLeagueMemberUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId }, presenter);
return presenter.getViewModel()!;
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async updateLeagueMemberRole(input: UpdateLeagueMemberRoleInput): Promise<UpdateLeagueMemberRoleOutput> {
async updateLeagueMemberRole(input: UpdateLeagueMemberRoleInputDTO): Promise<UpdateLeagueMemberRoleViewModel> {
this.logger.debug('Updating league member role', { leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
const result = await this.updateLeagueMemberRoleUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new UpdateLeagueMemberRolePresenter();
await this.updateLeagueMemberRoleUseCase.execute({ leagueId: input.leagueId, targetDriverId: input.targetDriverId, newRole: input.newRole }, presenter);
return presenter.getViewModel()!;
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQuery): Promise<LeagueOwnerSummaryViewModel | null> {
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQueryDTO): Promise<GetLeagueOwnerSummaryViewModel> {
this.logger.debug('Getting league owner summary:', query);
const result = await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: query.ownerId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new GetLeagueOwnerSummaryPresenter();
await this.getLeagueOwnerSummaryUseCase.execute({ ownerId: query.ownerId }, presenter);
return presenter.getViewModel()!.summary;
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async getLeagueFullConfig(query: GetLeagueAdminConfigQuery): Promise<LeagueConfigFormModelDto | null> {
async getLeagueFullConfig(query: GetLeagueAdminConfigQueryDTO): Promise<LeagueConfigFormViewModel | null> {
this.logger.debug('Getting league full config', { query });
const presenter = new LeagueConfigPresenter();
try {
await this.getLeagueFullConfigUseCase.execute({ leagueId: query.leagueId }, presenter);
return presenter.viewModel;
const result = await this.getLeagueFullConfigUseCase.execute({ leagueId: query.leagueId });
if (result.isErr()) {
this.logger.error('Error getting league full config', new Error(result.unwrapErr().code));
return null;
}
return result.unwrap();
} catch (error) {
this.logger.error('Error getting league full config', error instanceof Error ? error : new Error(String(error)));
return null;
}
}
async getLeagueProtests(query: GetLeagueProtestsQuery): Promise<LeagueAdminProtestsViewModel> {
async getLeagueProtests(query: GetLeagueProtestsQueryDTO): Promise<LeagueAdminProtestsDTO> {
this.logger.debug('Getting league protests:', query);
const presenter = new GetLeagueProtestsPresenter();
await this.getLeagueProtestsUseCase.execute({ leagueId: query.leagueId }, presenter);
return presenter.getViewModel()!;
const result = await this.getLeagueProtestsUseCase.execute({ leagueId: query.leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return result.unwrap();
}
async getLeagueSeasons(query: GetLeagueSeasonsQuery): Promise<LeagueSeasonSummaryViewModel[]> {
async getLeagueSeasons(query: GetLeagueSeasonsQueryDTO): Promise<LeagueSeasonSummaryDTO[]> {
this.logger.debug('Getting league seasons:', query);
const presenter = new GetLeagueSeasonsPresenter();
await this.getLeagueSeasonsUseCase.execute({ leagueId: query.leagueId }, presenter);
return presenter.getViewModel()!.seasons;
const result = await this.getLeagueSeasonsUseCase.execute({ leagueId: query.leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return result.unwrap().seasons;
}
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsViewModel> {
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
this.logger.debug('Getting league memberships', { leagueId });
const presenter = new GetLeagueMembershipsPresenter();
await this.getLeagueMembershipsUseCase.execute({ leagueId }, presenter);
return presenter.apiViewModel!;
const result = await this.getLeagueMembershipsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
return result.unwrap();
}
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsViewModel> {
this.logger.debug('Getting league standings', { leagueId });
const presenter = new LeagueStandingsPresenter();
await this.getLeagueStandingsUseCase.execute({ leagueId }, presenter);
return presenter.getViewModel()!;
return await this.getLeagueStandingsUseCase.execute(leagueId);
}
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
this.logger.debug('Getting league schedule', { leagueId });
const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new LeagueSchedulePresenter();
await this.getLeagueScheduleUseCase.execute({ leagueId }, presenter);
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueStats(leagueId: string): Promise<LeagueStatsViewModel> {
this.logger.debug('Getting league stats', { leagueId });
const result = await this.getLeagueStatsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new LeagueStatsPresenter();
await this.getLeagueStatsUseCase.execute({ leagueId }, presenter);
presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async getLeagueAdmin(leagueId: string): Promise<LeagueAdminViewModel> {
async getLeagueAdmin(leagueId: string): Promise<LeagueAdminDTO> {
this.logger.debug('Getting league admin data', { leagueId });
// For now, we'll keep the orchestration in the service since it combines multiple use cases
// TODO: Create a composite use case that handles all the admin data fetching
@@ -253,7 +329,7 @@ export class LeagueService {
};
}
async createLeague(input: CreateLeagueInput): Promise<CreateLeagueOutput> {
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueViewModel> {
this.logger.debug('Creating league', { input });
const command = {
name: input.name,
@@ -268,10 +344,12 @@ export class LeagueService {
enableTrophyChampionship: false,
};
const result = await this.createLeagueWithSeasonAndScoringUseCase.execute(command);
return {
leagueId: result.leagueId,
success: true,
};
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new CreateLeaguePresenter();
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async getLeagueScoringConfig(leagueId: string): Promise<LeagueScoringConfigViewModel | null> {
@@ -281,10 +359,10 @@ export class LeagueService {
try {
const result = await this.getLeagueScoringConfigUseCase.execute({ leagueId });
if (result.isErr()) {
this.logger.error('Error getting league scoring config', result.error);
this.logger.error('Error getting league scoring config', new Error(result.unwrapErr().code));
return null;
}
await presenter.present(result.value);
await presenter.present(result.unwrap());
return presenter.getViewModel();
} catch (error) {
this.logger.error('Error getting league scoring config', error instanceof Error ? error : new Error(String(error)));
@@ -295,40 +373,46 @@ export class LeagueService {
async listLeagueScoringPresets(): Promise<LeagueScoringPresetsViewModel> {
this.logger.debug('Listing league scoring presets');
const result = await this.listLeagueScoringPresetsUseCase.execute();
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
const presenter = new LeagueScoringPresetsPresenter();
await this.listLeagueScoringPresetsUseCase.execute(undefined, presenter);
await presenter.present(result.unwrap());
return presenter.getViewModel()!;
}
async joinLeague(leagueId: string, driverId: string): Promise<JoinLeagueOutput> {
async joinLeague(leagueId: string, driverId: string): Promise<JoinLeagueViewModel> {
this.logger.debug('Joining league', { leagueId, driverId });
const result = await this.joinLeagueUseCase.execute({ leagueId, driverId });
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: result.error.code,
error: error.code,
};
}
return {
success: true,
membershipId: result.value.id,
};
const presenter = new JoinLeaguePresenter();
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async transferLeagueOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<TransferLeagueOwnershipOutput> {
async transferLeagueOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<TransferLeagueOwnershipViewModel> {
this.logger.debug('Transferring league ownership', { leagueId, currentOwnerId, newOwnerId });
const result = await this.transferLeagueOwnershipUseCase.execute({ leagueId, currentOwnerId, newOwnerId });
if (result.isErr()) {
const error = result.unwrapErr();
return {
success: false,
error: result.error.code,
error: error.code,
};
}
return {
success: true,
};
const presenter = new TransferLeagueOwnershipPresenter();
presenter.present(result.unwrap());
return presenter.getViewModel();
}
async getSeasonSponsorships(seasonId: string): Promise<GetSeasonSponsorshipsOutputDTO> {

View File

@@ -0,0 +1,5 @@
export interface JoinLeagueOutputDTO {
success: boolean;
error?: string;
membershipId?: string;
}

View File

@@ -23,9 +23,9 @@ export class LeagueConfigFormModelDTO {
@Type(() => LeagueConfigFormModelStructureDTO)
structure: LeagueConfigFormModelStructureDTO;
@ApiProperty({ type: [Object] })
@ApiProperty({ type: [Object] })
@IsArray()
championships: any[];
championships: Object[];
@ApiProperty({ type: LeagueConfigFormModelScoringDTO })
@ValidateNested()

View File

@@ -0,0 +1,11 @@
export interface LeagueJoinRequestWithDriverDTO {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
driver: {
id: string;
name: string;
};
}

View File

@@ -0,0 +1,4 @@
export interface TransferLeagueOwnershipOutputDTO {
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,21 @@
import { ICreateLeaguePresenter, CreateLeagueResultDTO, CreateLeagueViewModel } from '@core/racing/application/presenters/ICreateLeaguePresenter';
export class CreateLeaguePresenter implements ICreateLeaguePresenter {
private result: CreateLeagueViewModel | null = null;
reset() {
this.result = null;
}
present(dto: CreateLeagueResultDTO): void {
this.result = {
leagueId: dto.leagueId,
success: true,
};
}
getViewModel(): CreateLeagueViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,21 @@
import { IJoinLeaguePresenter, JoinLeagueResultDTO, JoinLeagueViewModel } from '@core/racing/application/presenters/IJoinLeaguePresenter';
export class JoinLeaguePresenter implements IJoinLeaguePresenter {
private result: JoinLeagueViewModel | null = null;
reset() {
this.result = null;
}
present(dto: JoinLeagueResultDTO): void {
this.result = {
success: true,
membershipId: dto.id,
};
}
getViewModel(): JoinLeagueViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -8,11 +8,11 @@ export class LeagueAdminPresenter {
}
present(data: {
joinRequests: any[];
ownerSummary: any;
config: any;
protests: any;
seasons: any[];
joinRequests: unknown[];
ownerSummary: unknown;
config: unknown;
protests: unknown;
seasons: unknown[];
}) {
this.result = {
joinRequests: data.joinRequests,

View File

@@ -0,0 +1,20 @@
import { ITransferLeagueOwnershipPresenter, TransferLeagueOwnershipResultDTO, TransferLeagueOwnershipViewModel } from '@core/racing/application/presenters/ITransferLeagueOwnershipPresenter';
export class TransferLeagueOwnershipPresenter implements ITransferLeagueOwnershipPresenter {
private result: TransferLeagueOwnershipViewModel | null = null;
reset() {
this.result = null;
}
present(dto: TransferLeagueOwnershipResultDTO): void {
this.result = {
success: dto.success,
};
}
getViewModel(): TransferLeagueOwnershipViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,181 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { MediaController } from './MediaController';
import { MediaService } from './MediaService';
import type { Response } from 'express';
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
describe('MediaController', () => {
let controller: MediaController;
let service: ReturnType<typeof vi.mocked<MediaService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [MediaController],
providers: [
{
provide: MediaService,
useValue: {
requestAvatarGeneration: vi.fn(),
uploadMedia: vi.fn(),
getMedia: vi.fn(),
deleteMedia: vi.fn(),
getAvatar: vi.fn(),
updateAvatar: vi.fn(),
},
},
],
}).compile();
controller = module.get<MediaController>(MediaController);
service = vi.mocked(module.get(MediaService));
});
describe('requestAvatarGeneration', () => {
it('should request avatar generation and return 201 on success', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: true, jobId: 'job-123' };
service.requestAvatarGeneration.mockResolvedValue(result);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.requestAvatarGeneration(input, mockRes);
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result);
});
it('should return 400 on failure', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: false, error: 'Error' };
service.requestAvatarGeneration.mockResolvedValue(result);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.requestAvatarGeneration(input, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(result);
});
});
describe('uploadMedia', () => {
it('should upload media and return 201 on success', async () => {
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
const input: UploadMediaInputDTO = { type: 'image' };
const result = { success: true, mediaId: 'media-123' };
service.uploadMedia.mockResolvedValue(result);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.uploadMedia(file, input, mockRes);
expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file });
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result);
});
});
describe('getMedia', () => {
it('should return media if found', async () => {
const mediaId = 'media-123';
const result = { id: mediaId, url: 'url' };
service.getMedia.mockResolvedValue(result);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.getMedia(mediaId, mockRes);
expect(service.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
});
it('should return 404 if not found', async () => {
const mediaId = 'media-123';
service.getMedia.mockResolvedValue(null);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.getMedia(mediaId, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Media not found' });
});
});
describe('deleteMedia', () => {
it('should delete media', async () => {
const mediaId = 'media-123';
const result = { success: true };
service.deleteMedia.mockResolvedValue(result);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.deleteMedia(mediaId, mockRes);
expect(service.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
});
});
describe('getAvatar', () => {
it('should return avatar if found', async () => {
const driverId = 'driver-123';
const result = { url: 'avatar.jpg' };
service.getAvatar.mockResolvedValue(result);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.getAvatar(driverId, mockRes);
expect(service.getAvatar).toHaveBeenCalledWith(driverId);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
});
});
describe('updateAvatar', () => {
it('should update avatar', async () => {
const driverId = 'driver-123';
const input = { url: 'new-avatar.jpg' };
const result = { success: true };
service.updateAvatar.mockResolvedValue(result);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as unknown as ReturnType<typeof vi.mocked<Response>>;
await controller.updateAvatar(driverId, input, mockRes);
expect(service.updateAvatar).toHaveBeenCalledWith(driverId, input);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
});
});
});

View File

@@ -3,25 +3,19 @@ import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nest
import { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { MediaService } from './MediaService';
import type { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import type { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
import type { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO';
import type { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO';
import type { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import type { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
import { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
import { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO';
import { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO';
import { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type RequestAvatarGenerationOutput = RequestAvatarGenerationOutputDTO;
type UploadMediaInput = UploadMediaInputDTO;
type UploadMediaOutput = UploadMediaOutputDTO;
type GetMediaOutput = GetMediaOutputDTO;
type DeleteMediaOutput = DeleteMediaOutputDTO;
type GetAvatarOutput = GetAvatarOutputDTO;
type UpdateAvatarInput = UpdateAvatarInputDTO;
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
@ApiTags('media')
@Controller('media')
@@ -30,7 +24,7 @@ export class MediaController {
@Post('avatar/generate')
@ApiOperation({ summary: 'Request avatar generation' })
@ApiResponse({ status: 201, description: 'Avatar generation request submitted', type: RequestAvatarGenerationOutput })
@ApiResponse({ status: 201, description: 'Avatar generation request submitted', type: RequestAvatarGenerationOutputDTO })
async requestAvatarGeneration(
@Body() input: RequestAvatarGenerationInput,
@Res() res: Response,
@@ -47,7 +41,7 @@ export class MediaController {
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: 'Upload media file' })
@ApiConsumes('multipart/form-data')
@ApiResponse({ status: 201, description: 'Media uploaded successfully', type: UploadMediaOutput })
@ApiResponse({ status: 201, description: 'Media uploaded successfully', type: UploadMediaOutputDTO })
async uploadMedia(
@UploadedFile() file: Express.Multer.File,
@Body() input: UploadMediaInput,
@@ -64,7 +58,7 @@ export class MediaController {
@Get(':mediaId')
@ApiOperation({ summary: 'Get media by ID' })
@ApiParam({ name: 'mediaId', description: 'Media ID' })
@ApiResponse({ status: 200, description: 'Media details', type: GetMediaOutput })
@ApiResponse({ status: 200, description: 'Media details', type: GetMediaOutputDTO })
async getMedia(
@Param('mediaId') mediaId: string,
@Res() res: Response,
@@ -80,7 +74,7 @@ export class MediaController {
@Delete(':mediaId')
@ApiOperation({ summary: 'Delete media by ID' })
@ApiParam({ name: 'mediaId', description: 'Media ID' })
@ApiResponse({ status: 200, description: 'Media deleted', type: DeleteMediaOutput })
@ApiResponse({ status: 200, description: 'Media deleted', type: DeleteMediaOutputDTO })
async deleteMedia(
@Param('mediaId') mediaId: string,
@Res() res: Response,
@@ -92,7 +86,7 @@ export class MediaController {
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Avatar details', type: GetAvatarOutput })
@ApiResponse({ status: 200, description: 'Avatar details', type: GetAvatarOutputDTO })
async getAvatar(
@Param('driverId') driverId: string,
@Res() res: Response,
@@ -108,7 +102,7 @@ export class MediaController {
@Put('avatar/:driverId')
@ApiOperation({ summary: 'Update avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
@ApiResponse({ status: 200, description: 'Avatar updated', type: UpdateAvatarOutput })
@ApiResponse({ status: 200, description: 'Avatar updated', type: UpdateAvatarOutputDTO })
async updateAvatar(
@Param('driverId') driverId: string,
@Body() input: UpdateAvatarInput,

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MediaModule } from './MediaModule';
import { MediaController } from './MediaController';
import { MediaService } from './MediaService';
describe('MediaModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [MediaModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide MediaController', () => {
const controller = module.get<MediaController>(MediaController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(MediaController);
});
it('should provide MediaService', () => {
const service = module.get<MediaService>(MediaService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(MediaService);
});
});

View File

@@ -3,39 +3,82 @@ import { MediaService } from './MediaService';
// Import core interfaces
import { IAvatarGenerationRepository } from '@core/media/domain/repositories/IAvatarGenerationRepository';
import { IMediaRepository } from '@core/media/domain/repositories/IMediaRepository';
import { IAvatarRepository } from '@core/media/domain/repositories/IAvatarRepository';
import { FaceValidationPort } from '@core/media/application/ports/FaceValidationPort';
import { AvatarGenerationPort } from '@core/media/application/ports/AvatarGenerationPort';
import { MediaStoragePort } from '@core/media/application/ports/MediaStoragePort';
import type { Logger } from '@core/shared/application';
// Import use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import { UploadMediaUseCase } from '@core/media/application/use-cases/UploadMediaUseCase';
import { GetMediaUseCase } from '@core/media/application/use-cases/GetMediaUseCase';
import { DeleteMediaUseCase } from '@core/media/application/use-cases/DeleteMediaUseCase';
import { GetAvatarUseCase } from '@core/media/application/use-cases/GetAvatarUseCase';
import { UpdateAvatarUseCase } from '@core/media/application/use-cases/UpdateAvatarUseCase';
// Define injection tokens
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository';
export const FACE_VALIDATION_PORT_TOKEN = 'FaceValidationPort';
export const AVATAR_GENERATION_PORT_TOKEN = 'AvatarGenerationPort';
export const MEDIA_STORAGE_PORT_TOKEN = 'MediaStoragePort';
export const LOGGER_TOKEN = 'Logger';
// Use case tokens
export const REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN = 'RequestAvatarGenerationUseCase';
export const UPLOAD_MEDIA_USE_CASE_TOKEN = 'UploadMediaUseCase';
export const GET_MEDIA_USE_CASE_TOKEN = 'GetMediaUseCase';
export const DELETE_MEDIA_USE_CASE_TOKEN = 'DeleteMediaUseCase';
export const GET_AVATAR_USE_CASE_TOKEN = 'GetAvatarUseCase';
export const UPDATE_AVATAR_USE_CASE_TOKEN = 'UpdateAvatarUseCase';
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
import type { Media } from '@core/media/domain/entities/Media';
import type { Avatar } from '@core/media/domain/entities/Avatar';
import type { FaceValidationResult } from '@core/media/application/ports/FaceValidationPort';
import type { AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort';
import type { UploadResult } from '@core/media/application/ports/MediaStoragePort';
// Mock implementations
class MockAvatarGenerationRepository implements IAvatarGenerationRepository {
async save(_request: any): Promise<void> {}
async findById(_id: string): Promise<any | null> { return null; }
async findByUserId(_userId: string): Promise<any[]> { return []; }
async findLatestByUserId(_userId: string): Promise<any | null> { return null; }
async delete(_id: string): Promise<void> {}
async save(): Promise<void> {}
async findById(): Promise<AvatarGenerationRequest | null> { return null; }
async findByUserId(): Promise<AvatarGenerationRequest[]> { return []; }
async findLatestByUserId(): Promise<AvatarGenerationRequest | null> { return null; }
async delete(): Promise<void> {}
}
class MockMediaRepository implements IMediaRepository {
async save(): Promise<void> {}
async findById(): Promise<Media | null> { return null; }
async findByUploadedBy(): Promise<Media[]> { return []; }
async delete(): Promise<void> {}
}
class MockAvatarRepository implements IAvatarRepository {
async save(): Promise<void> {}
async findById(): Promise<Avatar | null> { return null; }
async findActiveByDriverId(): Promise<Avatar | null> { return null; }
async findByDriverId(): Promise<Avatar[]> { return []; }
async delete(): Promise<void> {}
}
class MockFaceValidationAdapter implements FaceValidationPort {
async validateFacePhoto(data: string): Promise<any> {
return { isValid: true, hasFace: true, faceCount: 1 };
async validateFacePhoto(): Promise<FaceValidationResult> {
return {
isValid: true,
hasFace: true,
faceCount: 1,
confidence: 0.95,
};
}
}
class MockAvatarGenerationAdapter implements AvatarGenerationPort {
async generateAvatars(options: any): Promise<any> {
async generateAvatars(): Promise<AvatarGenerationResult> {
return {
success: true,
avatars: [
@@ -47,11 +90,22 @@ class MockAvatarGenerationAdapter implements AvatarGenerationPort {
}
}
class MockMediaStorageAdapter implements MediaStoragePort {
async uploadMedia(): Promise<UploadResult> {
return {
success: true,
url: 'https://cdn.example.com/media/mock-file.png',
filename: 'mock-file.png',
};
}
async deleteMedia(): Promise<void> {}
}
class MockLogger implements Logger {
debug(message: string, meta?: any): void {}
info(message: string, meta?: any): void {}
warn(message: string, meta?: any): void {}
error(message: string, error?: Error): void {}
debug(): void {}
info(): void {}
warn(): void {}
error(): void {}
}
export const MediaProviders: Provider[] = [
@@ -60,6 +114,14 @@ export const MediaProviders: Provider[] = [
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
useClass: MockAvatarGenerationRepository,
},
{
provide: MEDIA_REPOSITORY_TOKEN,
useClass: MockMediaRepository,
},
{
provide: AVATAR_REPOSITORY_TOKEN,
useClass: MockAvatarRepository,
},
{
provide: FACE_VALIDATION_PORT_TOKEN,
useClass: MockFaceValidationAdapter,
@@ -68,6 +130,10 @@ export const MediaProviders: Provider[] = [
provide: AVATAR_GENERATION_PORT_TOKEN,
useClass: MockAvatarGenerationAdapter,
},
{
provide: MEDIA_STORAGE_PORT_TOKEN,
useClass: MockMediaStorageAdapter,
},
{
provide: LOGGER_TOKEN,
useClass: MockLogger,
@@ -79,4 +145,34 @@ export const MediaProviders: Provider[] = [
new RequestAvatarGenerationUseCase(avatarRepo, faceValidation, avatarGeneration, logger),
inject: [AVATAR_GENERATION_REPOSITORY_TOKEN, FACE_VALIDATION_PORT_TOKEN, AVATAR_GENERATION_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: UPLOAD_MEDIA_USE_CASE_TOKEN,
useFactory: (mediaRepo: IMediaRepository, mediaStorage: MediaStoragePort, logger: Logger) =>
new UploadMediaUseCase(mediaRepo, mediaStorage, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_MEDIA_USE_CASE_TOKEN,
useFactory: (mediaRepo: IMediaRepository, logger: Logger) =>
new GetMediaUseCase(mediaRepo, logger),
inject: [MEDIA_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: DELETE_MEDIA_USE_CASE_TOKEN,
useFactory: (mediaRepo: IMediaRepository, mediaStorage: MediaStoragePort, logger: Logger) =>
new DeleteMediaUseCase(mediaRepo, mediaStorage, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_AVATAR_USE_CASE_TOKEN,
useFactory: (avatarRepo: IAvatarRepository, logger: Logger) =>
new GetAvatarUseCase(avatarRepo, logger),
inject: [AVATAR_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: UPDATE_AVATAR_USE_CASE_TOKEN,
useFactory: (avatarRepo: IAvatarRepository, logger: Logger) =>
new UpdateAvatarUseCase(avatarRepo, logger),
inject: [AVATAR_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -8,6 +8,7 @@ import type { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import type { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type RequestAvatarGenerationOutput = RequestAvatarGenerationOutputDTO;
@@ -21,18 +22,41 @@ type UpdateAvatarOutput = UpdateAvatarOutputDTO;
// Use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import { UploadMediaUseCase } from '@core/media/application/use-cases/UploadMediaUseCase';
import { GetMediaUseCase } from '@core/media/application/use-cases/GetMediaUseCase';
import { DeleteMediaUseCase } from '@core/media/application/use-cases/DeleteMediaUseCase';
import { GetAvatarUseCase } from '@core/media/application/use-cases/GetAvatarUseCase';
import { UpdateAvatarUseCase } from '@core/media/application/use-cases/UpdateAvatarUseCase';
// Presenters
import { RequestAvatarGenerationPresenter } from './presenters/RequestAvatarGenerationPresenter';
import { UploadMediaPresenter } from './presenters/UploadMediaPresenter';
import { GetMediaPresenter } from './presenters/GetMediaPresenter';
import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter';
import { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
// Tokens
import { REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN, LOGGER_TOKEN } from './MediaProviders';
import {
REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
UPLOAD_MEDIA_USE_CASE_TOKEN,
GET_MEDIA_USE_CASE_TOKEN,
DELETE_MEDIA_USE_CASE_TOKEN,
GET_AVATAR_USE_CASE_TOKEN,
UPDATE_AVATAR_USE_CASE_TOKEN,
LOGGER_TOKEN
} from './MediaProviders';
import type { Logger } from '@core/shared/application';
@Injectable()
export class MediaService {
constructor(
@Inject(REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN) private readonly requestAvatarGenerationUseCase: RequestAvatarGenerationUseCase,
@Inject(UPLOAD_MEDIA_USE_CASE_TOKEN) private readonly uploadMediaUseCase: UploadMediaUseCase,
@Inject(GET_MEDIA_USE_CASE_TOKEN) private readonly getMediaUseCase: GetMediaUseCase,
@Inject(DELETE_MEDIA_USE_CASE_TOKEN) private readonly deleteMediaUseCase: DeleteMediaUseCase,
@Inject(GET_AVATAR_USE_CASE_TOKEN) private readonly getAvatarUseCase: GetAvatarUseCase,
@Inject(UPDATE_AVATAR_USE_CASE_TOKEN) private readonly updateAvatarUseCase: UpdateAvatarUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
@@ -43,46 +67,118 @@ export class MediaService {
await this.requestAvatarGenerationUseCase.execute({
userId: input.userId,
facePhotoData: input.facePhotoData,
suitColor: input.suitColor as any,
suitColor: input.suitColor as RacingSuitColor,
}, presenter);
return presenter.viewModel;
}
async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise<UploadMediaOutput> {
this.logger.debug('[MediaService] Uploading media.');
// TODO: Implement media upload logic
return {
success: true,
mediaId: 'placeholder-media-id',
url: 'placeholder-url',
};
const presenter = new UploadMediaPresenter();
await this.uploadMediaUseCase.execute({
file: input.file,
uploadedBy: input.userId, // Assuming userId is the uploader
metadata: input.metadata,
}, presenter);
const result = presenter.viewModel;
if (result.success) {
return {
success: true,
mediaId: result.mediaId!,
url: result.url!,
};
} else {
return {
success: false,
errorMessage: result.errorMessage || 'Upload failed',
};
}
}
async getMedia(mediaId: string): Promise<GetMediaOutput | null> {
this.logger.debug(`[MediaService] Getting media: ${mediaId}`);
// TODO: Implement get media logic
const presenter = new GetMediaPresenter();
await this.getMediaUseCase.execute({ mediaId }, presenter);
const result = presenter.viewModel;
if (result.success && result.media) {
return {
success: true,
mediaId: result.media.id,
filename: result.media.filename,
originalName: result.media.originalName,
mimeType: result.media.mimeType,
size: result.media.size,
url: result.media.url,
type: result.media.type,
uploadedBy: result.media.uploadedBy,
uploadedAt: result.media.uploadedAt,
metadata: result.media.metadata,
};
}
return null;
}
async deleteMedia(mediaId: string): Promise<DeleteMediaOutput> {
this.logger.debug(`[MediaService] Deleting media: ${mediaId}`);
// TODO: Implement delete media logic
const presenter = new DeleteMediaPresenter();
await this.deleteMediaUseCase.execute({ mediaId }, presenter);
const result = presenter.viewModel;
return {
success: true,
success: result.success,
errorMessage: result.errorMessage,
};
}
async getAvatar(driverId: string): Promise<GetAvatarOutput | null> {
this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`);
// TODO: Implement get avatar logic
const presenter = new GetAvatarPresenter();
await this.getAvatarUseCase.execute({ driverId }, presenter);
const result = presenter.viewModel;
if (result.success && result.avatar) {
return {
success: true,
avatarId: result.avatar.id,
driverId: result.avatar.driverId,
mediaUrl: result.avatar.mediaUrl,
selectedAt: result.avatar.selectedAt,
};
}
return null;
}
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutput> {
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
// TODO: Implement update avatar logic
const presenter = new UpdateAvatarPresenter();
await this.updateAvatarUseCase.execute({
driverId,
mediaUrl: input.mediaUrl,
}, presenter);
const result = presenter.viewModel;
return {
success: true,
success: result.success,
errorMessage: result.errorMessage,
};
}
}

View File

@@ -3,7 +3,7 @@ import { IsString, IsOptional } from 'class-validator';
export class UploadMediaInputDTO {
@ApiProperty({ type: 'string', format: 'binary' })
file: any; // File upload handled by multer
file: Express.Multer.File;
@ApiProperty()
@IsString()

View File

@@ -0,0 +1,14 @@
import type { IDeleteMediaPresenter, DeleteMediaResult } from '@core/media/application/presenters/IDeleteMediaPresenter';
export class DeleteMediaPresenter implements IDeleteMediaPresenter {
private result: DeleteMediaResult | null = null;
present(result: DeleteMediaResult) {
this.result = result;
}
get viewModel(): DeleteMediaResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,14 @@
import type { IGetAvatarPresenter, GetAvatarResult } from '@core/media/application/presenters/IGetAvatarPresenter';
export class GetAvatarPresenter implements IGetAvatarPresenter {
private result: GetAvatarResult | null = null;
present(result: GetAvatarResult) {
this.result = result;
}
get viewModel(): GetAvatarResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,14 @@
import type { IGetMediaPresenter, GetMediaResult } from '@core/media/application/presenters/IGetMediaPresenter';
export class GetMediaPresenter implements IGetMediaPresenter {
private result: GetMediaResult | null = null;
present(result: GetMediaResult) {
this.result = result;
}
get viewModel(): GetMediaResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -1,6 +1,8 @@
import { RequestAvatarGenerationOutput } from '../dto/MediaDto';
import type { RequestAvatarGenerationOutputDTO } from '../dtos/RequestAvatarGenerationOutputDTO';
import type { IRequestAvatarGenerationPresenter, RequestAvatarGenerationResultDTO } from '@core/media/application/presenters/IRequestAvatarGenerationPresenter';
type RequestAvatarGenerationOutput = RequestAvatarGenerationOutputDTO;
export class RequestAvatarGenerationPresenter implements IRequestAvatarGenerationPresenter {
private result: RequestAvatarGenerationOutput | null = null;

View File

@@ -0,0 +1,14 @@
import type { IUpdateAvatarPresenter, UpdateAvatarResult } from '@core/media/application/presenters/IUpdateAvatarPresenter';
export class UpdateAvatarPresenter implements IUpdateAvatarPresenter {
private result: UpdateAvatarResult | null = null;
present(result: UpdateAvatarResult) {
this.result = result;
}
get viewModel(): UpdateAvatarResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,14 @@
import type { IUploadMediaPresenter, UploadMediaResult } from '@core/media/application/presenters/IUploadMediaPresenter';
export class UploadMediaPresenter implements IUploadMediaPresenter {
private result: UploadMediaResult | null = null;
present(result: UploadMediaResult) {
this.result = result;
}
get viewModel(): UploadMediaResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,194 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { PaymentsController } from './PaymentsController';
import { PaymentsService } from './PaymentsService';
import { GetPaymentsQuery, CreatePaymentInput, UpdatePaymentStatusInput, GetMembershipFeesQuery, UpsertMembershipFeeInput, UpdateMemberPaymentInput, GetPrizesQuery, CreatePrizeInput, AwardPrizeInput, DeletePrizeInput, GetWalletQuery, ProcessWalletTransactionInput } from './dtos/PaymentsDto';
describe('PaymentsController', () => {
let controller: PaymentsController;
let service: ReturnType<typeof vi.mocked<PaymentsService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PaymentsController],
providers: [
{
provide: PaymentsService,
useValue: {
getPayments: vi.fn(),
createPayment: vi.fn(),
updatePaymentStatus: vi.fn(),
getMembershipFees: vi.fn(),
upsertMembershipFee: vi.fn(),
updateMemberPayment: vi.fn(),
getPrizes: vi.fn(),
createPrize: vi.fn(),
awardPrize: vi.fn(),
deletePrize: vi.fn(),
getWallet: vi.fn(),
processWalletTransaction: vi.fn(),
},
},
],
}).compile();
controller = module.get<PaymentsController>(PaymentsController);
service = vi.mocked(module.get(PaymentsService));
});
describe('getPayments', () => {
it('should return payments', async () => {
const query: GetPaymentsQuery = { status: 'pending' };
const result = { payments: [] };
service.getPayments.mockResolvedValue(result);
const response = await controller.getPayments(query);
expect(service.getPayments).toHaveBeenCalledWith(query);
expect(response).toEqual(result);
});
});
describe('createPayment', () => {
it('should create payment', async () => {
const input: CreatePaymentInput = { amount: 100, type: 'membership_fee', payerId: 'payer-123', payerType: 'driver', leagueId: 'league-123' };
const result = { payment: { id: 'pay-123' } };
service.createPayment.mockResolvedValue(result);
const response = await controller.createPayment(input);
expect(service.createPayment).toHaveBeenCalledWith(input);
expect(response).toEqual(result);
});
});
describe('updatePaymentStatus', () => {
it('should update payment status', async () => {
const input: UpdatePaymentStatusInput = { paymentId: 'pay-123', status: 'completed' };
const result = { payment: { id: 'pay-123', status: 'completed' } };
service.updatePaymentStatus.mockResolvedValue(result);
const response = await controller.updatePaymentStatus(input);
expect(service.updatePaymentStatus).toHaveBeenCalledWith(input);
expect(response).toEqual(result);
});
});
describe('getMembershipFees', () => {
it('should return membership fees', async () => {
const query: GetMembershipFeesQuery = { leagueId: 'league-123' };
const result = { fees: [] };
service.getMembershipFees.mockResolvedValue(result);
const response = await controller.getMembershipFees(query);
expect(service.getMembershipFees).toHaveBeenCalledWith(query);
expect(response).toEqual(result);
});
});
describe('upsertMembershipFee', () => {
it('should upsert membership fee', async () => {
const input: UpsertMembershipFeeInput = { leagueId: 'league-123', amount: 50 };
const result = { feeId: 'fee-123' };
service.upsertMembershipFee.mockResolvedValue(result);
const response = await controller.upsertMembershipFee(input);
expect(service.upsertMembershipFee).toHaveBeenCalledWith(input);
expect(response).toEqual(result);
});
});
describe('updateMemberPayment', () => {
it('should update member payment', async () => {
const input: UpdateMemberPaymentInput = { memberId: 'member-123', paymentId: 'pay-123' };
const result = { success: true };
service.updateMemberPayment.mockResolvedValue(result);
const response = await controller.updateMemberPayment(input);
expect(service.updateMemberPayment).toHaveBeenCalledWith(input);
expect(response).toEqual(result);
});
});
describe('getPrizes', () => {
it('should return prizes', async () => {
const query: GetPrizesQuery = { leagueId: 'league-123' };
const result = { prizes: [] };
service.getPrizes.mockResolvedValue(result);
const response = await controller.getPrizes(query);
expect(service.getPrizes).toHaveBeenCalledWith(query);
expect(response).toEqual(result);
});
});
describe('createPrize', () => {
it('should create prize', async () => {
const input: CreatePrizeInput = { name: 'Prize', amount: 100 };
const result = { prizeId: 'prize-123' };
service.createPrize.mockResolvedValue(result);
const response = await controller.createPrize(input);
expect(service.createPrize).toHaveBeenCalledWith(input);
expect(response).toEqual(result);
});
});
describe('awardPrize', () => {
it('should award prize', async () => {
const input: AwardPrizeInput = { prizeId: 'prize-123', driverId: 'driver-123' };
const result = { success: true };
service.awardPrize.mockResolvedValue(result);
const response = await controller.awardPrize(input);
expect(service.awardPrize).toHaveBeenCalledWith(input);
expect(response).toEqual(result);
});
});
describe('deletePrize', () => {
it('should delete prize', async () => {
const query: DeletePrizeInput = { prizeId: 'prize-123' };
const result = { success: true };
service.deletePrize.mockResolvedValue(result);
const response = await controller.deletePrize(query);
expect(service.deletePrize).toHaveBeenCalledWith(query);
expect(response).toEqual(result);
});
});
describe('getWallet', () => {
it('should return wallet', async () => {
const query: GetWalletQuery = { userId: 'user-123' };
const result = { balance: 100 };
service.getWallet.mockResolvedValue(result);
const response = await controller.getWallet(query);
expect(service.getWallet).toHaveBeenCalledWith(query);
expect(response).toEqual(result);
});
});
describe('processWalletTransaction', () => {
it('should process wallet transaction', async () => {
const input: ProcessWalletTransactionInput = { userId: 'user-123', amount: 50, type: 'deposit' };
const result = { transactionId: 'tx-123' };
service.processWalletTransaction.mockResolvedValue(result);
const response = await controller.processWalletTransaction(input);
expect(service.processWalletTransaction).toHaveBeenCalledWith(input);
expect(response).toEqual(result);
});
});
});

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PaymentsModule } from './PaymentsModule';
import { PaymentsController } from './PaymentsController';
import { PaymentsService } from './PaymentsService';
describe('PaymentsModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [PaymentsModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide PaymentsController', () => {
const controller = module.get<PaymentsController>(PaymentsController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(PaymentsController);
});
it('should provide PaymentsService', () => {
const service = module.get<PaymentsService>(PaymentsService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(PaymentsService);
});
});

View File

@@ -23,10 +23,10 @@ import { GetWalletUseCase } from '@core/payments/application/use-cases/GetWallet
import { ProcessWalletTransactionUseCase } from '@core/payments/application/use-cases/ProcessWalletTransactionUseCase';
// Import concrete in-memory implementations
import { InMemoryPaymentRepository } from '/payments/persistence/inmemory/InMemoryPaymentRepository';
import { InMemoryMembershipFeeRepository, InMemoryMemberPaymentRepository } from '/payments/persistence/inmemory/InMemoryMembershipFeeRepository';
import { InMemoryPrizeRepository } from '/payments/persistence/inmemory/InMemoryPrizeRepository';
import { InMemoryWalletRepository, InMemoryTransactionRepository } from '/payments/persistence/inmemory/InMemoryWalletRepository';
import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository';
import { InMemoryMembershipFeeRepository, InMemoryMemberPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository';
import { InMemoryPrizeRepository } from '@adapters/payments/persistence/inmemory/InMemoryPrizeRepository';
import { InMemoryWalletRepository, InMemoryTransactionRepository } from '@adapters/payments/persistence/inmemory/InMemoryWalletRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Repository injection tokens

View File

@@ -0,0 +1,39 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { ProtestsController } from './ProtestsController';
import { RaceService } from '../race/RaceService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
describe('ProtestsController', () => {
let controller: ProtestsController;
let raceService: ReturnType<typeof vi.mocked<RaceService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProtestsController],
providers: [
{
provide: RaceService,
useValue: {
reviewProtest: vi.fn(),
},
},
],
}).compile();
controller = module.get<ProtestsController>(ProtestsController);
raceService = vi.mocked(module.get(RaceService));
});
describe('reviewProtest', () => {
it('should review protest', async () => {
const protestId = 'protest-123';
const body: Omit<ReviewProtestCommandDTO, 'protestId'> = { decision: 'upheld', reason: 'Reason' };
raceService.reviewProtest.mockResolvedValue(undefined);
await controller.reviewProtest(protestId, body);
expect(raceService.reviewProtest).toHaveBeenCalledWith({ protestId, ...body });
});
});
});

View File

@@ -0,0 +1,23 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProtestsModule } from './ProtestsModule';
import { ProtestsController } from './ProtestsController';
describe('ProtestsModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [ProtestsModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide ProtestsController', () => {
const controller = module.get<ProtestsController>(ProtestsController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(ProtestsController);
});
});

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { ProtestsController } from './ProtestsController';
import { RaceModule } from '../race/RaceModule';
import { ProtestsService } from './ProtestsService';
import { ProtestsProviders } from './ProtestsProviders';
@Module({
imports: [RaceModule],
providers: [ProtestsService, ...ProtestsProviders],
controllers: [ProtestsController],
})
export class ProtestsModule {}

View File

@@ -0,0 +1,45 @@
import { Provider } from '@nestjs/common';
import { ProtestsService } from './ProtestsService';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
// Import concrete in-memory implementations
import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository';
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Import use cases
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
// Define injection tokens
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const LOGGER_TOKEN = 'Logger';
export const ProtestsProviders: Provider[] = [
ProtestsService, // Provide the service itself
{
provide: PROTEST_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryProtestRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RACE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryLeagueMembershipRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
ReviewProtestUseCase,
];

View File

@@ -0,0 +1,31 @@
import { Injectable, Inject } from '@nestjs/common';
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
// Tokens
import { LOGGER_TOKEN } from './ProtestsProviders';
@Injectable()
export class ProtestsService {
constructor(
private readonly reviewProtestUseCase: ReviewProtestUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async reviewProtest(command: {
protestId: string;
stewardId: string;
decision: 'uphold' | 'dismiss';
decisionNotes: string;
}): Promise<void> {
this.logger.debug('[ProtestsService] Reviewing protest:', command);
const result = await this.reviewProtestUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to review protest');
}
}
}

View File

@@ -0,0 +1,74 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RaceController } from './RaceController';
import { RaceService } from './RaceService';
describe('RaceController', () => {
let controller: RaceController;
let service: jest.Mocked<RaceService>;
beforeEach(async () => {
const mockService = {
getAllRaces: jest.fn(),
getTotalRaces: jest.fn(),
getRacesPageData: jest.fn(),
getAllRacesPageData: jest.fn(),
getRaceDetail: jest.fn(),
getRaceResultsDetail: jest.fn(),
getRaceWithSOF: jest.fn(),
getRaceProtests: jest.fn(),
getRacePenalties: jest.fn(),
registerForRace: jest.fn(),
withdrawFromRace: jest.fn(),
cancelRace: jest.fn(),
completeRace: jest.fn(),
importRaceResults: jest.fn(),
fileProtest: jest.fn(),
applyQuickPenalty: jest.fn(),
applyPenalty: jest.fn(),
requestProtestDefense: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [RaceController],
providers: [
{
provide: RaceService,
useValue: mockService,
},
],
}).compile();
controller = module.get<RaceController>(RaceController);
service = module.get(RaceService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('getAllRaces', () => {
it('should return all races', async () => {
const mockResult = { races: [], totalCount: 0 };
service.getAllRaces.mockResolvedValue(mockResult);
const result = await controller.getAllRaces();
expect(service.getAllRaces).toHaveBeenCalled();
expect(result).toEqual(mockResult);
});
});
describe('getTotalRaces', () => {
it('should return total races count', async () => {
const mockResult = { totalRaces: 5 };
service.getTotalRaces.mockResolvedValue(mockResult);
const result = await controller.getTotalRaces();
expect(service.getTotalRaces).toHaveBeenCalled();
expect(result).toEqual(mockResult);
});
});
// Add more tests as needed
});

View File

@@ -9,10 +9,8 @@ import { RaceResultsDetailDTO } from './dtos/RaceResultsDetailDTO';
import { RaceWithSOFDTO } from './dtos/RaceWithSOFDTO';
import { RaceProtestsDTO } from './dtos/RaceProtestsDTO';
import { RacePenaltiesDTO } from './dtos/RacePenaltiesDTO';
import { GetRaceDetailParamsDTO } from './dtos/GetRaceDetailParamsDTO';
import { RegisterForRaceParamsDTO } from './dtos/RegisterForRaceParamsDTO';
import { WithdrawFromRaceParamsDTO } from './dtos/WithdrawFromRaceParamsDTO';
import { RaceActionParamsDTO } from './dtos/RaceActionParamsDTO';
import { ImportRaceResultsDTO } from './dtos/ImportRaceResultsDTO';
import { ImportRaceResultsSummaryDTO } from './dtos/ImportRaceResultsSummaryDTO';
import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
@@ -156,7 +154,7 @@ export class RaceController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'File a protest' })
@ApiResponse({ status: 200, description: 'Protest filed successfully' })
async fileProtest(@Body() body: FileProtestCommandDTO): Promise<any> {
async fileProtest(@Body() body: FileProtestCommandDTO): Promise<void> {
return this.raceService.fileProtest(body);
}
@@ -164,7 +162,7 @@ export class RaceController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Apply a quick penalty' })
@ApiResponse({ status: 200, description: 'Penalty applied successfully' })
async applyQuickPenalty(@Body() body: QuickPenaltyCommandDTO): Promise<any> {
async applyQuickPenalty(@Body() body: QuickPenaltyCommandDTO): Promise<void> {
return this.raceService.applyQuickPenalty(body);
}
@@ -172,7 +170,7 @@ export class RaceController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Apply a penalty' })
@ApiResponse({ status: 200, description: 'Penalty applied successfully' })
async applyPenalty(@Body() body: ApplyPenaltyCommandDTO): Promise<any> {
async applyPenalty(@Body() body: ApplyPenaltyCommandDTO): Promise<void> {
return this.raceService.applyPenalty(body);
}
@@ -180,7 +178,7 @@ export class RaceController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Request protest defense' })
@ApiResponse({ status: 200, description: 'Defense requested successfully' })
async requestProtestDefense(@Body() body: RequestProtestDefenseCommandDTO): Promise<any> {
async requestProtestDefense(@Body() body: RequestProtestDefenseCommandDTO): Promise<void> {
return this.raceService.requestProtestDefense(body);
}
}

View File

@@ -0,0 +1,28 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RaceModule } from './RaceModule';
import { RaceController } from './RaceController';
import { RaceService } from './RaceService';
describe('RaceModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [RaceModule],
}).compile();
});
it('should compile the module', async () => {
expect(module).toBeDefined();
});
it('should have RaceController', () => {
const controller = module.get<RaceController>(RaceController);
expect(controller).toBeDefined();
});
it('should have RaceService', () => {
const service = module.get<RaceService>(RaceService);
expect(service).toBeDefined();
});
});

View File

@@ -6,6 +6,7 @@ import type { Logger } from '@core/shared/application/Logger';
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
@@ -23,6 +24,7 @@ import { InMemoryResultRepository } from '@adapters/racing/persistence/inmemory/
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
import { InMemoryPenaltyRepository } from '@adapters/racing/persistence/inmemory/InMemoryPenaltyRepository';
import { InMemoryProtestRepository } from '@adapters/racing/persistence/inmemory/InMemoryProtestRepository';
import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemory/InMemoryStandingRepository';
import { InMemoryDriverRatingProvider } from '@adapters/racing/ports/InMemoryDriverRatingProvider';
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
@@ -58,6 +60,7 @@ export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const PENALTY_REPOSITORY_TOKEN = 'IPenaltyRepository';
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';
@@ -104,6 +107,11 @@ export const RaceProviders: Provider[] = [
useFactory: (logger: Logger) => new InMemoryProtestRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: STANDING_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryStandingRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DRIVER_RATING_PROVIDER_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRatingProvider(logger),
@@ -121,13 +129,13 @@ export const RaceProviders: Provider[] = [
// Use cases
{
provide: GetAllRacesUseCase,
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) => new GetAllRacesUseCase(raceRepo, leagueRepo),
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository, logger: Logger) => new GetAllRacesUseCase(raceRepo, leagueRepo, logger),
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetTotalRacesUseCase,
useFactory: (raceRepo: IRaceRepository) => new GetTotalRacesUseCase(raceRepo),
inject: [RACE_REPOSITORY_TOKEN],
useFactory: (raceRepo: IRaceRepository, logger: Logger) => new GetTotalRacesUseCase(raceRepo, logger),
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetRaceDetailUseCase,
@@ -173,14 +181,35 @@ export const RaceProviders: Provider[] = [
},
{
provide: GetRaceResultsDetailUseCase,
useFactory: (resultRepo: IResultRepository, driverRepo: IDriverRepository, imageService: IImageServicePort) =>
new GetRaceResultsDetailUseCase(resultRepo, driverRepo, imageService),
inject: [RESULT_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN],
useFactory: (
raceRepo: IRaceRepository,
leagueRepo: ILeagueRepository,
resultRepo: IResultRepository,
driverRepo: IDriverRepository,
penaltyRepo: IPenaltyRepository,
) => new GetRaceResultsDetailUseCase(raceRepo, leagueRepo, resultRepo, driverRepo, penaltyRepo),
inject: [
RACE_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
PENALTY_REPOSITORY_TOKEN,
],
},
{
provide: GetRaceWithSOFUseCase,
useFactory: (raceRepo: IRaceRepository) => new GetRaceWithSOFUseCase(raceRepo),
inject: [RACE_REPOSITORY_TOKEN],
useFactory: (
raceRepo: IRaceRepository,
raceRegRepo: IRaceRegistrationRepository,
resultRepo: IResultRepository,
driverRatingProvider: DriverRatingProvider,
) => new GetRaceWithSOFUseCase(raceRepo, raceRegRepo, resultRepo, driverRatingProvider),
inject: [
RACE_REPOSITORY_TOKEN,
RACE_REGISTRATION_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
DRIVER_RATING_PROVIDER_TOKEN,
],
},
{
provide: GetRaceProtestsUseCase,
@@ -200,8 +229,8 @@ export const RaceProviders: Provider[] = [
},
{
provide: WithdrawFromRaceUseCase,
useFactory: (raceRegRepo: IRaceRegistrationRepository, logger: Logger) => new WithdrawFromRaceUseCase(raceRegRepo, logger),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (raceRegRepo: IRaceRegistrationRepository) => new WithdrawFromRaceUseCase(raceRegRepo),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN],
},
{
provide: CancelRaceUseCase,
@@ -210,16 +239,78 @@ export const RaceProviders: Provider[] = [
},
{
provide: CompleteRaceUseCase,
useFactory: (raceRepo: IRaceRepository, logger: Logger) => new CompleteRaceUseCase(raceRepo, logger),
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (
raceRepo: IRaceRepository,
raceRegRepo: IRaceRegistrationRepository,
resultRepo: IResultRepository,
standingRepo: IStandingRepository,
driverRatingProvider: DriverRatingProvider,
) => new CompleteRaceUseCase(raceRepo, raceRegRepo, resultRepo, standingRepo, driverRatingProvider),
inject: [
RACE_REPOSITORY_TOKEN,
RACE_REGISTRATION_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
DRIVER_RATING_PROVIDER_TOKEN,
],
},
{
provide: ImportRaceResultsApiUseCase,
useFactory: (
raceRepo: IRaceRepository,
leagueRepo: ILeagueRepository,
resultRepo: IResultRepository,
driverRepo: IDriverRepository,
standingRepo: IStandingRepository,
logger: Logger,
) => new ImportRaceResultsApiUseCase(
raceRepo,
leagueRepo,
resultRepo,
driverRepo,
standingRepo,
logger,
),
inject: [
RACE_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
LOGGER_TOKEN,
],
},
{
provide: ImportRaceResultsUseCase,
useFactory: (
raceRepo: IRaceRepository,
leagueRepo: ILeagueRepository,
resultRepo: IResultRepository,
driverRepo: IDriverRepository,
standingRepo: IStandingRepository,
logger: Logger,
) => new ImportRaceResultsUseCase(
raceRepo,
leagueRepo,
resultRepo,
driverRepo,
standingRepo,
logger,
),
inject: [
RACE_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
LOGGER_TOKEN,
],
},
ImportRaceResultsApiUseCase,
ImportRaceResultsUseCase,
{
provide: FileProtestUseCase,
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, driverRepo: IDriverRepository, logger: Logger) =>
new FileProtestUseCase(protestRepo, raceRepo, driverRepo, logger),
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) =>
new FileProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo),
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: QuickPenaltyUseCase,

View File

@@ -1,20 +1,19 @@
import { Injectable, Inject } from '@nestjs/common';
import {
AllRacesPageViewModel,
RaceStatsDto,
ImportRaceResultsInput,
ImportRaceResultsSummaryViewModel,
RaceDetailViewModelDto,
RacesPageDataViewModelDto,
RaceResultsDetailViewModelDto,
RaceWithSOFViewModelDto,
RaceProtestsViewModelDto,
RacePenaltiesViewModelDto,
GetRaceDetailParamsDto,
RegisterForRaceParamsDto,
WithdrawFromRaceParamsDto,
RaceActionParamsDto,
} from './dtos/RaceDTO';
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
import type { GetTotalRacesViewModel } from '@core/racing/application/presenters/IGetTotalRacesPresenter';
import type { RaceDetailViewModel } from '@core/racing/application/presenters/IRaceDetailPresenter';
import type { RacesPageViewModel } from '@core/racing/application/presenters/IRacesPagePresenter';
import type { RaceResultsDetailViewModel } from '@core/racing/application/presenters/IRaceResultsDetailPresenter';
import type { RaceWithSOFViewModel } from '@core/racing/application/presenters/IRaceWithSOFPresenter';
import type { RaceProtestsViewModel } from '@core/racing/application/presenters/IRaceProtestsPresenter';
import type { RacePenaltiesViewModel } from '@core/racing/application/presenters/IRacePenaltiesPresenter';
// DTOs
import { GetRaceDetailParamsDTO } from './dtos/GetRaceDetailParamsDTO';
import { RegisterForRaceParamsDTO } from './dtos/RegisterForRaceParamsDTO';
import { WithdrawFromRaceParamsDTO } from './dtos/WithdrawFromRaceParamsDTO';
import { RaceActionParamsDTO } from './dtos/RaceActionParamsDTO';
import { ImportRaceResultsDTO } from './dtos/ImportRaceResultsDTO';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
@@ -46,6 +45,13 @@ import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
import { GetTotalRacesPresenter } from './presenters/GetTotalRacesPresenter';
import { ImportRaceResultsApiPresenter } from './presenters/ImportRaceResultsApiPresenter';
// Command DTOs
import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO';
import { ApplyPenaltyCommandDTO } from './dtos/ApplyPenaltyCommandDTO';
import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO';
import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO';
// Tokens
import { LOGGER_TOKEN } from './RaceProviders';
@@ -66,7 +72,6 @@ export class RaceService {
private readonly withdrawFromRaceUseCase: WithdrawFromRaceUseCase,
private readonly cancelRaceUseCase: CancelRaceUseCase,
private readonly completeRaceUseCase: CompleteRaceUseCase,
private readonly importRaceResultsUseCase: ImportRaceResultsUseCase,
private readonly fileProtestUseCase: FileProtestUseCase,
private readonly quickPenaltyUseCase: QuickPenaltyUseCase,
private readonly applyPenaltyUseCase: ApplyPenaltyUseCase,
@@ -83,34 +88,33 @@ export class RaceService {
return presenter.getViewModel()!;
}
async getTotalRaces(): Promise<RaceStatsDto> {
async getTotalRaces(): Promise<GetTotalRacesViewModel> {
this.logger.debug('[RaceService] Fetching total races count.');
const presenter = new GetTotalRacesPresenter();
await this.getTotalRacesUseCase.execute({}, presenter);
return presenter.getViewModel()!;
}
async importRaceResults(input: ImportRaceResultsInput): Promise<ImportRaceResultsSummaryViewModel> {
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsSummaryDTO> {
this.logger.debug('Importing race results:', input);
const presenter = new ImportRaceResultsApiPresenter();
await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent }, presenter);
return presenter.getViewModel()!;
}
async getRaceDetail(params: GetRaceDetailParamsDto): Promise<RaceDetailViewModelDto> {
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailViewModel> {
this.logger.debug('[RaceService] Fetching race detail:', params);
const presenter = new RaceDetailPresenter();
const result = await this.getRaceDetailUseCase.execute(params);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to get race detail');
throw new Error('Failed to get race detail');
}
return result.value;
}
async getRacesPageData(): Promise<RacesPageDataViewModelDto> {
async getRacesPageData(): Promise<RacesPageViewModel> {
this.logger.debug('[RaceService] Fetching races page data.');
const result = await this.getRacesPageDataUseCase.execute();
@@ -122,7 +126,7 @@ export class RaceService {
return result.value;
}
async getAllRacesPageData(): Promise<RacesPageDataViewModelDto> {
async getAllRacesPageData(): Promise<RacesPageViewModel> {
this.logger.debug('[RaceService] Fetching all races page data.');
const result = await this.getAllRacesPageDataUseCase.execute();
@@ -134,165 +138,143 @@ export class RaceService {
return result.value;
}
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailViewModelDto> {
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailViewModel> {
this.logger.debug('[RaceService] Fetching race results detail:', { raceId });
const result = await this.getRaceResultsDetailUseCase.execute({ raceId });
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to get race results detail');
throw new Error('Failed to get race results detail');
}
return result.value;
}
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFViewModelDto> {
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
this.logger.debug('[RaceService] Fetching race with SOF:', { raceId });
const result = await this.getRaceWithSOFUseCase.execute({ raceId });
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to get race with SOF');
throw new Error('Failed to get race with SOF');
}
return result.value;
}
async getRaceProtests(raceId: string): Promise<RaceProtestsViewModelDto> {
async getRaceProtests(raceId: string): Promise<RaceProtestsViewModel> {
this.logger.debug('[RaceService] Fetching race protests:', { raceId });
const result = await this.getRaceProtestsUseCase.execute({ raceId });
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to get race protests');
throw new Error('Failed to get race protests');
}
return result.value;
}
async getRacePenalties(raceId: string): Promise<RacePenaltiesViewModelDto> {
async getRacePenalties(raceId: string): Promise<RacePenaltiesViewModel> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
const result = await this.getRacePenaltiesUseCase.execute({ raceId });
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to get race penalties');
throw new Error('Failed to get race penalties');
}
return result.value;
}
async registerForRace(params: RegisterForRaceParamsDto): Promise<void> {
async registerForRace(params: RegisterForRaceParamsDTO): Promise<void> {
this.logger.debug('[RaceService] Registering for race:', params);
const result = await this.registerForRaceUseCase.execute(params);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to register for race');
throw new Error('Failed to register for race');
}
}
async withdrawFromRace(params: WithdrawFromRaceParamsDto): Promise<void> {
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<void> {
this.logger.debug('[RaceService] Withdrawing from race:', params);
const result = await this.withdrawFromRaceUseCase.execute(params);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to withdraw from race');
throw new Error('Failed to withdraw from race');
}
}
async cancelRace(params: RaceActionParamsDto): Promise<void> {
async cancelRace(params: RaceActionParamsDTO): Promise<void> {
this.logger.debug('[RaceService] Cancelling race:', params);
const result = await this.cancelRaceUseCase.execute({ raceId: params.raceId });
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to cancel race');
throw new Error('Failed to cancel race');
}
}
async completeRace(params: RaceActionParamsDto): Promise<void> {
async completeRace(params: RaceActionParamsDTO): Promise<void> {
this.logger.debug('[RaceService] Completing race:', params);
const result = await this.completeRaceUseCase.execute({ raceId: params.raceId });
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to complete race');
}
}
async importRaceResultsAlt(params: { raceId: string; resultsFileContent: string }): Promise<void> {
this.logger.debug('[RaceService] Importing race results (alt):', params);
const result = await this.importRaceResultsUseCase.execute({
raceId: params.raceId,
resultsFileContent: params.resultsFileContent,
});
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to import race results');
throw new Error('Failed to complete race');
}
}
async fileProtest(command: any): Promise<any> {
async fileProtest(command: FileProtestCommandDTO): Promise<void> {
this.logger.debug('[RaceService] Filing protest:', command);
const result = await this.fileProtestUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to file protest');
throw new Error('Failed to file protest');
}
return result.value;
}
async applyQuickPenalty(command: any): Promise<any> {
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<void> {
this.logger.debug('[RaceService] Applying quick penalty:', command);
const result = await this.quickPenaltyUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to apply quick penalty');
throw new Error('Failed to apply quick penalty');
}
return result.value;
}
async applyPenalty(command: any): Promise<any> {
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<void> {
this.logger.debug('[RaceService] Applying penalty:', command);
const result = await this.applyPenaltyUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to apply penalty');
throw new Error('Failed to apply penalty');
}
return result.value;
}
async requestProtestDefense(command: any): Promise<any> {
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<void> {
this.logger.debug('[RaceService] Requesting protest defense:', command);
const result = await this.requestProtestDefenseUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to request protest defense');
throw new Error('Failed to request protest defense');
}
return result.value;
}
async reviewProtest(command: any): Promise<any> {
async reviewProtest(command: ReviewProtestCommandDTO): Promise<void> {
this.logger.debug('[RaceService] Reviewing protest:', command);
const result = await this.reviewProtestUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.error.details.message || 'Failed to review protest');
throw new Error('Failed to review protest');
}
return result.value;
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { RaceViewModel } from './RaceViewModel';
import { RaceViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
export class AllRacesPageDTO {
@ApiProperty({ type: [RaceViewModel] })

View File

@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty } from 'class-validator';
export class GetRaceDetailParamsDTODTO {
export class GetRaceDetailParamsDTO {
@ApiProperty()
@IsString()
@IsNotEmpty()

View File

@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsString, IsNumber } from 'class-validator';
export class ImportRaceResultsSummaryDTOViewModel {
export class ImportRaceResultsSummaryDTO {
@ApiProperty()
@IsBoolean()
success!: boolean;

View File

@@ -1,9 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { RaceProtestDto } from './RaceProtestDto';
import { RaceProtestDTO } from './RaceProtestDTO';
export class RaceProtestsDTO {
@ApiProperty({ type: [RaceProtestDto] })
protests!: RaceProtestDto[];
@ApiProperty({ type: [RaceProtestDTO] })
protests!: RaceProtestDTO[];
@ApiProperty()
driverMap!: Record<string, string>;

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { RaceResultDto } from './RaceResultDto';
import { RaceResultDTO } from './RaceResultDTO';
export class RaceResultsDetailDTO {
@ApiProperty()
@@ -11,6 +11,6 @@ export class RaceResultsDetailDTO {
@IsString()
track!: string;
@ApiProperty({ type: [RaceResultDto] })
results!: RaceResultDto[];
@ApiProperty({ type: [RaceResultDTO] })
results!: RaceResultDTO[];
}

View File

@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { RacesPageDataRaceDto } from './RacesPageDataRaceDto';
import { RacesPageDataRaceDTO } from './RacesPageDataRaceDTO';
export class RacesPageDataDTO {
@ApiProperty({ type: [RacesPageDataRaceDto] })
races!: RacesPageDataRaceDto[];
@ApiProperty({ type: [RacesPageDataRaceDTO] })
races!: RacesPageDataRaceDTO[];
}

View File

@@ -1,8 +1,7 @@
import { IGetTotalRacesPresenter, GetTotalRacesResultDTO } from '@core/racing/application/presenters/IGetTotalRacesPresenter';
import { RaceStatsDto } from '../dto/RaceDto';
import { IGetTotalRacesPresenter, GetTotalRacesResultDTO, GetTotalRacesViewModel } from '@core/racing/application/presenters/IGetTotalRacesPresenter';
export class GetTotalRacesPresenter implements IGetTotalRacesPresenter {
private result: RaceStatsDto | null = null;
private result: GetTotalRacesViewModel | null = null;
reset() {
this.result = null;
@@ -14,7 +13,7 @@ export class GetTotalRacesPresenter implements IGetTotalRacesPresenter {
};
}
getViewModel(): RaceStatsDto | null {
getViewModel(): GetTotalRacesViewModel | null {
return this.result;
}
}

View File

@@ -0,0 +1,210 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { SponsorController } from './SponsorController';
import { SponsorService } from './SponsorService';
describe('SponsorController', () => {
let controller: SponsorController;
let sponsorService: ReturnType<typeof vi.mocked<SponsorService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SponsorController],
providers: [
{
provide: SponsorService,
useValue: {
getEntitySponsorshipPricing: vi.fn(),
getSponsors: vi.fn(),
createSponsor: vi.fn(),
getSponsorDashboard: vi.fn(),
getSponsorSponsorships: vi.fn(),
getSponsor: vi.fn(),
getPendingSponsorshipRequests: vi.fn(),
acceptSponsorshipRequest: vi.fn(),
rejectSponsorshipRequest: vi.fn(),
},
},
],
}).compile();
controller = module.get<SponsorController>(SponsorController);
sponsorService = vi.mocked(module.get(SponsorService));
});
describe('getEntitySponsorshipPricing', () => {
it('should return sponsorship pricing', async () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
sponsorService.getEntitySponsorshipPricing.mockResolvedValue(mockResult);
const result = await controller.getEntitySponsorshipPricing();
expect(result).toEqual(mockResult);
expect(sponsorService.getEntitySponsorshipPricing).toHaveBeenCalled();
});
});
describe('getSponsors', () => {
it('should return sponsors list', async () => {
const mockResult = { sponsors: [] };
sponsorService.getSponsors.mockResolvedValue(mockResult);
const result = await controller.getSponsors();
expect(result).toEqual(mockResult);
expect(sponsorService.getSponsors).toHaveBeenCalled();
});
});
describe('createSponsor', () => {
it('should create sponsor', async () => {
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor' };
sponsorService.createSponsor.mockResolvedValue(mockResult);
const result = await controller.createSponsor(input);
expect(result).toEqual(mockResult);
expect(sponsorService.createSponsor).toHaveBeenCalledWith(input);
});
});
describe('getSponsorDashboard', () => {
it('should return sponsor dashboard', async () => {
const sponsorId = 'sponsor-1';
const mockResult = { sponsorId, metrics: {}, sponsoredLeagues: [] };
sponsorService.getSponsorDashboard.mockResolvedValue(mockResult);
const result = await controller.getSponsorDashboard(sponsorId);
expect(result).toEqual(mockResult);
expect(sponsorService.getSponsorDashboard).toHaveBeenCalledWith({ sponsorId });
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
sponsorService.getSponsorDashboard.mockResolvedValue(null);
const result = await controller.getSponsorDashboard(sponsorId);
expect(result).toBeNull();
});
});
describe('getSponsorSponsorships', () => {
it('should return sponsor sponsorships', async () => {
const sponsorId = 'sponsor-1';
const mockResult = { sponsorId, sponsorships: [] };
sponsorService.getSponsorSponsorships.mockResolvedValue(mockResult);
const result = await controller.getSponsorSponsorships(sponsorId);
expect(result).toEqual(mockResult);
expect(sponsorService.getSponsorSponsorships).toHaveBeenCalledWith({ sponsorId });
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
sponsorService.getSponsorSponsorships.mockResolvedValue(null);
const result = await controller.getSponsorSponsorships(sponsorId);
expect(result).toBeNull();
});
});
describe('getSponsor', () => {
it('should return sponsor', async () => {
const sponsorId = 'sponsor-1';
const mockResult = { id: sponsorId, name: 'Test Sponsor' };
sponsorService.getSponsor.mockResolvedValue(mockResult);
const result = await controller.getSponsor(sponsorId);
expect(result).toEqual(mockResult);
expect(sponsorService.getSponsor).toHaveBeenCalledWith(sponsorId);
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
sponsorService.getSponsor.mockResolvedValue(null);
const result = await controller.getSponsor(sponsorId);
expect(result).toBeNull();
});
});
describe('getPendingSponsorshipRequests', () => {
it('should return pending sponsorship requests', async () => {
const query = { entityType: 'season' as const, entityId: 'season-1' };
const mockResult = { entityType: 'season', entityId: 'season-1', requests: [], totalCount: 0 };
sponsorService.getPendingSponsorshipRequests.mockResolvedValue(mockResult);
const result = await controller.getPendingSponsorshipRequests(query);
expect(result).toEqual(mockResult);
expect(sponsorService.getPendingSponsorshipRequests).toHaveBeenCalledWith(query);
});
});
describe('acceptSponsorshipRequest', () => {
it('should accept sponsorship request', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1' };
const mockResult = {
requestId,
sponsorshipId: 'sponsorship-1',
status: 'accepted' as const,
acceptedAt: new Date(),
platformFee: 10,
netAmount: 90,
};
sponsorService.acceptSponsorshipRequest.mockResolvedValue(mockResult);
const result = await controller.acceptSponsorshipRequest(requestId, input);
expect(result).toEqual(mockResult);
expect(sponsorService.acceptSponsorshipRequest).toHaveBeenCalledWith(requestId, input.respondedBy);
});
it('should return null on error', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1' };
sponsorService.acceptSponsorshipRequest.mockResolvedValue(null);
const result = await controller.acceptSponsorshipRequest(requestId, input);
expect(result).toBeNull();
});
});
describe('rejectSponsorshipRequest', () => {
it('should reject sponsorship request', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1', reason: 'Not interested' };
const mockResult = {
requestId,
status: 'rejected' as const,
rejectedAt: new Date(),
reason: 'Not interested',
};
sponsorService.rejectSponsorshipRequest.mockResolvedValue(mockResult);
const result = await controller.rejectSponsorshipRequest(requestId, input);
expect(result).toEqual(mockResult);
expect(sponsorService.rejectSponsorshipRequest).toHaveBeenCalledWith(requestId, input.respondedBy, input.reason);
});
it('should return null on error', async () => {
const requestId = 'request-1';
const input = { respondedBy: 'user-1' };
sponsorService.rejectSponsorshipRequest.mockResolvedValue(null);
const result = await controller.rejectSponsorshipRequest(requestId, input);
expect(result).toBeNull();
});
});
});

View File

@@ -13,6 +13,8 @@ import { GetSponsorOutputDTO } from './dtos/GetSponsorOutputDTO';
import { GetPendingSponsorshipRequestsOutputDTO } from './dtos/GetPendingSponsorshipRequestsOutputDTO';
import { AcceptSponsorshipRequestInputDTO } from './dtos/AcceptSponsorshipRequestInputDTO';
import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO';
import type { AcceptSponsorshipRequestResultDTO } from '@core/racing/application/dtos/AcceptSponsorshipRequestResultDTO';
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
@ApiTags('sponsors')
@Controller('sponsors')
@@ -69,26 +71,26 @@ export class SponsorController {
@ApiOperation({ summary: 'Get pending sponsorship requests' })
@ApiResponse({ status: 200, description: 'List of pending sponsorship requests', type: GetPendingSponsorshipRequestsOutputDTO })
async getPendingSponsorshipRequests(@Query() query: { entityType: string; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> {
return this.sponsorService.getPendingSponsorshipRequests(query);
return this.sponsorService.getPendingSponsorshipRequests(query as { entityType: import('@core/racing/domain/entities/SponsorshipRequest').SponsorableEntityType; entityId: string });
}
@Post('requests/:requestId/accept')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Accept a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async acceptSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: AcceptSponsorshipRequestInputDTO): Promise<any> {
return this.sponsorService.acceptSponsorshipRequest(requestId, input.respondedBy);
}
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Accept a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request accepted' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async acceptSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: AcceptSponsorshipRequestInputDTO): Promise<AcceptSponsorshipRequestResultDTO | null> {
return this.sponsorService.acceptSponsorshipRequest(requestId, input.respondedBy);
}
@Post('requests/:requestId/reject')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async rejectSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: RejectSponsorshipRequestInputDTO): Promise<any> {
return this.sponsorService.rejectSponsorshipRequest(requestId, input.respondedBy, input.reason);
}
@Post('requests/:requestId/reject')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reject a sponsorship request' })
@ApiResponse({ status: 200, description: 'Sponsorship request rejected' })
@ApiResponse({ status: 400, description: 'Invalid request' })
@ApiResponse({ status: 404, description: 'Request not found' })
async rejectSponsorshipRequest(@Param('requestId') requestId: string, @Body() input: RejectSponsorshipRequestInputDTO): Promise<RejectSponsorshipRequestResultDTO | null> {
return this.sponsorService.rejectSponsorshipRequest(requestId, input.respondedBy, input.reason);
}
}

View File

@@ -10,6 +10,10 @@ import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/IL
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ISponsorshipPricingRepository';
import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import { INotificationService } from '@core/notifications/application/ports/INotificationService';
import { IPaymentGateway } from '@core/racing/application/ports/IPaymentGateway';
import { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import type { Logger } from '@core/shared/application';
// Import use cases
@@ -152,7 +156,7 @@ export const SponsorProviders: Provider[] = [
},
{
provide: ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
useFactory: (sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, notificationService: any, paymentGateway: any, walletRepository: any, leagueWalletRepository: any, logger: Logger) =>
useFactory: (sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, notificationService: INotificationService, paymentGateway: IPaymentGateway, walletRepository: IWalletRepository, leagueWalletRepository: ILeagueWalletRepository, logger: Logger) =>
new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepo, seasonSponsorshipRepo, seasonRepo, notificationService, paymentGateway, walletRepository, leagueWalletRepository, logger),
inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, 'INotificationService', 'IPaymentGateway', 'IWalletRepository', 'ILeagueWalletRepository', LOGGER_TOKEN],
},

View File

@@ -0,0 +1,258 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { SponsorService } from './SponsorService';
import type { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import type { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import type { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import type { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import type { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import type { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
import type { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import type { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import type { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('SponsorService', () => {
let service: SponsorService;
let getSponsorshipPricingUseCase: { execute: Mock };
let getSponsorsUseCase: { execute: Mock };
let createSponsorUseCase: { execute: Mock };
let getSponsorDashboardUseCase: { execute: Mock };
let getSponsorSponsorshipsUseCase: { execute: Mock };
let getSponsorUseCase: { execute: Mock };
let getPendingSponsorshipRequestsUseCase: { execute: Mock };
let acceptSponsorshipRequestUseCase: { execute: Mock };
let rejectSponsorshipRequestUseCase: { execute: Mock };
let logger: {
debug: Mock;
info: Mock;
warn: Mock;
error: Mock;
};
beforeEach(() => {
getSponsorshipPricingUseCase = { execute: vi.fn() };
getSponsorsUseCase = { execute: vi.fn() };
createSponsorUseCase = { execute: vi.fn() };
getSponsorDashboardUseCase = { execute: vi.fn() };
getSponsorSponsorshipsUseCase = { execute: vi.fn() };
getSponsorUseCase = { execute: vi.fn() };
getPendingSponsorshipRequestsUseCase = { execute: vi.fn() };
acceptSponsorshipRequestUseCase = { execute: vi.fn() };
rejectSponsorshipRequestUseCase = { execute: vi.fn() };
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
service = new SponsorService(
getSponsorshipPricingUseCase as unknown as GetSponsorshipPricingUseCase,
getSponsorsUseCase as unknown as GetSponsorsUseCase,
createSponsorUseCase as unknown as CreateSponsorUseCase,
getSponsorDashboardUseCase as unknown as GetSponsorDashboardUseCase,
getSponsorSponsorshipsUseCase as unknown as GetSponsorSponsorshipsUseCase,
getSponsorUseCase as unknown as GetSponsorUseCase,
getPendingSponsorshipRequestsUseCase as unknown as GetPendingSponsorshipRequestsUseCase,
acceptSponsorshipRequestUseCase as unknown as AcceptSponsorshipRequestUseCase,
rejectSponsorshipRequestUseCase as unknown as RejectSponsorshipRequestUseCase,
logger as unknown as Logger,
);
});
describe('getEntitySponsorshipPricing', () => {
it('should return sponsorship pricing', async () => {
const mockPresenter = {
viewModel: { entityType: 'season', entityId: 'season-1', pricing: [] },
};
getSponsorshipPricingUseCase.execute.mockResolvedValue(undefined);
// Mock the presenter
const originalGetSponsorshipPricingPresenter = await import('./presenters/GetSponsorshipPricingPresenter');
const mockPresenterClass = vi.fn().mockImplementation(() => mockPresenter);
vi.doMock('./presenters/GetSponsorshipPricingPresenter', () => ({
GetSponsorshipPricingPresenter: mockPresenterClass,
}));
const result = await service.getEntitySponsorshipPricing();
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorshipPricingUseCase.execute).toHaveBeenCalledWith(undefined, mockPresenter);
});
});
describe('getSponsors', () => {
it('should return sponsors list', async () => {
const mockPresenter = {
viewModel: { sponsors: [] },
};
getSponsorsUseCase.execute.mockResolvedValue(undefined);
const result = await service.getSponsors();
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorsUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object));
});
});
describe('createSponsor', () => {
it('should create sponsor successfully', async () => {
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
const mockPresenter = {
viewModel: { id: 'sponsor-1', name: 'Test Sponsor' },
};
createSponsorUseCase.execute.mockResolvedValue(undefined);
const result = await service.createSponsor(input);
expect(result).toEqual(mockPresenter.viewModel);
expect(createSponsorUseCase.execute).toHaveBeenCalledWith(input, expect.any(Object));
});
});
describe('getSponsorDashboard', () => {
it('should return sponsor dashboard', async () => {
const params = { sponsorId: 'sponsor-1' };
const mockPresenter = {
viewModel: { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] },
};
getSponsorDashboardUseCase.execute.mockResolvedValue(undefined);
const result = await service.getSponsorDashboard(params);
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorDashboardUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object));
});
});
describe('getSponsorSponsorships', () => {
it('should return sponsor sponsorships', async () => {
const params = { sponsorId: 'sponsor-1' };
const mockPresenter = {
viewModel: { sponsorId: 'sponsor-1', sponsorships: [] },
};
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(undefined);
const result = await service.getSponsorSponsorships(params);
expect(result).toEqual(mockPresenter.viewModel);
expect(getSponsorSponsorshipsUseCase.execute).toHaveBeenCalledWith(params, expect.any(Object));
});
});
describe('getSponsor', () => {
it('should return sponsor when found', async () => {
const sponsorId = 'sponsor-1';
const mockSponsor = { id: sponsorId, name: 'Test Sponsor' };
getSponsorUseCase.execute.mockResolvedValue(Result.ok(mockSponsor));
const result = await service.getSponsor(sponsorId);
expect(result).toEqual(mockSponsor);
expect(getSponsorUseCase.execute).toHaveBeenCalledWith({ sponsorId });
});
it('should return null when sponsor not found', async () => {
const sponsorId = 'sponsor-1';
getSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
const result = await service.getSponsor(sponsorId);
expect(result).toBeNull();
});
});
describe('getPendingSponsorshipRequests', () => {
it('should return pending sponsorship requests', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
const mockResult = {
entityType: 'season',
entityId: 'season-1',
requests: [],
totalCount: 0,
};
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(mockResult));
const result = await service.getPendingSponsorshipRequests(params);
expect(result).toEqual(mockResult);
expect(getPendingSponsorshipRequestsUseCase.execute).toHaveBeenCalledWith(params);
});
it('should return empty result on error', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
const result = await service.getPendingSponsorshipRequests(params);
expect(result).toEqual({
entityType: 'season',
entityId: 'season-1',
requests: [],
totalCount: 0,
});
});
});
describe('acceptSponsorshipRequest', () => {
it('should accept sponsorship request successfully', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
const mockResult = {
requestId,
sponsorshipId: 'sponsorship-1',
status: 'accepted' as const,
acceptedAt: new Date(),
platformFee: 10,
netAmount: 90,
};
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(mockResult));
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
expect(result).toEqual(mockResult);
expect(acceptSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy });
});
it('should return null on error', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
expect(result).toBeNull();
});
});
describe('rejectSponsorshipRequest', () => {
it('should reject sponsorship request successfully', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
const reason = 'Not interested';
const mockResult = {
requestId,
status: 'rejected' as const,
rejectedAt: new Date(),
reason,
};
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(mockResult));
const result = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
expect(result).toEqual(mockResult);
expect(rejectSponsorshipRequestUseCase.execute).toHaveBeenCalledWith({ requestId, respondedBy, reason });
});
it('should return null on error', async () => {
const requestId = 'request-1';
const respondedBy = 'user-1';
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.err({ code: 'NOT_FOUND' }));
const result = await service.rejectSponsorshipRequest(requestId, respondedBy);
expect(result).toBeNull();
});
});
});

View File

@@ -11,11 +11,6 @@ import { GetSponsorOutputDTO } from './dtos/GetSponsorOutputDTO';
import { GetPendingSponsorshipRequestsOutputDTO } from './dtos/GetPendingSponsorshipRequestsOutputDTO';
import { AcceptSponsorshipRequestInputDTO } from './dtos/AcceptSponsorshipRequestInputDTO';
import { RejectSponsorshipRequestInputDTO } from './dtos/RejectSponsorshipRequestInputDTO';
import { SponsorDTO } from './dtos/SponsorDTO';
import { SponsorDashboardMetricsDTO } from './dtos/SponsorDashboardMetricsDTO';
import { SponsoredLeagueDTO } from './dtos/SponsoredLeagueDTO';
import { SponsorDashboardInvestmentDTO } from './dtos/SponsorDashboardInvestmentDTO';
import { SponsorshipDetailDTO } from './dtos/SponsorshipDetailDTO';
// Use cases
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
@@ -24,16 +19,13 @@ import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateS
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
import { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { GetPendingSponsorshipRequestsUseCase, GetPendingSponsorshipRequestsDTO } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest';
import type { AcceptSponsorshipRequestResultDTO } from '@core/racing/application/dtos/AcceptSponsorshipRequestResultDTO';
import type { RejectSponsorshipRequestResultDTO } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
// Presenters
import { GetSponsorshipPricingPresenter } from './presenters/GetSponsorshipPricingPresenter';
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
// Tokens
import { GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN, GET_SPONSORS_USE_CASE_TOKEN, CREATE_SPONSOR_USE_CASE_TOKEN, GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN, GET_SPONSOR_SPONSORSHIPS_USE_CASE_TOKEN, GET_SPONSOR_USE_CASE_TOKEN, GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN, ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN, REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN, LOGGER_TOKEN } from './SponsorProviders';
@@ -57,41 +49,56 @@ export class SponsorService {
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
const presenter = new GetSponsorshipPricingPresenter();
await this.getSponsorshipPricingUseCase.execute(undefined, presenter);
return presenter.viewModel;
const result = await this.getSponsorshipPricingUseCase.execute();
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsorship pricing.', result.error);
return { entityType: 'season', entityId: '', pricing: [] };
}
return result.value as GetEntitySponsorshipPricingResultDTO;
}
async getSponsors(): Promise<GetSponsorsOutputDTO> {
this.logger.debug('[SponsorService] Fetching sponsors.');
const presenter = new GetSponsorsPresenter();
await this.getSponsorsUseCase.execute(undefined, presenter);
return presenter.viewModel;
const result = await this.getSponsorsUseCase.execute();
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsors.', result.error);
return { sponsors: [] };
}
return result.value as GetSponsorsOutputDTO;
}
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
this.logger.debug('[SponsorService] Creating sponsor.', { input });
const presenter = new CreateSponsorPresenter();
await this.createSponsorUseCase.execute(input, presenter);
return presenter.viewModel;
const result = await this.createSponsorUseCase.execute(input);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to create sponsor.', result.error);
throw new Error(result.error.details?.message || 'Failed to create sponsor');
}
return result.value as CreateSponsorOutputDTO;
}
async getSponsorDashboard(params: GetSponsorDashboardQueryParamsDTO): Promise<SponsorDashboardDTO | null> {
this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
const presenter = new GetSponsorDashboardPresenter();
await this.getSponsorDashboardUseCase.execute(params, presenter);
return presenter.viewModel as SponsorDashboardDTO | null;
const result = await this.getSponsorDashboardUseCase.execute(params);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor dashboard.', result.error);
return null;
}
return result.value as SponsorDashboardDTO | null;
}
async getSponsorSponsorships(params: GetSponsorSponsorshipsQueryParamsDTO): Promise<SponsorSponsorshipsDTO | null> {
this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
const presenter = new GetSponsorSponsorshipsPresenter();
await this.getSponsorSponsorshipsUseCase.execute(params, presenter);
return presenter.viewModel as SponsorSponsorshipsDTO | null;
const result = await this.getSponsorSponsorshipsUseCase.execute(params);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch sponsor sponsorships.', result.error);
return null;
}
return result.value as SponsorSponsorshipsDTO | null;
}
async getSponsor(sponsorId: string): Promise<GetSponsorOutputDTO | null> {
@@ -105,18 +112,18 @@ export class SponsorService {
return result.value as GetSponsorOutputDTO | null;
}
async getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> {
async getPendingSponsorshipRequests(params: { entityType: SponsorableEntityType; entityId: string }): Promise<GetPendingSponsorshipRequestsOutputDTO> {
this.logger.debug('[SponsorService] Fetching pending sponsorship requests.', { params });
const result = await this.getPendingSponsorshipRequestsUseCase.execute(params as any);
const result = await this.getPendingSponsorshipRequestsUseCase.execute(params as GetPendingSponsorshipRequestsDTO);
if (result.isErr()) {
this.logger.error('[SponsorService] Failed to fetch pending sponsorship requests.', result.error);
return { entityType: params.entityType as any, entityId: params.entityId, requests: [], totalCount: 0 };
return { entityType: params.entityType, entityId: params.entityId, requests: [], totalCount: 0 };
}
return result.value as GetPendingSponsorshipRequestsOutputDTO;
}
async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise<{ requestId: string; sponsorshipId: string; status: string; acceptedAt: Date; platformFee: number; netAmount: number } | null> {
async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise<AcceptSponsorshipRequestResultDTO | null> {
this.logger.debug('[SponsorService] Accepting sponsorship request.', { requestId, respondedBy });
const result = await this.acceptSponsorshipRequestUseCase.execute({ requestId, respondedBy });
@@ -127,7 +134,7 @@ export class SponsorService {
return result.value;
}
async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise<{ requestId: string; status: string; rejectedAt: Date } | null> {
async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise<RejectSponsorshipRequestResultDTO | null> {
this.logger.debug('[SponsorService] Rejecting sponsorship request.', { requestId, respondedBy, reason });
const result = await this.rejectSponsorshipRequestUseCase.execute({ requestId, respondedBy, reason });

View File

@@ -5,5 +5,5 @@ export class AcceptSponsorshipRequestInputDTO {
@ApiProperty()
@IsString()
@IsNotEmpty()
respondedBy: string;
respondedBy!: string;
}

View File

@@ -5,12 +5,12 @@ export class CreateSponsorInputDTO {
@ApiProperty()
@IsString()
@IsNotEmpty()
name: string;
name!: string;
@ApiProperty()
@IsEmail()
@IsNotEmpty()
contactEmail: string;
contactEmail!: string;
@ApiProperty({ required: false })
@IsOptional()

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CreateSponsorPresenter } from './CreateSponsorPresenter';
describe('CreateSponsorPresenter', () => {
let presenter: CreateSponsorPresenter;
beforeEach(() => {
presenter = new CreateSponsorPresenter();
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor' };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor', contactEmail: 'test@example.com' };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
describe('getViewModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor' };
presenter.present(mockResult);
expect(presenter.getViewModel()).toEqual(mockResult);
});
});
describe('viewModel', () => {
it('should throw error when not presented', () => {
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('should return the result when presented', () => {
const mockResult = { id: 'sponsor-1', name: 'Test Sponsor' };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetEntitySponsorshipPricingPresenter } from './GetEntitySponsorshipPricingPresenter';
describe('GetEntitySponsorshipPricingPresenter', () => {
let presenter: GetEntitySponsorshipPricingPresenter;
beforeEach(() => {
presenter = new GetEntitySponsorshipPricingPresenter();
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
describe('getViewModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
presenter.present(mockResult);
expect(presenter.getViewModel()).toEqual(mockResult);
});
});
describe('viewModel', () => {
it('should throw error when not presented', () => {
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('should return the result when presented', () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetSponsorDashboardPresenter } from './GetSponsorDashboardPresenter';
describe('GetSponsorDashboardPresenter', () => {
let presenter: GetSponsorDashboardPresenter;
beforeEach(() => {
presenter = new GetSponsorDashboardPresenter();
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult = { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult = { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
describe('getViewModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult = { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] };
presenter.present(mockResult);
expect(presenter.getViewModel()).toEqual(mockResult);
});
});
describe('viewModel', () => {
it('should throw error when not presented', () => {
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('should return the result when presented', () => {
const mockResult = { sponsorId: 'sponsor-1', metrics: {}, sponsoredLeagues: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetSponsorSponsorshipsPresenter } from './GetSponsorSponsorshipsPresenter';
describe('GetSponsorSponsorshipsPresenter', () => {
let presenter: GetSponsorSponsorshipsPresenter;
beforeEach(() => {
presenter = new GetSponsorSponsorshipsPresenter();
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult = { sponsorId: 'sponsor-1', sponsorName: 'Test Sponsor', sponsorships: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult = { sponsorId: 'sponsor-1', sponsorName: 'Test Sponsor', sponsorships: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
describe('getViewModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult = { sponsorId: 'sponsor-1', sponsorName: 'Test Sponsor', sponsorships: [] };
presenter.present(mockResult);
expect(presenter.getViewModel()).toEqual(mockResult);
});
});
describe('viewModel', () => {
it('should throw error when not presented', () => {
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('should return the result when presented', () => {
const mockResult = { sponsorId: 'sponsor-1', sponsorName: 'Test Sponsor', sponsorships: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetSponsorsPresenter } from './GetSponsorsPresenter';
describe('GetSponsorsPresenter', () => {
let presenter: GetSponsorsPresenter;
beforeEach(() => {
presenter = new GetSponsorsPresenter();
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult = { sponsors: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult = {
sponsors: [
{ id: 'sponsor-1', name: 'Sponsor One', contactEmail: 's1@example.com' },
{ id: 'sponsor-2', name: 'Sponsor Two', contactEmail: 's2@example.com' },
],
};
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
describe('getViewModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult = { sponsors: [] };
presenter.present(mockResult);
expect(presenter.getViewModel()).toEqual(mockResult);
});
});
describe('viewModel', () => {
it('should throw error when not presented', () => {
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('should return the result when presented', () => {
const mockResult = { sponsors: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetSponsorshipPricingPresenter } from './GetSponsorshipPricingPresenter';
describe('GetSponsorshipPricingPresenter', () => {
let presenter: GetSponsorshipPricingPresenter;
beforeEach(() => {
presenter = new GetSponsorshipPricingPresenter();
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult = { tiers: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult = { tiers: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
describe('getViewModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult = { tiers: [] };
presenter.present(mockResult);
expect(presenter.getViewModel()).toEqual(mockResult);
});
});
describe('viewModel', () => {
it('should throw error when not presented', () => {
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
it('should return the result when presented', () => {
const mockResult = { tiers: [] };
presenter.present(mockResult);
expect(presenter.viewModel).toEqual(mockResult);
});
});
});

View File

@@ -0,0 +1,149 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { TeamController } from './TeamController';
import { TeamService } from './TeamService';
import type { Request } from 'express';
import { CreateTeamInputDTO, UpdateTeamInputDTO } from './dtos/CreateTeamInputDTO';
describe('TeamController', () => {
let controller: TeamController;
let service: ReturnType<typeof vi.mocked<TeamService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TeamController],
providers: [
{
provide: TeamService,
useValue: {
getAll: vi.fn(),
getDetails: vi.fn(),
getMembers: vi.fn(),
getJoinRequests: vi.fn(),
create: vi.fn(),
update: vi.fn(),
getDriverTeam: vi.fn(),
getMembership: vi.fn(),
},
},
],
}).compile();
controller = module.get<TeamController>(TeamController);
service = vi.mocked(module.get(TeamService));
});
describe('getAll', () => {
it('should return all teams', async () => {
const result = { teams: [] };
service.getAll.mockResolvedValue(result);
const response = await controller.getAll();
expect(service.getAll).toHaveBeenCalled();
expect(response).toEqual(result);
});
});
describe('getDetails', () => {
it('should return team details', async () => {
const teamId = 'team-123';
const userId = 'user-456';
const result = { id: teamId, name: 'Team' };
service.getDetails.mockResolvedValue(result);
const mockReq: Partial<Request> = { ['user']: { userId } };
const response = await controller.getDetails(teamId, mockReq as Request);
expect(service.getDetails).toHaveBeenCalledWith(teamId, userId);
expect(response).toEqual(result);
});
});
describe('getMembers', () => {
it('should return team members', async () => {
const teamId = 'team-123';
const result = { members: [] };
service.getMembers.mockResolvedValue(result);
const response = await controller.getMembers(teamId);
expect(service.getMembers).toHaveBeenCalledWith(teamId);
expect(response).toEqual(result);
});
});
describe('getJoinRequests', () => {
it('should return join requests', async () => {
const teamId = 'team-123';
const result = { requests: [] };
service.getJoinRequests.mockResolvedValue(result);
const response = await controller.getJoinRequests(teamId);
expect(service.getJoinRequests).toHaveBeenCalledWith(teamId);
expect(response).toEqual(result);
});
});
describe('create', () => {
it('should create team', async () => {
const input: CreateTeamInputDTO = { name: 'New Team' };
const userId = 'user-123';
const result = { teamId: 'team-456' };
service.create.mockResolvedValue(result);
const mockReq: Partial<Request> = { ['user']: { userId } };
const response = await controller.create(input, mockReq as Request);
expect(service.create).toHaveBeenCalledWith(input, userId);
expect(response).toEqual(result);
});
});
describe('update', () => {
it('should update team', async () => {
const teamId = 'team-123';
const input: UpdateTeamInputDTO = { name: 'Updated Team' };
const userId = 'user-456';
const result = { success: true };
service.update.mockResolvedValue(result);
const mockReq: Partial<Request> = { ['user']: { userId } };
const response = await controller.update(teamId, input, mockReq as Request);
expect(service.update).toHaveBeenCalledWith(teamId, input, userId);
expect(response).toEqual(result);
});
});
describe('getDriverTeam', () => {
it('should return driver team', async () => {
const driverId = 'driver-123';
const result = { teamId: 'team-456' };
service.getDriverTeam.mockResolvedValue(result);
const response = await controller.getDriverTeam(driverId);
expect(service.getDriverTeam).toHaveBeenCalledWith(driverId);
expect(response).toEqual(result);
});
});
describe('getMembership', () => {
it('should return team membership', async () => {
const teamId = 'team-123';
const driverId = 'driver-456';
const result = { role: 'member' };
service.getMembership.mockResolvedValue(result);
const response = await controller.getMembership(teamId, driverId);
expect(service.getMembership).toHaveBeenCalledWith(teamId, driverId);
expect(response).toEqual(result);
});
});
});

View File

@@ -0,0 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TeamModule } from './TeamModule';
import { TeamController } from './TeamController';
import { TeamService } from './TeamService';
describe('TeamModule', () => {
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [TeamModule],
}).compile();
});
it('should compile the module', () => {
expect(module).toBeDefined();
});
it('should provide TeamController', () => {
const controller = module.get<TeamController>(TeamController);
expect(controller).toBeDefined();
expect(controller).toBeInstanceOf(TeamController);
});
it('should provide TeamService', () => {
const service = module.get<TeamService>(TeamService);
expect(service).toBeDefined();
expect(service).toBeInstanceOf(TeamService);
});
});

View File

@@ -1,6 +1,110 @@
import { Provider } from '@nestjs/common';
import { TeamService } from './TeamService';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
// Import concrete in-memory implementations
import { InMemoryTeamRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamRepository';
import { InMemoryTeamMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamMembershipRepository';
import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Import use cases
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase';
import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase';
import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase';
import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase';
import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamMembershipUseCase } from '@core/racing/application/use-cases/GetTeamMembershipUseCase';
// Define injection tokens
export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';
export const TeamProviders: Provider[] = [
TeamService,
TeamService, // Provide the service itself
{
provide: TEAM_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamMembershipRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: IMAGE_SERVICE_TOKEN,
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
{
provide: GetAllTeamsUseCase,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository, logger: Logger) =>
new GetAllTeamsUseCase(teamRepo, membershipRepo, logger),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetTeamDetailsUseCase,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) =>
new GetTeamDetailsUseCase(teamRepo, membershipRepo),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: GetTeamMembersUseCase,
useFactory: (membershipRepo: ITeamMembershipRepository, driverRepo: IDriverRepository, imageService: IImageServicePort, logger: Logger) =>
new GetTeamMembersUseCase(membershipRepo, driverRepo, imageService, logger),
inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: GetTeamJoinRequestsUseCase,
useFactory: (membershipRepo: ITeamMembershipRepository, driverRepo: IDriverRepository, imageService: IImageServicePort, logger: Logger) =>
new GetTeamJoinRequestsUseCase(membershipRepo, driverRepo, imageService, logger),
inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, IMAGE_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: CreateTeamUseCase,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) =>
new CreateTeamUseCase(teamRepo, membershipRepo),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: UpdateTeamUseCase,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository) =>
new UpdateTeamUseCase(teamRepo, membershipRepo),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: GetDriverTeamUseCase,
useFactory: (teamRepo: ITeamRepository, membershipRepo: ITeamMembershipRepository, logger: Logger) =>
new GetDriverTeamUseCase(teamRepo, membershipRepo, logger),
inject: [TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetTeamMembershipUseCase,
useFactory: (membershipRepo: ITeamMembershipRepository, logger: Logger) =>
new GetTeamMembershipUseCase(membershipRepo, logger),
inject: [TEAM_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -5,8 +5,7 @@ import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriv
import type { Logger } from '@core/shared/application/Logger';
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
import { DriverTeamPresenter } from './presenters/DriverTeamPresenter';
import { AllTeamsViewModel, DriverTeamViewModel, GetDriverTeamQuery } from './dto/TeamDto';
import { TEAM_GET_ALL_USE_CASE_TOKEN, TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN, TEAM_LOGGER_TOKEN } from './TeamProviders';
import { AllTeamsViewModel, DriverTeamViewModel } from './dtos/TeamDto';
describe('TeamService', () => {
let service: TeamService;
@@ -31,138 +30,72 @@ describe('TeamService', () => {
providers: [
TeamService,
{
provide: TEAM_GET_ALL_USE_CASE_TOKEN,
provide: GetAllTeamsUseCase,
useValue: mockGetAllTeamsUseCase,
},
{
provide: TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN,
provide: GetDriverTeamUseCase,
useValue: mockGetDriverTeamUseCase,
},
{
provide: TEAM_LOGGER_TOKEN,
provide: 'Logger',
useValue: mockLogger,
},
],
}).compile();
service = module.get<TeamService>(TeamService);
getAllTeamsUseCase = module.get(TEAM_GET_ALL_USE_CASE_TOKEN);
getDriverTeamUseCase = module.get(TEAM_GET_DRIVER_TEAM_USE_CASE_TOKEN);
logger = module.get(TEAM_LOGGER_TOKEN);
getAllTeamsUseCase = module.get(GetAllTeamsUseCase);
getDriverTeamUseCase = module.get(GetDriverTeamUseCase);
logger = module.get('Logger');
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getAllTeams', () => {
it('should create presenter, call use case, and return view model', async () => {
const mockViewModel: AllTeamsViewModel = {
teams: [],
totalCount: 0,
};
describe('getAll', () => {
it('should call use case and return result', async () => {
const mockResult = { isOk: () => true, value: { teams: [], totalCount: 0 } };
getAllTeamsUseCase.execute.mockResolvedValue(mockResult as any);
const mockPresenter = {
reset: jest.fn(),
present: jest.fn(),
get viewModel(): AllTeamsViewModel {
return mockViewModel;
},
getViewModel: jest.fn().mockReturnValue({ teams: [], totalCount: 0 }),
};
// Mock the presenter constructor
const originalConstructor = AllTeamsPresenter;
(AllTeamsPresenter as any) = jest.fn().mockImplementation(() => mockPresenter);
// Mock the use case to call the presenter
getAllTeamsUseCase.execute.mockImplementation(async (input, presenter) => {
presenter.present({ teams: [] });
});
const result = await service.getAll();
const result = await service.getAllTeams();
expect(AllTeamsPresenter).toHaveBeenCalled();
expect(getAllTeamsUseCase.execute).toHaveBeenCalledWith(undefined, mockPresenter);
expect(result).toBe(mockViewModel);
// Restore
AllTeamsPresenter = originalConstructor;
expect(getAllTeamsUseCase.execute).toHaveBeenCalled();
expect(result).toEqual({ teams: [], totalCount: 0 });
});
});
describe('getDriverTeam', () => {
it('should create presenter, call use case, and return view model', async () => {
const query: GetDriverTeamQuery = { teamId: 'team1', driverId: 'driver1' };
const mockViewModel: DriverTeamViewModel = {
team: {
id: 'team1',
name: 'Team 1',
tag: 'T1',
description: 'Description',
ownerId: 'driver1',
leagues: [],
},
membership: {
role: 'owner' as any,
joinedAt: new Date(),
isActive: true,
},
isOwner: true,
canManage: true,
};
it('should call use case and return result', async () => {
const mockResult = { isOk: () => true, value: {} };
getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any);
const mockPresenter = {
reset: jest.fn(),
present: jest.fn(),
get viewModel(): DriverTeamViewModel {
return mockViewModel;
},
getViewModel: jest.fn().mockReturnValue({} as DriverTeamViewModel),
};
// Mock the presenter constructor
const originalConstructor = DriverTeamPresenter;
(DriverTeamPresenter as any) = jest.fn().mockImplementation(() => mockPresenter);
// Mock the use case to call the presenter
getDriverTeamUseCase.execute.mockImplementation(async (input, presenter) => {
presenter.present({
team: {
id: 'team1',
name: 'Team 1',
tag: 'T1',
description: 'Description',
ownerId: 'driver1',
leagues: [],
},
membership: {
role: 'owner',
status: 'active',
joinedAt: new Date(),
},
driverId: 'driver1',
});
});
const result = await service.getDriverTeam('driver1');
const result = await service.getDriverTeam(query);
expect(DriverTeamPresenter).toHaveBeenCalled();
expect(getDriverTeamUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver1' }, mockPresenter);
expect(result).toBe(mockViewModel);
// Restore
DriverTeamPresenter = originalConstructor;
expect(getDriverTeamUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver1' });
expect(result).toEqual({});
});
it('should return null on error', async () => {
const query: GetDriverTeamQuery = { teamId: 'team1', driverId: 'driver1' };
const mockResult = { isErr: () => true, error: {} };
getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any);
// Mock the use case to throw an error
getDriverTeamUseCase.execute.mockRejectedValue(new Error('Team not found'));
const result = await service.getDriverTeam(query);
const result = await service.getDriverTeam('driver1');
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { GetAllTeamsOutputDTO } from './dtos/GetAllTeamsOutputDTO';
import { GetTeamDetailsOutputDTO } from './dtos/GetTeamDetailsOutputDTO';
import { GetTeamMembersOutputDTO } from './dtos/GetTeamMembersOutputDTO';
@@ -10,63 +10,162 @@ import { UpdateTeamOutputDTO } from './dtos/UpdateTeamOutputDTO';
import { GetDriverTeamOutputDTO } from './dtos/GetDriverTeamOutputDTO';
import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO';
// Core imports
import type { Logger } from '@core/shared/application/Logger';
// Use cases
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase';
import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase';
import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase';
import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase';
import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamMembershipUseCase } from '@core/racing/application/use-cases/GetTeamMembershipUseCase';
// API Presenters
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
import { TeamDetailsPresenter } from './presenters/TeamDetailsPresenter';
import { TeamMembersPresenter } from './presenters/TeamMembersPresenter';
import { TeamJoinRequestsPresenter } from './presenters/TeamJoinRequestsPresenter';
import { DriverTeamPresenter } from './presenters/DriverTeamPresenter';
// Tokens
import { LOGGER_TOKEN } from './TeamProviders';
@Injectable()
export class TeamService {
constructor(
private readonly getAllTeamsUseCase: GetAllTeamsUseCase,
private readonly getTeamDetailsUseCase: GetTeamDetailsUseCase,
private readonly getTeamMembersUseCase: GetTeamMembersUseCase,
private readonly getTeamJoinRequestsUseCase: GetTeamJoinRequestsUseCase,
private readonly createTeamUseCase: CreateTeamUseCase,
private readonly updateTeamUseCase: UpdateTeamUseCase,
private readonly getDriverTeamUseCase: GetDriverTeamUseCase,
private readonly getTeamMembershipUseCase: GetTeamMembershipUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getAll(): Promise<GetAllTeamsOutputDTO> {
// TODO: Implement getAll teams logic
return {
teams: [],
totalCount: 0,
};
this.logger.debug('[TeamService] Fetching all teams.');
const presenter = new AllTeamsPresenter();
const result = await this.getAllTeamsUseCase.execute();
if (result.isErr()) {
this.logger.error('Error fetching all teams', result.error);
return { teams: [], totalCount: 0 };
}
await presenter.present(result.value);
return presenter.getViewModel()!;
}
async getDetails(teamId: string, userId?: string): Promise<GetTeamDetailsOutputDTO | null> {
// TODO: Implement get team details logic
return null;
this.logger.debug(`[TeamService] Fetching team details for teamId: ${teamId}, userId: ${userId}`);
const presenter = new TeamDetailsPresenter();
const result = await this.getTeamDetailsUseCase.execute({ teamId, driverId: userId || '' });
if (result.isErr()) {
this.logger.error(`Error fetching team details for teamId: ${teamId}`, result.error);
return null;
}
await presenter.present(result.value);
return presenter.getViewModel();
}
async getMembers(teamId: string): Promise<GetTeamMembersOutputDTO> {
// TODO: Implement get team members logic
return {
members: [],
totalCount: 0,
ownerCount: 0,
managerCount: 0,
memberCount: 0,
};
this.logger.debug(`[TeamService] Fetching team members for teamId: ${teamId}`);
const presenter = new TeamMembersPresenter();
const result = await this.getTeamMembersUseCase.execute({ teamId });
if (result.isErr()) {
this.logger.error(`Error fetching team members for teamId: ${teamId}`, result.error);
return {
members: [],
totalCount: 0,
ownerCount: 0,
managerCount: 0,
memberCount: 0,
};
}
await presenter.present(result.value);
return presenter.getViewModel()!;
}
async getJoinRequests(teamId: string): Promise<GetTeamJoinRequestsOutputDTO> {
// TODO: Implement get team join requests logic
return {
requests: [],
pendingCount: 0,
totalCount: 0,
};
this.logger.debug(`[TeamService] Fetching team join requests for teamId: ${teamId}`);
const presenter = new TeamJoinRequestsPresenter();
const result = await this.getTeamJoinRequestsUseCase.execute({ teamId });
if (result.isErr()) {
this.logger.error(`Error fetching team join requests for teamId: ${teamId}`, result.error);
return {
requests: [],
pendingCount: 0,
totalCount: 0,
};
}
await presenter.present(result.value);
return presenter.getViewModel()!;
}
async create(input: CreateTeamInputDTO, userId?: string): Promise<CreateTeamOutputDTO> {
// TODO: Implement create team logic
return {
id: 'placeholder-id',
success: true,
this.logger.debug('[TeamService] Creating team', { input, userId });
const command = {
name: input.name,
tag: input.tag,
description: input.description,
ownerId: userId || '',
};
const result = await this.createTeamUseCase.execute(command);
if (result.isErr()) {
this.logger.error('Error creating team', result.error);
return { id: '', success: false };
}
return { id: result.value.id, success: true };
}
async update(teamId: string, input: UpdateTeamInputDTO, userId?: string): Promise<UpdateTeamOutputDTO> {
// TODO: Implement update team logic
return {
success: true,
this.logger.debug(`[TeamService] Updating team ${teamId}`, { input, userId });
const command = {
teamId,
name: input.name,
tag: input.tag,
description: input.description,
performerId: userId || '',
};
const result = await this.updateTeamUseCase.execute(command);
if (result.isErr()) {
this.logger.error(`Error updating team ${teamId}`, result.error);
return { success: false };
}
return { success: true };
}
async getDriverTeam(driverId: string): Promise<GetDriverTeamOutputDTO | null> {
// TODO: Implement get driver team logic
return null;
this.logger.debug(`[TeamService] Fetching driver team for driverId: ${driverId}`);
const result = await this.getDriverTeamUseCase.execute({ driverId });
if (result.isErr()) {
this.logger.error(`Error fetching driver team for driverId: ${driverId}`, result.error);
return null;
}
const presenter = new DriverTeamPresenter();
await presenter.present(result.value);
return presenter.getViewModel();
}
async getMembership(teamId: string, driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
// TODO: Implement get team membership logic
return null;
this.logger.debug(`[TeamService] Fetching team membership for teamId: ${teamId}, driverId: ${driverId}`);
const result = await this.getTeamMembershipUseCase.execute({ teamId, driverId });
if (result.isErr()) {
this.logger.error(`Error fetching team membership for teamId: ${teamId}, driverId: ${driverId}`, result.error);
return null;
}
return result.value;
}
}

View File

@@ -1,4 +1,4 @@
import { ITeamsLeaderboardPresenter, TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel } from '@core/racing/application/presenters/ITeamsLeaderboardPresenter';
import { ITeamsLeaderboardPresenter, TeamsLeaderboardResultDTO, TeamsLeaderboardViewModel, TeamLeaderboardItemViewModel } from '@core/racing/application/presenters/ITeamsLeaderboardPresenter';
export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
private result: TeamsLeaderboardViewModel | null = null;
@@ -9,7 +9,7 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
present(dto: TeamsLeaderboardResultDTO) {
this.result = {
teams: dto.teams as any, // Cast to match the view model
teams: dto.teams as TeamLeaderboardItemViewModel[],
recruitingCount: dto.recruitingCount,
groupsBySkillLevel: {
beginner: [],
@@ -17,7 +17,7 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
advanced: [],
pro: [],
},
topTeams: dto.teams.slice(0, 10) as any,
topTeams: (dto.teams as TeamLeaderboardItemViewModel[]).slice(0, 10),
};
}

View File

@@ -0,0 +1,53 @@
import type { Logger } from '@core/shared/application';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
export interface GetAnalyticsMetricsInput {
startDate?: Date;
endDate?: Date;
}
export interface GetAnalyticsMetricsOutput {
pageViews: number;
uniqueVisitors: number;
averageSessionDuration: number;
bounceRate: number;
}
export class GetAnalyticsMetricsUseCase {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly logger: Logger,
) {}
async execute(input: GetAnalyticsMetricsInput = {}): Promise<GetAnalyticsMetricsOutput> {
try {
const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
const endDate = input.endDate ?? new Date();
// For now, return placeholder values as actual implementation would require
// aggregating data across all entities or specifying which entity
// This is a simplified version
const pageViews = 0;
const uniqueVisitors = 0;
const averageSessionDuration = 0;
const bounceRate = 0;
this.logger.info('Analytics metrics retrieved', {
startDate,
endDate,
pageViews,
uniqueVisitors,
});
return {
pageViews,
uniqueVisitors,
averageSessionDuration,
bounceRate,
};
} catch (error) {
this.logger.error('Failed to get analytics metrics', { error, input });
throw error;
}
}
}

View File

@@ -0,0 +1,43 @@
import type { Logger } from '@core/shared/application';
export interface GetDashboardDataInput {}
export interface GetDashboardDataOutput {
totalUsers: number;
activeUsers: number;
totalRaces: number;
totalLeagues: number;
}
export class GetDashboardDataUseCase {
constructor(
private readonly logger: Logger,
) {}
async execute(_input: GetDashboardDataInput = {}): Promise<GetDashboardDataOutput> {
try {
// Placeholder implementation - would need repositories from identity and racing domains
const totalUsers = 0;
const activeUsers = 0;
const totalRaces = 0;
const totalLeagues = 0;
this.logger.info('Dashboard data retrieved', {
totalUsers,
activeUsers,
totalRaces,
totalLeagues,
});
return {
totalUsers,
activeUsers,
totalRaces,
totalLeagues,
};
} catch (error) {
this.logger.error('Failed to get dashboard data', { error });
throw error;
}
}
}

View File

@@ -1,13 +1,7 @@
/**
* Use Case: RecordEngagementUseCase
*
* Records an engagement event when a visitor interacts with an entity.
*/
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent';
export interface RecordEngagementInput {
action: EngagementAction;
@@ -24,43 +18,41 @@ export interface RecordEngagementOutput {
engagementWeight: number;
}
export class RecordEngagementUseCase
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
export class RecordEngagementUseCase {
constructor(
private readonly engagementRepository: IEngagementRepository,
private readonly logger: Logger,
) {}
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,
const engagementEvent = EngagementEvent.create({
id: crypto.randomUUID(),
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
actorId: input.actorId,
actorType: input.actorType,
sessionId: input.sessionId,
};
const event = EngagementEvent.create({
...baseProps,
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
metadata: input.metadata,
});
await this.engagementRepository.save(event);
this.logger.info('Engagement recorded successfully', { eventId, input });
await this.engagementRepository.save(engagementEvent);
this.logger.info('Engagement event recorded', {
engagementId: engagementEvent.id,
action: input.action,
entityId: input.entityId,
entityType: input.entityType,
});
return {
eventId,
engagementWeight: event.getEngagementWeight(),
eventId: engagementEvent.id,
engagementWeight: engagementEvent.getEngagementWeight(),
};
} catch (error) {
this.logger.error('Error recording engagement', error instanceof Error ? error : new Error(String(error)), { input });
this.logger.error('Failed to record engagement event', { error: error as Error, input });
throw error;
}
}
}
}

View File

@@ -1,14 +1,7 @@
/**
* Use Case: RecordPageViewUseCase
*
* Records a page view event when a visitor accesses an entity page.
*/
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import { PageView } from '../../domain/entities/PageView';
import type { EntityType, VisitorType } from '../../domain/types/PageView';
import type { IPageViewRepository } from '../repositories/IPageViewRepository';
export interface RecordPageViewInput {
entityType: EntityType;
@@ -25,41 +18,40 @@ export interface RecordPageViewOutput {
pageViewId: string;
}
export class RecordPageViewUseCase
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
export class RecordPageViewUseCase {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly logger: Logger,
) {}
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,
const pageView = PageView.create({
id: crypto.randomUUID(),
entityType: input.entityType,
entityId: input.entityId,
visitorId: input.visitorId,
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 } : {}),
referrer: input.referrer,
userAgent: input.userAgent,
country: input.country,
});
await this.pageViewRepository.save(pageView);
this.logger.info('Page view recorded successfully', { pageViewId, input });
return { pageViewId };
this.logger.info('Page view recorded', {
pageViewId: pageView.id,
entityId: input.entityId,
entityType: input.entityType,
});
return {
pageViewId: pageView.id,
};
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Error recording page view', err, { input });
this.logger.error('Failed to record page view', { error, input });
throw error;
}
}
}
}

View File

@@ -0,0 +1,12 @@
import type { PageView } from '../entities/PageView';
export interface IPageViewRepository {
save(pageView: PageView): Promise<void>;
findById(id: string): Promise<PageView | null>;
findByEntityId(entityId: string): Promise<PageView[]>;
findBySessionId(sessionId: string): Promise<PageView[]>;
countByEntityId(entityId: string): Promise<number>;
getUniqueVisitorsCount(entityId: string, startDate: Date, endDate: Date): Promise<number>;
getAverageSessionDuration(entityId: string, startDate: Date, endDate: Date): Promise<number>;
getBounceRate(entityId: string, startDate: Date, endDate: Date): Promise<number>;
}

Some files were not shown because too many files have changed in this diff Show More