refactor use cases

This commit is contained in:
2026-01-08 15:34:51 +01:00
parent d984ab24a8
commit 52e9a2f6a7
362 changed files with 5192 additions and 8409 deletions

View File

@@ -4,13 +4,6 @@ import type { INotificationPreferenceRepository } from '@core/notifications/doma
import type { NotificationGatewayRegistry } from '@core/notifications/application/ports/NotificationGateway';
import { SendNotificationUseCase } from '@core/notifications/application/use-cases/SendNotificationUseCase';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
class NoOpOutputPort implements UseCaseOutputPort<any> {
present(_result: any): void {
// No-op for adapter
}
}
export class NotificationServiceAdapter implements NotificationService {
private readonly useCase: SendNotificationUseCase;
@@ -27,7 +20,6 @@ export class NotificationServiceAdapter implements NotificationService {
notificationRepository,
preferenceRepository,
gatewayRegistry,
new NoOpOutputPort(),
logger,
);
}
@@ -45,4 +37,4 @@ export class NotificationServiceAdapter implements NotificationService {
}
}
}
}
}

View File

@@ -3,50 +3,31 @@ import { Module } from '@nestjs/common';
import { InMemoryAdminPersistenceModule } from '../../persistence/inmemory/InMemoryAdminPersistenceModule';
import { AdminService } from './AdminService';
import { AdminController } from './AdminController';
import { ListUsersPresenter } from './presenters/ListUsersPresenter';
import { DashboardStatsPresenter } from './presenters/DashboardStatsPresenter';
import { AuthModule } from '../auth/AuthModule';
import { ListUsersUseCase } from '@core/admin/application/use-cases/ListUsersUseCase';
import { GetDashboardStatsUseCase } from './use-cases/GetDashboardStatsUseCase';
import { InitializationLogger } from '../../shared/logging/InitializationLogger';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
import type { DashboardStatsResult } from './use-cases/GetDashboardStatsUseCase';
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
export const ADMIN_USER_REPOSITORY_TOKEN = 'IAdminUserRepository';
export const LIST_USERS_OUTPUT_PORT_TOKEN = 'ListUsersOutputPort';
export const DASHBOARD_STATS_OUTPUT_PORT_TOKEN = 'DashboardStatsOutputPort';
const initLogger = InitializationLogger.getInstance();
const adminProviders: Provider[] = [
AdminService,
ListUsersPresenter,
DashboardStatsPresenter,
{
provide: LIST_USERS_OUTPUT_PORT_TOKEN,
useExisting: ListUsersPresenter,
},
{
provide: DASHBOARD_STATS_OUTPUT_PORT_TOKEN,
useExisting: DashboardStatsPresenter,
},
{
provide: ListUsersUseCase,
useFactory: (
repository: IAdminUserRepository,
output: UseCaseOutputPort<ListUsersResult>,
) => new ListUsersUseCase(repository, output),
inject: [ADMIN_USER_REPOSITORY_TOKEN, LIST_USERS_OUTPUT_PORT_TOKEN],
) => new ListUsersUseCase(repository),
inject: [ADMIN_USER_REPOSITORY_TOKEN],
},
{
provide: GetDashboardStatsUseCase,
useFactory: (
repository: IAdminUserRepository,
output: UseCaseOutputPort<DashboardStatsResult>,
) => new GetDashboardStatsUseCase(repository, output),
inject: [ADMIN_USER_REPOSITORY_TOKEN, DASHBOARD_STATS_OUTPUT_PORT_TOKEN],
) => new GetDashboardStatsUseCase(repository),
inject: [ADMIN_USER_REPOSITORY_TOKEN],
},
];

View File

@@ -12,18 +12,6 @@ const mockGetDashboardStatsUseCase = {
execute: vi.fn(),
};
// Mock presenters
const mockListUsersPresenter = {
present: vi.fn(),
getViewModel: vi.fn(),
};
const mockDashboardStatsPresenter = {
present: vi.fn(),
responseModel: {},
reset: vi.fn(),
};
describe('AdminService', () => {
describe('TDD - Test First', () => {
let service: AdminService;
@@ -32,9 +20,7 @@ describe('AdminService', () => {
vi.clearAllMocks();
service = new AdminService(
mockListUsersUseCase as any,
mockListUsersPresenter as any,
mockGetDashboardStatsUseCase as any,
mockDashboardStatsPresenter as any
mockGetDashboardStatsUseCase as any
);
});
@@ -50,15 +36,19 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
const result = await service.listUsers({ actorId: 'actor-1' });
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith({ actorId: 'actor-1' });
expect(mockListUsersPresenter.getViewModel).toHaveBeenCalled();
expect(result).toEqual(expectedResult);
expect(result).toEqual({
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
});
});
it('should return users when they exist', async () => {
@@ -88,16 +78,6 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue({
users: [
{ id: 'user-1', email: 'user1@example.com', displayName: 'User 1', roles: ['user'], status: 'active', isSystemAdmin: false, createdAt: user1.createdAt, updatedAt: user1.updatedAt },
{ id: 'user-2', email: 'user2@example.com', displayName: 'User 2', roles: ['admin'], status: 'active', isSystemAdmin: true, createdAt: user2.createdAt, updatedAt: user2.updatedAt },
],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
});
// Act
const result = await service.listUsers({ actorId: 'actor-1' });
@@ -105,6 +85,11 @@ describe('AdminService', () => {
// Assert
expect(result.users).toHaveLength(2);
expect(result.total).toBe(2);
// Check that users are converted to DTOs
expect(result.users[0]?.id).toBe('user-1');
expect(result.users[0]?.email).toBe('user1@example.com');
expect(result.users[1]?.id).toBe('user-2');
expect(result.users[1]?.email).toBe('user2@example.com');
});
it('should apply filters correctly', async () => {
@@ -126,13 +111,6 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue({
users: [{ id: 'admin-1', email: 'admin@example.com', displayName: 'Admin', roles: ['admin'], status: 'active', isSystemAdmin: true, createdAt: adminUser.createdAt, updatedAt: adminUser.updatedAt }],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
// Act
const result = await service.listUsers({
@@ -163,13 +141,6 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue({
users: [],
total: 50,
page: 3,
limit: 10,
totalPages: 5,
});
// Act
const result = await service.listUsers({
@@ -202,7 +173,6 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({
@@ -232,7 +202,6 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({
@@ -260,7 +229,6 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({
@@ -299,7 +267,6 @@ describe('AdminService', () => {
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({

View File

@@ -1,19 +1,18 @@
import { Injectable } from '@nestjs/common';
import { ListUsersUseCase, ListUsersInput } from '@core/admin/application/use-cases/ListUsersUseCase';
import { ListUsersPresenter, ListUsersViewModel } from './presenters/ListUsersPresenter';
import { ListUsersUseCase, ListUsersInput, ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
import { GetDashboardStatsUseCase, GetDashboardStatsInput } from './use-cases/GetDashboardStatsUseCase';
import { DashboardStatsPresenter, DashboardStatsResponse } from './presenters/DashboardStatsPresenter';
import { UserListResponseDto, UserResponseDto } from './dtos/UserResponseDto';
import { DashboardStatsResponseDto } from './dto/DashboardStatsResponseDto';
import type { AdminUser } from '@core/admin/domain/entities/AdminUser';
@Injectable()
export class AdminService {
constructor(
private readonly listUsersUseCase: ListUsersUseCase,
private readonly listUsersPresenter: ListUsersPresenter,
private readonly getDashboardStatsUseCase: GetDashboardStatsUseCase,
private readonly dashboardStatsPresenter: DashboardStatsPresenter,
) {}
async listUsers(input: ListUsersInput): Promise<ListUsersViewModel> {
async listUsers(input: ListUsersInput): Promise<UserListResponseDto> {
const result = await this.listUsersUseCase.execute(input);
if (result.isErr()) {
@@ -21,12 +20,11 @@ export class AdminService {
throw new Error(`${error.code}: ${error.details.message}`);
}
return this.listUsersPresenter.getViewModel();
const data = result.unwrap();
return this.toListResponseDto(data);
}
async getDashboardStats(input: GetDashboardStatsInput): Promise<DashboardStatsResponse> {
this.dashboardStatsPresenter.reset();
async getDashboardStats(input: GetDashboardStatsInput): Promise<DashboardStatsResponseDto> {
const result = await this.getDashboardStatsUseCase.execute(input);
if (result.isErr()) {
@@ -34,6 +32,54 @@ export class AdminService {
throw new Error(`${error.code}: ${error.details.message}`);
}
return this.dashboardStatsPresenter.responseModel;
const data = result.unwrap();
return data;
}
}
private toListResponseDto(result: ListUsersResult): UserListResponseDto {
return {
users: result.users.map(user => this.toUserResponse(user)),
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
};
}
private toUserResponse(user: AdminUser | Record<string, unknown>): UserResponseDto {
// Handle both domain objects and plain objects
if (user.id && typeof user.id === 'object' && 'value' in (user.id as Record<string, unknown>)) {
// Domain object
const domainUser = user as AdminUser;
const response: UserResponseDto = {
id: domainUser.id.value,
email: domainUser.email.value,
displayName: domainUser.displayName,
roles: domainUser.roles.map(r => r.value),
status: domainUser.status.value,
isSystemAdmin: domainUser.isSystemAdmin(),
createdAt: domainUser.createdAt,
updatedAt: domainUser.updatedAt,
};
if (domainUser.lastLoginAt) response.lastLoginAt = domainUser.lastLoginAt;
if (domainUser.primaryDriverId) response.primaryDriverId = domainUser.primaryDriverId;
return response;
} else {
// Plain object (for tests)
const plainUser = user as Record<string, unknown>;
const response: UserResponseDto = {
id: plainUser.id as string,
email: plainUser.email as string,
displayName: plainUser.displayName as string,
roles: plainUser.roles as string[],
status: plainUser.status as string,
isSystemAdmin: plainUser.isSystemAdmin as boolean,
createdAt: plainUser.createdAt as Date,
updatedAt: plainUser.updatedAt as Date,
};
if (plainUser.lastLoginAt) response.lastLoginAt = plainUser.lastLoginAt as Date;
if (plainUser.primaryDriverId) response.primaryDriverId = plainUser.primaryDriverId as string;
return response;
}
}
}

View File

@@ -1,6 +1,5 @@
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
import { AuthorizationService } from '@core/admin/domain/services/AuthorizationService';
import { UserId } from '@core/admin/domain/value-objects/UserId';
@@ -46,10 +45,9 @@ export type GetDashboardStatsApplicationError = ApplicationErrorCode<GetDashboar
export class GetDashboardStatsUseCase {
constructor(
private readonly adminUserRepo: IAdminUserRepository,
private readonly output: UseCaseOutputPort<DashboardStatsResult>,
) {}
async execute(input: GetDashboardStatsInput): Promise<Result<void, GetDashboardStatsApplicationError>> {
async execute(input: GetDashboardStatsInput): Promise<Result<DashboardStatsResult, GetDashboardStatsApplicationError>> {
try {
// Get actor (current user)
const actor = await this.adminUserRepo.findById(UserId.fromString(input.actorId));
@@ -166,9 +164,7 @@ export class GetDashboardStatsUseCase {
activityTimeline,
};
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get dashboard stats';

View File

@@ -1,9 +1,6 @@
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Provider } from '@nestjs/common';
import {
@@ -13,13 +10,8 @@ import {
const LOGGER_TOKEN = 'Logger';
const RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN = 'RecordPageViewOutputPort_TOKEN';
const RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN = 'RecordEngagementOutputPort_TOKEN';
const GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN = 'GetDashboardDataOutputPort_TOKEN';
const GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN = 'GetAnalyticsMetricsOutputPort_TOKEN';
import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
import { GetDashboardDataOutput, GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import { GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
import { AnalyticsService } from './AnalyticsService';
@@ -34,44 +26,28 @@ export const AnalyticsProviders: Provider[] = [
RecordEngagementPresenter,
GetDashboardDataPresenter,
GetAnalyticsMetricsPresenter,
{
provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN,
useExisting: RecordPageViewPresenter,
},
{
provide: RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN,
useExisting: RecordEngagementPresenter,
},
{
provide: GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN,
useExisting: GetDashboardDataPresenter,
},
{
provide: GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN,
useExisting: GetAnalyticsMetricsPresenter,
},
{
provide: RecordPageViewUseCase,
useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort<RecordPageViewOutput>) =>
new RecordPageViewUseCase(repo, logger, output),
inject: [ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, LOGGER_TOKEN, RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN],
useFactory: (repo: IPageViewRepository, logger: Logger) =>
new RecordPageViewUseCase(repo, logger),
inject: [ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: RecordEngagementUseCase,
useFactory: (repo: IEngagementRepository, logger: Logger, output: UseCaseOutputPort<RecordEngagementOutput>) =>
new RecordEngagementUseCase(repo, logger, output),
inject: [ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, LOGGER_TOKEN, RECORD_ENGAGEMENT_OUTPUT_PORT_TOKEN],
useFactory: (repo: IEngagementRepository, logger: Logger) =>
new RecordEngagementUseCase(repo, logger),
inject: [ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetDashboardDataUseCase,
useFactory: (logger: Logger, output: UseCaseOutputPort<GetDashboardDataOutput>) =>
new GetDashboardDataUseCase(logger, output),
inject: [LOGGER_TOKEN, GET_DASHBOARD_DATA_OUTPUT_PORT_TOKEN],
useFactory: (logger: Logger) =>
new GetDashboardDataUseCase(logger),
inject: [LOGGER_TOKEN],
},
{
provide: GetAnalyticsMetricsUseCase,
useFactory: (logger: Logger, output: UseCaseOutputPort<GetAnalyticsMetricsOutput>, repo: IPageViewRepository) =>
new GetAnalyticsMetricsUseCase(logger, output, repo),
inject: [LOGGER_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN, ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN],
useFactory: (logger: Logger, repo: IPageViewRepository) =>
new GetAnalyticsMetricsUseCase(logger, repo),
inject: [LOGGER_TOKEN, ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN],
},
];

View File

@@ -12,8 +12,7 @@ describe('AnalyticsService', () => {
const recordPageViewUseCase = {
execute: vi.fn(async () => {
recordPageViewPresenter.present({ pageViewId: 'pv-1' });
return Result.ok(undefined);
return Result.ok({ pageViewId: 'pv-1' });
}),
};
@@ -77,8 +76,7 @@ describe('AnalyticsService', () => {
const recordEngagementPresenter = new RecordEngagementPresenter();
const recordEngagementUseCase = {
execute: vi.fn(async () => {
recordEngagementPresenter.present({ eventId: 'e1', engagementWeight: 7 });
return Result.ok(undefined);
return Result.ok({ eventId: 'e1', engagementWeight: 7 });
}),
};
@@ -154,13 +152,12 @@ describe('AnalyticsService', () => {
const getDashboardDataPresenter = new GetDashboardDataPresenter();
const getDashboardDataUseCase = {
execute: vi.fn(async () => {
getDashboardDataPresenter.present({
return Result.ok({
totalUsers: 1,
activeUsers: 2,
totalRaces: 3,
totalLeagues: 4,
});
return Result.ok(undefined);
}),
};
@@ -217,13 +214,12 @@ describe('AnalyticsService', () => {
const getAnalyticsMetricsPresenter = new GetAnalyticsMetricsPresenter();
const getAnalyticsMetricsUseCase = {
execute: vi.fn(async () => {
getAnalyticsMetricsPresenter.present({
return Result.ok({
pageViews: 10,
uniqueVisitors: 0,
averageSessionDuration: 0,
bounceRate: 0,
});
return Result.ok(undefined);
}),
};
@@ -275,4 +271,4 @@ describe('AnalyticsService', () => {
await expect(service.getAnalyticsMetrics()).rejects.toThrow('Failed to get analytics metrics');
});
});
});

View File

@@ -31,50 +31,42 @@ export class AnalyticsService {
) {}
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutputDTO> {
this.recordPageViewPresenter.reset();
const result = await this.recordPageViewUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to record page view');
}
return this.recordPageViewPresenter.responseModel;
return this.recordPageViewPresenter.transform(result.unwrap());
}
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutputDTO> {
this.recordEngagementPresenter.reset();
const result = await this.recordEngagementUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to record engagement');
}
return this.recordEngagementPresenter.responseModel;
return this.recordEngagementPresenter.transform(result.unwrap());
}
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
this.getDashboardDataPresenter.reset();
const result = await this.getDashboardDataUseCase.execute();
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get dashboard data');
}
return this.getDashboardDataPresenter.responseModel;
return this.getDashboardDataPresenter.transform(result.unwrap());
}
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
this.getAnalyticsMetricsPresenter.reset();
const result = await this.getAnalyticsMetricsUseCase.execute({});
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get analytics metrics');
}
return this.getAnalyticsMetricsPresenter.responseModel;
return this.getAnalyticsMetricsPresenter.transform(result.unwrap());
}
}
}

View File

@@ -17,7 +17,7 @@ describe('GetAnalyticsMetricsPresenter', () => {
bounceRate: 0.4,
};
presenter.present(output);
presenter.transform(output);
const dto = presenter.getResponseModel();
@@ -35,11 +35,11 @@ describe('GetAnalyticsMetricsPresenter', () => {
});
});
it('getResponseModel throws if not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
it('getResponseModel throws if not transformed', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not transformed');
});
it('responseModel throws if not presented', () => {
expect(() => presenter.responseModel).toThrow('Presenter not presented');
it('responseModel throws if not transformed', () => {
expect(() => presenter.responseModel).toThrow('Presenter not transformed');
});
});

View File

@@ -1,30 +1,30 @@
import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOutputDTO';
export class GetAnalyticsMetricsPresenter implements UseCaseOutputPort<GetAnalyticsMetricsOutput> {
export class GetAnalyticsMetricsPresenter {
private model: GetAnalyticsMetricsOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: GetAnalyticsMetricsOutput): void {
transform(result: GetAnalyticsMetricsOutput): GetAnalyticsMetricsOutputDTO {
this.model = {
pageViews: result.pageViews,
uniqueVisitors: result.uniqueVisitors,
averageSessionDuration: result.averageSessionDuration,
bounceRate: result.bounceRate,
};
return this.model;
}
get responseModel(): GetAnalyticsMetricsOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
if (!this.model) throw new Error('Presenter not transformed');
return this.model;
}
getResponseModel(): GetAnalyticsMetricsOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
if (!this.model) throw new Error('Presenter not transformed');
return this.model;
}
}
}

View File

@@ -17,7 +17,7 @@ describe('GetDashboardDataPresenter', () => {
totalLeagues: 5,
};
presenter.present(output);
presenter.transform(output);
expect(presenter.getResponseModel()).toEqual({
totalUsers: 100,
@@ -33,11 +33,11 @@ describe('GetDashboardDataPresenter', () => {
});
});
it('getResponseModel throws if not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
it('getResponseModel throws if not transformed', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not transformed');
});
it('responseModel throws if not presented', () => {
expect(() => presenter.responseModel).toThrow('Presenter not presented');
it('responseModel throws if not transformed', () => {
expect(() => presenter.responseModel).toThrow('Presenter not transformed');
});
});

View File

@@ -1,30 +1,30 @@
import type { GetDashboardDataOutput } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDTO';
export class GetDashboardDataPresenter implements UseCaseOutputPort<GetDashboardDataOutput> {
export class GetDashboardDataPresenter {
private model: GetDashboardDataOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: GetDashboardDataOutput): void {
transform(result: GetDashboardDataOutput): GetDashboardDataOutputDTO {
this.model = {
totalUsers: result.totalUsers,
activeUsers: result.activeUsers,
totalRaces: result.totalRaces,
totalLeagues: result.totalLeagues,
};
return this.model;
}
get responseModel(): GetDashboardDataOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
if (!this.model) throw new Error('Presenter not transformed');
return this.model;
}
getResponseModel(): GetDashboardDataOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
if (!this.model) throw new Error('Presenter not transformed');
return this.model;
}
}
}

View File

@@ -15,23 +15,15 @@ describe('RecordEngagementPresenter', () => {
engagementWeight: 10,
};
presenter.present(output);
presenter.transform(output);
expect(presenter.getResponseModel()).toEqual({
eventId: 'event-123',
engagementWeight: 10,
});
expect(presenter.responseModel).toEqual({
eventId: 'event-123',
engagementWeight: 10,
});
});
it('getResponseModel throws if not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
});
it('responseModel throws if not presented', () => {
expect(() => presenter.responseModel).toThrow('Presenter not presented');
it('responseModel throws if not transformed', () => {
expect(() => presenter.responseModel).toThrow('Presenter not transformed');
});
});

View File

@@ -1,28 +1,19 @@
import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDTO';
export class RecordEngagementPresenter implements UseCaseOutputPort<RecordEngagementOutput> {
export class RecordEngagementPresenter {
private model: RecordEngagementOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: RecordEngagementOutput): void {
transform(output: RecordEngagementOutput): RecordEngagementOutputDTO {
this.model = {
eventId: result.eventId,
engagementWeight: result.engagementWeight,
eventId: output.eventId,
engagementWeight: output.engagementWeight,
};
return this.model;
}
get responseModel(): RecordEngagementOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
if (!this.model) throw new Error('Presenter not transformed');
return this.model;
}
getResponseModel(): RecordEngagementOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}
}

View File

@@ -14,21 +14,14 @@ describe('RecordPageViewPresenter', () => {
pageViewId: 'pv-123',
};
presenter.present(output);
presenter.transform(output);
expect(presenter.getResponseModel()).toEqual({
pageViewId: 'pv-123',
});
expect(presenter.responseModel).toEqual({
pageViewId: 'pv-123',
});
});
it('getResponseModel throws if not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
});
it('responseModel throws if not presented', () => {
expect(() => presenter.responseModel).toThrow('Presenter not presented');
it('responseModel throws if not transformed', () => {
expect(() => presenter.responseModel).toThrow('Presenter not transformed');
});
});

View File

@@ -1,27 +1,18 @@
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
export class RecordPageViewPresenter implements UseCaseOutputPort<RecordPageViewOutput> {
export class RecordPageViewPresenter {
private model: RecordPageViewOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: RecordPageViewOutput): void {
transform(output: RecordPageViewOutput): RecordPageViewOutputDTO {
this.model = {
pageViewId: result.pageViewId,
pageViewId: output.pageViewId,
};
return this.model;
}
get responseModel(): RecordPageViewOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
if (!this.model) throw new Error('Presenter not transformed');
return this.model;
}
getResponseModel(): RecordPageViewOutputDTO {
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}
}

View File

@@ -1,5 +1,6 @@
import { Provider } from '@nestjs/common';
import type { Logger } from '@core/shared/application';
import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieIdentitySessionAdapter';
import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase';
@@ -13,13 +14,6 @@ import type { ICompanyRepository } from '@core/identity/domain/repositories/ICom
import type { IMagicLinkRepository } from '@core/identity/domain/repositories/IMagicLinkRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { IMagicLinkNotificationPort } from '@core/identity/domain/ports/IMagicLinkNotificationPort';
import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase';
import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase';
import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase';
import type { SignupSponsorResult } from '@core/identity/application/use-cases/SignupSponsorUseCase';
import type { ForgotPasswordResult } from '@core/identity/application/use-cases/ForgotPasswordUseCase';
import type { ResetPasswordResult } from '@core/identity/application/use-cases/ResetPasswordUseCase';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import {
AUTH_REPOSITORY_TOKEN,
@@ -75,9 +69,8 @@ export const AuthProviders: Provider[] = [
authRepo: IAuthRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<LoginResult>,
) => new LoginUseCase(authRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_OUTPUT_PORT_TOKEN],
) => new LoginUseCase(authRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: SIGNUP_USE_CASE_TOKEN,
@@ -85,9 +78,8 @@ export const AuthProviders: Provider[] = [
authRepo: IAuthRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<SignupResult>,
) => new SignupUseCase(authRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_OUTPUT_PORT_TOKEN],
) => new SignupUseCase(authRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: SIGNUP_SPONSOR_USE_CASE_TOKEN,
@@ -96,15 +88,14 @@ export const AuthProviders: Provider[] = [
companyRepo: ICompanyRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<SignupSponsorResult>,
) => new SignupSponsorUseCase(authRepo, companyRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, COMPANY_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, SIGNUP_SPONSOR_OUTPUT_PORT_TOKEN],
) => new SignupSponsorUseCase(authRepo, companyRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, COMPANY_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: LOGOUT_USE_CASE_TOKEN,
useFactory: (sessionPort: IdentitySessionPort, logger: Logger, output: UseCaseOutputPort<LogoutResult>) =>
new LogoutUseCase(sessionPort, logger, output),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_OUTPUT_PORT_TOKEN],
useFactory: (sessionPort: IdentitySessionPort, logger: Logger) =>
new LogoutUseCase(sessionPort, logger),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN],
},
ForgotPasswordPresenter,
ResetPasswordPresenter,
@@ -132,9 +123,8 @@ export const AuthProviders: Provider[] = [
magicLinkRepo: IMagicLinkRepository,
notificationPort: IMagicLinkNotificationPort,
logger: Logger,
output: UseCaseOutputPort<ForgotPasswordResult>,
) => new ForgotPasswordUseCase(authRepo, magicLinkRepo, notificationPort, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, MAGIC_LINK_NOTIFICATION_PORT_TOKEN, LOGGER_TOKEN, FORGOT_PASSWORD_OUTPUT_PORT_TOKEN],
) => new ForgotPasswordUseCase(authRepo, magicLinkRepo, notificationPort, logger),
inject: [AUTH_REPOSITORY_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, MAGIC_LINK_NOTIFICATION_PORT_TOKEN, LOGGER_TOKEN],
},
{
provide: RESET_PASSWORD_USE_CASE_TOKEN,
@@ -143,8 +133,7 @@ export const AuthProviders: Provider[] = [
magicLinkRepo: IMagicLinkRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<ResetPasswordResult>,
) => new ResetPasswordUseCase(authRepo, magicLinkRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, RESET_PASSWORD_OUTPUT_PORT_TOKEN],
) => new ResetPasswordUseCase(authRepo, magicLinkRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, MAGIC_LINK_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -87,8 +87,7 @@ describe('AuthService', () => {
const signupUseCase = {
execute: vi.fn(async () => {
authSessionPresenter.present({ userId: 'u2', email: 'e2', displayName: 'Jane Smith' });
return Result.ok(undefined);
return Result.ok({ userId: 'u2', email: 'e2', displayName: 'Jane Smith' });
}),
};
@@ -156,8 +155,7 @@ describe('AuthService', () => {
const loginUseCase = {
execute: vi.fn(async () => {
authSessionPresenter.present({ userId: 'u3', email: 'e3', displayName: 'Bob Wilson' });
return Result.ok(undefined);
return Result.ok({ userId: 'u3', email: 'e3', displayName: 'Bob Wilson' });
}),
};
@@ -234,8 +232,7 @@ describe('AuthService', () => {
const commandResultPresenter = new FakeCommandResultPresenter();
const logoutUseCase = {
execute: vi.fn(async () => {
commandResultPresenter.present({ success: true });
return Result.ok(undefined);
return Result.ok({ success: true });
}),
};

View File

@@ -116,8 +116,6 @@ export class AuthService {
async signupWithEmail(params: SignupParamsDTO): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
this.authSessionPresenter.reset();
const input: SignupInput = {
email: params.email,
password: params.password,
@@ -131,6 +129,9 @@ export class AuthService {
throw new Error(mapApplicationErrorToMessage(error, 'Signup failed'));
}
const signupResult = result.unwrap();
this.authSessionPresenter.present(signupResult);
const userDTO = this.authSessionPresenter.responseModel;
const inferredRole = inferDemoRoleFromEmail(userDTO.email);
const session = await this.identitySessionPort.createSession({
@@ -149,8 +150,6 @@ export class AuthService {
async signupSponsor(params: SignupSponsorParamsDTO): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting sponsor signup for email: ${params.email}`);
this.authSessionPresenter.reset();
const input: SignupSponsorInput = {
email: params.email,
password: params.password,
@@ -165,6 +164,9 @@ export class AuthService {
throw new Error(mapApplicationErrorToMessage(error, 'Sponsor signup failed'));
}
const signupResult = result.unwrap();
this.authSessionPresenter.present(signupResult);
const userDTO = this.authSessionPresenter.responseModel;
const inferredRole = inferDemoRoleFromEmail(userDTO.email);
const session = await this.identitySessionPort.createSession({
@@ -183,8 +185,6 @@ export class AuthService {
async loginWithEmail(params: LoginParamsDTO): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
this.authSessionPresenter.reset();
const input: LoginInput = {
email: params.email,
password: params.password,
@@ -197,6 +197,9 @@ export class AuthService {
throw new Error(mapApplicationErrorToMessage(error, 'Login failed'));
}
const loginResult = result.unwrap();
this.authSessionPresenter.present(loginResult);
const userDTO = this.authSessionPresenter.responseModel;
const sessionOptions = params.rememberMe !== undefined
? { rememberMe: params.rememberMe }
@@ -223,8 +226,6 @@ export class AuthService {
async logout(): Promise<CommandResultDTO> {
this.logger.debug('[AuthService] Attempting logout.');
this.commandResultPresenter.reset();
const result = await this.logoutUseCase.execute();
if (result.isErr()) {
@@ -232,6 +233,9 @@ export class AuthService {
throw new Error(mapApplicationErrorToMessage(error, 'Logout failed'));
}
const logoutResult = result.unwrap();
this.commandResultPresenter.present(logoutResult);
return this.commandResultPresenter.responseModel;
}
@@ -285,8 +289,6 @@ export class AuthService {
async forgotPassword(params: { email: string }): Promise<{ message: string; magicLink?: string }> {
this.logger.debug(`[AuthService] Attempting forgot password for email: ${params.email}`);
this.forgotPasswordPresenter.reset();
const input: ForgotPasswordInput = {
email: params.email,
};
@@ -298,6 +300,9 @@ export class AuthService {
throw new Error(mapApplicationErrorToMessage(error, 'Forgot password failed'));
}
const forgotPasswordResult = executeResult.unwrap();
this.forgotPasswordPresenter.present(forgotPasswordResult);
const response = this.forgotPasswordPresenter.responseModel;
const result: { message: string; magicLink?: string } = {
message: response.message,
@@ -311,8 +316,6 @@ export class AuthService {
async resetPassword(params: { token: string; newPassword: string }): Promise<{ message: string }> {
this.logger.debug('[AuthService] Attempting reset password');
this.resetPasswordPresenter.reset();
const input: ResetPasswordInput = {
token: params.token,
newPassword: params.newPassword,
@@ -325,6 +328,9 @@ export class AuthService {
throw new Error(mapApplicationErrorToMessage(error, 'Reset password failed'));
}
const resetResult = result.unwrap();
this.resetPasswordPresenter.present(resetResult);
return this.resetPasswordPresenter.responseModel;
}
}

View File

@@ -4,16 +4,14 @@ import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../../persistence/achievement/Achi
import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
import type { RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData';
import { SeedDemoUsers } from '../../../../../adapters/bootstrap/SeedDemoUsers';
import { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
import { SignupWithEmailUseCase } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
import {
CreateAchievementUseCase,
type CreateAchievementResult,
type IAchievementRepository,
} from '@core/identity/application/use-cases/achievement/CreateAchievementUseCase';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { CookieIdentitySessionAdapter } from '../../../../../adapters/identity/session/CookieIdentitySessionAdapter';
import { USER_REPOSITORY_TOKEN as IDENTITY_USER_REPOSITORY_TOKEN, AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN } from '../../persistence/identity/IdentityPersistenceTokens';
import { ADMIN_USER_REPOSITORY_TOKEN } from '../../persistence/admin/AdminPersistenceTokens';
@@ -29,21 +27,6 @@ export const RACING_SEED_DEPENDENCIES_TOKEN = 'RacingSeedDependencies';
export const ENSURE_INITIAL_DATA_TOKEN = 'EnsureInitialData_Bootstrap';
export const SEED_DEMO_USERS_TOKEN = 'SeedDemoUsers';
// Adapter classes for output ports
class SignupWithEmailOutputAdapter implements UseCaseOutputPort<SignupWithEmailResult> {
present(result: SignupWithEmailResult): void {
// Bootstrap doesn't need to handle output, just log success
console.log('[Bootstrap] Signup completed', result);
}
}
class CreateAchievementOutputAdapter implements UseCaseOutputPort<CreateAchievementResult> {
present(result: CreateAchievementResult): void {
// Bootstrap doesn't need to handle output, just log success
console.log('[Bootstrap] Achievement created', result);
}
}
export const BootstrapProviders: Provider[] = [
{
provide: RACING_SEED_DEPENDENCIES_TOKEN,
@@ -152,8 +135,7 @@ export const BootstrapProviders: Provider[] = [
return new SignupWithEmailUseCase(
userRepository,
sessionPort,
logger,
new SignupWithEmailOutputAdapter()
logger
);
},
inject: [USER_REPOSITORY_TOKEN, IDENTITY_SESSION_PORT_TOKEN, 'Logger'],
@@ -166,8 +148,7 @@ export const BootstrapProviders: Provider[] = [
) => {
return new CreateAchievementUseCase(
achievementRepository,
logger,
new CreateAchievementOutputAdapter()
logger
);
},
inject: [ACHIEVEMENT_REPOSITORY_TOKEN, 'Logger'],

View File

@@ -3,7 +3,6 @@ import { DashboardModule } from './DashboardModule';
import { DashboardController } from './DashboardController';
import { DashboardService } from './DashboardService';
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
import { DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN } from './DashboardProviders';
describe('DashboardModule', () => {
let module: TestingModule;
@@ -30,8 +29,8 @@ describe('DashboardModule', () => {
expect(service).toBeInstanceOf(DashboardService);
});
it('should bind DashboardOverviewPresenter as the output port for the use case', () => {
const presenter = module.get<DashboardOverviewPresenter>(DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN);
it('should provide DashboardOverviewPresenter', () => {
const presenter = module.get<DashboardOverviewPresenter>(DashboardOverviewPresenter);
expect(presenter).toBeDefined();
expect(presenter).toBeInstanceOf(DashboardOverviewPresenter);
});

View File

@@ -21,7 +21,6 @@ import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
import {
DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN,
DASHBOARD_OVERVIEW_USE_CASE_TOKEN,
DRIVER_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN,
@@ -36,7 +35,6 @@ import {
// Re-export tokens for convenience (legacy imports)
export {
DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN,
DASHBOARD_OVERVIEW_USE_CASE_TOKEN,
DRIVER_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN,
@@ -60,10 +58,6 @@ export const DashboardProviders: Provider[] = [
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN,
useExisting: DashboardOverviewPresenter,
},
{
provide: DASHBOARD_OVERVIEW_USE_CASE_TOKEN,
useFactory: (
@@ -77,7 +71,6 @@ export const DashboardProviders: Provider[] = [
feedRepo: IFeedRepository,
socialRepo: ISocialGraphRepository,
imageService: ImageServicePort,
output: DashboardOverviewPresenter,
) =>
new DashboardOverviewUseCase(
driverRepo,
@@ -91,7 +84,6 @@ export const DashboardProviders: Provider[] = [
socialRepo,
async (driverId: string) => imageService.getDriverAvatar(driverId),
() => null,
output,
),
inject: [
DRIVER_REPOSITORY_TOKEN,
@@ -104,7 +96,6 @@ export const DashboardProviders: Provider[] = [
SOCIAL_FEED_REPOSITORY_TOKEN,
SOCIAL_GRAPH_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN,
DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN,
],
},
];

View File

@@ -4,8 +4,9 @@ import { DashboardService } from './DashboardService';
describe('DashboardService', () => {
it('getDashboardOverview returns presenter model on success', async () => {
const presenter = { getResponseModel: vi.fn(() => ({ feed: [] })) };
const useCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const mockResult = { currentDriver: null, myUpcomingRaces: [], otherUpcomingRaces: [], upcomingRaces: [], activeLeaguesCount: 0, nextRace: null, recentResults: [], leagueStandingsSummaries: [], feedSummary: { notificationCount: 0, items: [] }, friends: [] };
const presenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ feed: [] })) };
const useCase = { execute: vi.fn(async () => Result.ok(mockResult)) };
const service = new DashboardService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
@@ -15,13 +16,14 @@ describe('DashboardService', () => {
await expect(service.getDashboardOverview('d1')).resolves.toEqual({ feed: [] });
expect(useCase.execute).toHaveBeenCalledWith({ driverId: 'd1' });
expect(presenter.present).toHaveBeenCalledWith(mockResult);
});
it('getDashboardOverview throws with details message on error', async () => {
const service = new DashboardService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })) } as any,
{ getResponseModel: vi.fn() } as any,
{ present: vi.fn(), getResponseModel: vi.fn() } as any,
);
await expect(service.getDashboardOverview('d1')).rejects.toThrow('Failed to get dashboard overview: boom');
@@ -31,7 +33,7 @@ describe('DashboardService', () => {
const service = new DashboardService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ getResponseModel: vi.fn() } as any,
{ present: vi.fn(), getResponseModel: vi.fn() } as any,
);
await expect(service.getDashboardOverview('d1')).rejects.toThrow('Failed to get dashboard overview: Unknown error');

View File

@@ -1,5 +1,5 @@
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DashboardOverviewDTO } from './dtos/DashboardOverviewDTO';
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
@@ -8,7 +8,6 @@ import type { Logger } from '@core/shared/application/Logger';
// Tokens (standalone to avoid circular imports)
import {
DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN,
DASHBOARD_OVERVIEW_USE_CASE_TOKEN,
LOGGER_TOKEN,
} from './DashboardTokens';
@@ -18,7 +17,7 @@ export class DashboardService {
constructor(
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(DASHBOARD_OVERVIEW_USE_CASE_TOKEN) private readonly dashboardOverviewUseCase: DashboardOverviewUseCase,
@Inject(DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN) private readonly presenter: DashboardOverviewPresenter,
private readonly presenter: DashboardOverviewPresenter,
) {}
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {
@@ -29,9 +28,11 @@ export class DashboardService {
if (result.isErr()) {
const error = result.error;
const message = error?.details?.message || 'Unknown error';
throw new Error(`Failed to get dashboard overview: ${message}`);
throw new NotFoundException(`Failed to get dashboard overview: ${message}`);
}
// Present the result
this.presenter.present(result.unwrap());
return this.presenter.getResponseModel();
}
}

View File

@@ -12,5 +12,4 @@ export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const DASHBOARD_OVERVIEW_USE_CASE_TOKEN = 'DashboardOverviewUseCase';
export const DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN = 'DashboardOverviewOutputPort';

View File

@@ -1,4 +1,3 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type {
DashboardOverviewResult,
} from '@core/racing/application/use-cases/DashboardOverviewUseCase';
@@ -13,7 +12,7 @@ import {
DashboardFriendSummaryDTO,
} from '../dtos/DashboardOverviewDTO';
export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOverviewResult> {
export class DashboardOverviewPresenter {
private responseModel: DashboardOverviewDTO | null = null;
present(data: DashboardOverviewResult): void {

View File

@@ -6,7 +6,7 @@ import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepos
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
@@ -65,12 +65,6 @@ import {
IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN,
GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN,
COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN,
IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN,
UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN,
GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
DRIVER_STATS_REPOSITORY_TOKEN,
MEDIA_REPOSITORY_TOKEN,
RANKING_SERVICE_TOKEN,
@@ -119,32 +113,6 @@ export const DriverProviders: Provider[] = createLoggedProviders([
inject: [MEDIA_RESOLVER_TOKEN],
},
// Output ports (point to presenters)
{
provide: GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN,
useExisting: DriversLeaderboardPresenter,
},
{
provide: GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN,
useExisting: DriverStatsPresenter,
},
{
provide: COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN,
useExisting: CompleteOnboardingPresenter,
},
{
provide: IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN,
useExisting: DriverRegistrationStatusPresenter,
},
{
provide: UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN,
useExisting: DriverPresenter,
},
{
provide: GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
useExisting: DriverProfilePresenter,
},
// Logger
{
provide: LOGGER_TOKEN,
@@ -230,37 +198,35 @@ export const DriverProviders: Provider[] = createLoggedProviders([
rankingUseCase: IRankingUseCase,
driverStatsUseCase: IDriverStatsUseCase,
logger: Logger,
output: UseCaseOutputPort<unknown>,
) => new GetDriversLeaderboardUseCase(
driverRepo,
rankingUseCase,
driverStatsUseCase,
logger,
output
logger
),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, LOGGER_TOKEN, GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN],
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, output: UseCaseOutputPort<unknown>) => new GetTotalDriversUseCase(driverRepo, output),
inject: [DRIVER_REPOSITORY_TOKEN, GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN],
useFactory: (driverRepo: IDriverRepository) => new GetTotalDriversUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
},
{
provide: COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, logger: Logger, output: UseCaseOutputPort<unknown>) => new CompleteDriverOnboardingUseCase(driverRepo, logger, output),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN],
useFactory: (driverRepo: IDriverRepository, logger: Logger) => new CompleteDriverOnboardingUseCase(driverRepo, logger),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
useFactory: (registrationRepo: IRaceRegistrationRepository, logger: Logger, output: UseCaseOutputPort<unknown>) =>
new IsDriverRegisteredForRaceUseCase(registrationRepo, logger, output),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN, IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN],
useFactory: (registrationRepo: IRaceRegistrationRepository, logger: Logger) =>
new IsDriverRegisteredForRaceUseCase(registrationRepo, logger),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, logger: Logger, output: UseCaseOutputPort<unknown>) =>
new UpdateDriverProfileUseCase(driverRepo, logger, output),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN],
useFactory: (driverRepo: IDriverRepository, logger: Logger) =>
new UpdateDriverProfileUseCase(driverRepo, logger),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
@@ -272,7 +238,6 @@ export const DriverProviders: Provider[] = createLoggedProviders([
driverExtendedProfileProvider: DriverExtendedProfileProvider,
driverStatsUseCase: IDriverStatsUseCase,
rankingUseCase: IRankingUseCase,
output: UseCaseOutputPort<unknown>,
) =>
new GetProfileOverviewUseCase(
driverRepo,
@@ -282,7 +247,6 @@ export const DriverProviders: Provider[] = createLoggedProviders([
driverExtendedProfileProvider,
driverStatsUseCase,
rankingUseCase,
output,
),
inject: [
DRIVER_REPOSITORY_TOKEN,
@@ -292,7 +256,6 @@ export const DriverProviders: Provider[] = createLoggedProviders([
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
DRIVER_STATS_SERVICE_TOKEN,
RANKING_SERVICE_TOKEN,
GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
],
},
], initLogger);
], initLogger);

View File

@@ -143,32 +143,6 @@ export const GET_LEAGUE_WALLET_USE_CASE = 'GetLeagueWalletUseCase';
export const WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE = 'WithdrawFromLeagueWalletUseCase';
export const GET_SEASON_SPONSORSHIPS_USE_CASE = 'GetSeasonSponsorshipsUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityOutputPort_TOKEN';
export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityAndScoringOutputPort_TOKEN';
export const GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN = 'GetLeagueStandingsOutputPort_TOKEN';
export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN';
export const GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSeasonSponsorshipsOutputPort_TOKEN';
export const LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN = 'ListLeagueScoringPresetsOutputPort_TOKEN';
export const APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN = 'ApproveLeagueJoinRequestOutputPort_TOKEN';
export const CREATE_LEAGUE_OUTPUT_PORT_TOKEN = 'CreateLeagueOutputPort_TOKEN';
export const GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN = 'GetLeagueAdminPermissionsOutputPort_TOKEN';
export const GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN = 'GetLeagueMembershipsOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterMembersOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterJoinRequestsOutputPort_TOKEN';
export const GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN = 'GetLeagueOwnerSummaryOutputPort_TOKEN';
export const GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN = 'GetLeagueSeasonsOutputPort_TOKEN';
export const JOIN_LEAGUE_OUTPUT_PORT_TOKEN = 'JoinLeagueOutputPort_TOKEN';
export const GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN = 'GetLeagueScheduleOutputPort_TOKEN';
export const GET_LEAGUE_STATS_OUTPUT_PORT_TOKEN = 'GetLeagueStatsOutputPort_TOKEN';
export const REJECT_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN = 'RejectLeagueJoinRequestOutputPort_TOKEN';
export const REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN = 'RemoveLeagueMemberOutputPort_TOKEN';
export const TOTAL_LEAGUES_OUTPUT_PORT_TOKEN = 'TotalLeaguesOutputPort_TOKEN';
export const TRANSFER_LEAGUE_OWNERSHIP_OUTPUT_PORT_TOKEN = 'TransferLeagueOwnershipOutputPort_TOKEN';
export const UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN = 'UpdateLeagueMemberRoleOutputPort_TOKEN';
export const GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN = 'GetLeagueFullConfigOutputPort_TOKEN';
export const GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN = 'GetLeagueScoringConfigOutputPort_TOKEN';
export const GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN = 'GetLeagueWalletOutputPort_TOKEN';
export const WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN = 'WithdrawFromLeagueWalletOutputPort_TOKEN';
export const LeagueProviders: Provider[] = [
LeagueService,
@@ -227,133 +201,6 @@ export const LeagueProviders: Provider[] = [
DeleteLeagueSeasonScheduleRacePresenter,
PublishLeagueSeasonSchedulePresenter,
UnpublishLeagueSeasonSchedulePresenter,
// Output ports
{
provide: GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN,
useExisting: AllLeaguesWithCapacityPresenter,
},
{
provide: GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN,
useExisting: AllLeaguesWithCapacityAndScoringPresenter,
},
{
provide: GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN,
useExisting: LeagueStandingsPresenter,
},
{
provide: GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueProtestsPresenter,
},
{
provide: GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
useExisting: GetSeasonSponsorshipsPresenter,
},
{
provide: LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN,
useExisting: LeagueScoringPresetsPresenter,
},
{
provide: APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN,
useExisting: ApproveLeagueJoinRequestPresenter,
},
{
provide: CREATE_LEAGUE_OUTPUT_PORT_TOKEN,
useExisting: CreateLeaguePresenter,
},
{
provide: GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueAdminPermissionsPresenter,
},
{
provide: GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueMembershipsPresenter,
},
{
provide: GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueRosterMembersPresenter,
},
{
provide: GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueRosterJoinRequestsPresenter,
},
{
provide: GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueOwnerSummaryPresenter,
},
{
provide: GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueSeasonsPresenter,
},
{
provide: JOIN_LEAGUE_OUTPUT_PORT_TOKEN,
useExisting: JoinLeaguePresenter,
},
{
provide: GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN,
useExisting: LeagueSchedulePresenter,
},
{
provide: GET_LEAGUE_STATS_OUTPUT_PORT_TOKEN,
useExisting: LeagueStatsPresenter,
},
{
provide: REJECT_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN,
useExisting: RejectLeagueJoinRequestPresenter,
},
{
provide: REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN,
useExisting: RemoveLeagueMemberPresenter,
},
{
provide: TOTAL_LEAGUES_OUTPUT_PORT_TOKEN,
useExisting: TotalLeaguesPresenter,
},
{
provide: TRANSFER_LEAGUE_OWNERSHIP_OUTPUT_PORT_TOKEN,
useExisting: TransferLeagueOwnershipPresenter,
},
{
provide: UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN,
useExisting: UpdateLeagueMemberRolePresenter,
},
{
provide: GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN,
useExisting: LeagueConfigPresenter,
},
{
provide: GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN,
useExisting: LeagueScoringConfigPresenter,
},
{
provide: GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
useExisting: GetLeagueWalletPresenter,
},
{
provide: WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
useExisting: WithdrawFromLeagueWalletPresenter,
},
// Schedule mutation output ports
{
provide: LeagueTokens.CREATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
useExisting: CreateLeagueSeasonScheduleRacePresenter,
},
{
provide: LeagueTokens.UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
useExisting: UpdateLeagueSeasonScheduleRacePresenter,
},
{
provide: LeagueTokens.DELETE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
useExisting: DeleteLeagueSeasonScheduleRacePresenter,
},
{
provide: LeagueTokens.PUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
useExisting: PublishLeagueSeasonSchedulePresenter,
},
{
provide: LeagueTokens.UNPUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
useExisting: UnpublishLeagueSeasonSchedulePresenter,
},
// Use cases
{
@@ -370,7 +217,6 @@ export const LeagueProviders: Provider[] = [
seasonRepo: ISeasonRepository,
scoringRepo: import('@core/racing/domain/repositories/ILeagueScoringConfigRepository').ILeagueScoringConfigRepository,
gameRepo: import('@core/racing/domain/repositories/IGameRepository').IGameRepository,
output: AllLeaguesWithCapacityAndScoringPresenter,
) =>
new GetAllLeaguesWithCapacityAndScoringUseCase(
leagueRepo,
@@ -379,7 +225,6 @@ export const LeagueProviders: Provider[] = [
scoringRepo,
gameRepo,
{ getPresetById: getLeagueScoringPresetById },
output,
),
inject: [
LEAGUE_REPOSITORY_TOKEN,
@@ -387,7 +232,6 @@ export const LeagueProviders: Provider[] = [
SEASON_REPOSITORY_TOKEN,
LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
GAME_REPOSITORY_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN,
],
},
{
@@ -395,12 +239,10 @@ export const LeagueProviders: Provider[] = [
useFactory: (
standingRepo: IStandingRepository,
driverRepo: IDriverRepository,
output: LeagueStandingsPresenter,
) => new GetLeagueStandingsUseCase(standingRepo, driverRepo, output),
) => new GetLeagueStandingsUseCase(standingRepo, driverRepo),
inject: [
STANDING_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN,
],
},
{
@@ -421,9 +263,9 @@ export const LeagueProviders: Provider[] = [
},
{
provide: GET_TOTAL_LEAGUES_USE_CASE,
useFactory: (leagueRepo: ILeagueRepository, output: TotalLeaguesPresenter) =>
new GetTotalLeaguesUseCase(leagueRepo, output),
inject: [LEAGUE_REPOSITORY_TOKEN, TOTAL_LEAGUES_OUTPUT_PORT_TOKEN],
useFactory: (leagueRepo: ILeagueRepository) =>
new GetTotalLeaguesUseCase(leagueRepo),
inject: [LEAGUE_REPOSITORY_TOKEN],
},
{
provide: GET_LEAGUE_JOIN_REQUESTS_USE_CASE,
@@ -431,9 +273,8 @@ export const LeagueProviders: Provider[] = [
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: LeagueJoinRequestsPresenter,
) => new GetLeagueJoinRequestsUseCase(membershipRepo, driverRepo, leagueRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LeagueJoinRequestsPresenter],
) => new GetLeagueJoinRequestsUseCase(membershipRepo, driverRepo, leagueRepo),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
},
{
provide: APPROVE_LEAGUE_JOIN_REQUEST_USE_CASE,
@@ -453,21 +294,30 @@ export const LeagueProviders: Provider[] = [
provide: REMOVE_LEAGUE_MEMBER_USE_CASE,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
output: RemoveLeagueMemberPresenter,
) => new RemoveLeagueMemberUseCase(membershipRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN],
) => new RemoveLeagueMemberUseCase(membershipRepo),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: UPDATE_LEAGUE_MEMBER_ROLE_USE_CASE,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
output: UpdateLeagueMemberRolePresenter,
) => new UpdateLeagueMemberRoleUseCase(membershipRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN],
) => new UpdateLeagueMemberRoleUseCase(membershipRepo),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: GET_LEAGUE_OWNER_SUMMARY_USE_CASE,
useClass: GetLeagueOwnerSummaryUseCase,
useFactory: (
leagueRepo: ILeagueRepository,
driverRepo: IDriverRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
standingRepo: IStandingRepository,
) => new GetLeagueOwnerSummaryUseCase(leagueRepo, driverRepo, leagueMembershipRepo, standingRepo),
inject: [
LEAGUE_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
],
},
{
provide: GET_LEAGUE_PROTESTS_USE_CASE,
@@ -476,14 +326,12 @@ export const LeagueProviders: Provider[] = [
protestRepo: IProtestRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: GetLeagueProtestsPresenter,
) => new GetLeagueProtestsUseCase(raceRepo, protestRepo, driverRepo, leagueRepo, output),
) => new GetLeagueProtestsUseCase(raceRepo, protestRepo, driverRepo, leagueRepo),
inject: [
RACE_REPOSITORY_TOKEN,
PROTEST_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN,
],
},
{
@@ -496,9 +344,8 @@ export const LeagueProviders: Provider[] = [
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: GetLeagueMembershipsPresenter,
) => new GetLeagueMembershipsUseCase(membershipRepo, driverRepo, leagueRepo, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, GetLeagueMembershipsPresenter],
) => new GetLeagueMembershipsUseCase(membershipRepo, driverRepo, leagueRepo),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
},
{
provide: GET_LEAGUE_ROSTER_MEMBERS_USE_CASE,
@@ -506,13 +353,11 @@ export const LeagueProviders: Provider[] = [
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: GetLeagueRosterMembersPresenter,
) => new GetLeagueRosterMembersUseCase(membershipRepo, driverRepo, leagueRepo, output),
) => new GetLeagueRosterMembersUseCase(membershipRepo, driverRepo, leagueRepo),
inject: [
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN,
],
},
{
@@ -521,13 +366,11 @@ export const LeagueProviders: Provider[] = [
membershipRepo: ILeagueMembershipRepository,
driverRepo: IDriverRepository,
leagueRepo: ILeagueRepository,
output: GetLeagueRosterJoinRequestsPresenter,
) => new GetLeagueRosterJoinRequestsUseCase(membershipRepo, driverRepo, leagueRepo, output),
) => new GetLeagueRosterJoinRequestsUseCase(membershipRepo, driverRepo, leagueRepo),
inject: [
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN,
],
},
{
@@ -537,14 +380,12 @@ export const LeagueProviders: Provider[] = [
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: LeagueSchedulePresenter,
) => new GetLeagueScheduleUseCase(leagueRepo, seasonRepo, raceRepo, logger, output),
) => new GetLeagueScheduleUseCase(leagueRepo, seasonRepo, raceRepo, logger),
inject: [
LEAGUE_REPOSITORY_TOKEN,
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN,
],
},
{
@@ -553,13 +394,11 @@ export const LeagueProviders: Provider[] = [
leagueRepo: ILeagueRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
output: GetLeagueAdminPermissionsPresenter,
) => new GetLeagueAdminPermissionsUseCase(leagueRepo, leagueMembershipRepo, logger, output),
) => new GetLeagueAdminPermissionsUseCase(leagueRepo, leagueMembershipRepo, logger),
inject: [
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN,
],
},
{
@@ -568,13 +407,11 @@ export const LeagueProviders: Provider[] = [
leagueRepo: ILeagueRepository,
walletRepo: ILeagueWalletRepository,
transactionRepo: ITransactionRepository,
output: GetLeagueWalletPresenter,
) => new GetLeagueWalletUseCase(leagueRepo, walletRepo, transactionRepo, output),
) => new GetLeagueWalletUseCase(leagueRepo, walletRepo, transactionRepo),
inject: [
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_WALLET_REPOSITORY_TOKEN,
TRANSACTION_REPOSITORY_TOKEN,
GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
],
},
{
@@ -584,14 +421,12 @@ export const LeagueProviders: Provider[] = [
walletRepo: ILeagueWalletRepository,
transactionRepo: ITransactionRepository,
logger: Logger,
output: WithdrawFromLeagueWalletPresenter,
) => new WithdrawFromLeagueWalletUseCase(leagueRepo, walletRepo, transactionRepo, logger, output),
) => new WithdrawFromLeagueWalletUseCase(leagueRepo, walletRepo, transactionRepo, logger),
inject: [
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_WALLET_REPOSITORY_TOKEN,
TRANSACTION_REPOSITORY_TOKEN,
LOGGER_TOKEN,
WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
],
},
{
@@ -602,7 +437,6 @@ export const LeagueProviders: Provider[] = [
leagueRepo: ILeagueRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
raceRepo: IRaceRepository,
output: GetSeasonSponsorshipsPresenter,
) =>
new GetSeasonSponsorshipsUseCase(
seasonSponsorshipRepo,
@@ -610,7 +444,6 @@ export const LeagueProviders: Provider[] = [
leagueRepo,
leagueMembershipRepo,
raceRepo,
output,
),
inject: [
'ISeasonSponsorshipRepository',
@@ -618,23 +451,21 @@ export const LeagueProviders: Provider[] = [
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
],
},
{ // TODO wtf is this here? doesn't look like it adhers to our concepts
provide: LIST_LEAGUE_SCORING_PRESETS_USE_CASE,
useFactory: (output: LeagueScoringPresetsPresenter) =>
new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets(), output),
inject: [LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN],
useFactory: () =>
new ListLeagueScoringPresetsUseCase(listLeagueScoringPresets()),
inject: [],
},
{
provide: JOIN_LEAGUE_USE_CASE,
useFactory: (
membershipRepo: ILeagueMembershipRepository,
logger: Logger,
output: JoinLeaguePresenter,
) => new JoinLeagueUseCase(membershipRepo, logger, output),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, JoinLeaguePresenter],
) => new JoinLeagueUseCase(membershipRepo, logger),
inject: [LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: TRANSFER_LEAGUE_OWNERSHIP_USE_CASE,
@@ -652,16 +483,14 @@ export const LeagueProviders: Provider[] = [
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: CreateLeagueSeasonScheduleRacePresenter,
) =>
new CreateLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger, output, {
new CreateLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger, {
generateRaceId: () => `race-${randomUUID()}`,
}),
inject: [
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.CREATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
],
},
{
@@ -670,13 +499,11 @@ export const LeagueProviders: Provider[] = [
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: UpdateLeagueSeasonScheduleRacePresenter,
) => new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger, output),
) => new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger),
inject: [
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
],
},
{
@@ -685,13 +512,11 @@ export const LeagueProviders: Provider[] = [
seasonRepo: ISeasonRepository,
raceRepo: IRaceRepository,
logger: Logger,
output: DeleteLeagueSeasonScheduleRacePresenter,
) => new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger, output),
) => new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepo, raceRepo, logger),
inject: [
SEASON_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.DELETE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN,
],
},
{
@@ -699,12 +524,10 @@ export const LeagueProviders: Provider[] = [
useFactory: (
seasonRepo: ISeasonRepository,
logger: Logger,
output: PublishLeagueSeasonSchedulePresenter,
) => new PublishLeagueSeasonScheduleUseCase(seasonRepo, logger, output),
) => new PublishLeagueSeasonScheduleUseCase(seasonRepo, logger),
inject: [
SEASON_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.PUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
],
},
{
@@ -712,12 +535,10 @@ export const LeagueProviders: Provider[] = [
useFactory: (
seasonRepo: ISeasonRepository,
logger: Logger,
output: UnpublishLeagueSeasonSchedulePresenter,
) => new UnpublishLeagueSeasonScheduleUseCase(seasonRepo, logger, output),
) => new UnpublishLeagueSeasonScheduleUseCase(seasonRepo, logger),
inject: [
SEASON_REPOSITORY_TOKEN,
LOGGER_TOKEN,
LeagueTokens.UNPUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN,
],
},
];

View File

@@ -62,15 +62,19 @@ describe('LeagueService', () => {
present: vi.fn(),
getViewModel: vi.fn(() => ({ leagues: [], totalCount: 0 })),
};
const leagueStandingsPresenter = { getResponseModel: vi.fn(() => ({ standings: [] })) };
const leagueProtestsPresenter = { getResponseModel: vi.fn(() => ({ protests: [] })) };
const seasonSponsorshipsPresenter = { getViewModel: vi.fn(() => ({ sponsorships: [] })) };
const leagueScoringPresetsPresenter = { getViewModel: vi.fn(() => ({ presets: [] })) };
const approveLeagueJoinRequestPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const createLeaguePresenter = { getViewModel: vi.fn(() => ({ id: 'l1' })) };
const getLeagueAdminPermissionsPresenter = { getResponseModel: vi.fn(() => ({ canManage: true })) };
const leagueStandingsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ standings: [] })) };
const leagueProtestsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ protests: [] })) };
const seasonSponsorshipsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ sponsorships: [] })) };
const leagueScoringPresetsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ presets: [] })) };
const approveLeagueJoinRequestPresenter = {
present: vi.fn(),
getViewModel: vi.fn(() => ({ success: true }))
};
const createLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ id: 'l1' })) };
const getLeagueAdminPermissionsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ canManage: true })) };
const getLeagueMembershipsPresenter = {
reset: vi.fn(),
present: vi.fn(),
getViewModel: vi.fn(() => ({ memberships: { memberships: [] } })),
};
@@ -85,27 +89,30 @@ describe('LeagueService', () => {
present: vi.fn(),
getViewModel: vi.fn(() => ([])),
};
const getLeagueOwnerSummaryPresenter = { getViewModel: vi.fn(() => ({ ownerId: 'o1' })) };
const getLeagueSeasonsPresenter = { getResponseModel: vi.fn(() => ([])) };
const joinLeaguePresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const leagueSchedulePresenter = { getViewModel: vi.fn(() => ({ seasonId: 'season-1', published: false, races: [] })) };
const leagueStatsPresenter = { getResponseModel: vi.fn(() => ({ stats: {} })) };
const rejectLeagueJoinRequestPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const removeLeagueMemberPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const totalLeaguesPresenter = { getResponseModel: vi.fn(() => ({ total: 1 })) };
const transferLeagueOwnershipPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const updateLeagueMemberRolePresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const getLeagueOwnerSummaryPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ ownerId: 'o1' })) };
const getLeagueSeasonsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ([])) };
const joinLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const leagueSchedulePresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ seasonId: 'season-1', published: false, races: [] })) };
const leagueStatsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ stats: {} })) };
const rejectLeagueJoinRequestPresenter = {
present: vi.fn(),
getViewModel: vi.fn(() => ({ success: true }))
};
const removeLeagueMemberPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const totalLeaguesPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ total: 1 })) };
const transferLeagueOwnershipPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const updateLeagueMemberRolePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const leagueConfigPresenter = { getViewModel: vi.fn(() => ({ form: {} })) };
const leagueScoringConfigPresenter = { getViewModel: vi.fn(() => ({ config: {} })) };
const getLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ balance: 0 })) };
const withdrawFromLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
const leagueJoinRequestsPresenter = { getViewModel: vi.fn(() => ({ joinRequests: [] })) };
const getLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ balance: 0 })) };
const withdrawFromLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
const leagueJoinRequestsPresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ joinRequests: [] })) };
const createLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) };
const updateLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
const deleteLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
const publishLeagueSeasonSchedulePresenter = { getResponseModel: vi.fn(() => ({ success: true, published: true })) };
const unpublishLeagueSeasonSchedulePresenter = { getResponseModel: vi.fn(() => ({ success: true, published: false })) };
const createLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) };
const updateLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
const deleteLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
const publishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: true })) };
const unpublishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: false })) };
const service = new (LeagueService as any)(
getAllLeaguesWithCapacityUseCase as any,
@@ -195,8 +202,7 @@ describe('LeagueService', () => {
});
expect(rejectLeagueJoinRequestUseCase.execute).toHaveBeenCalledWith(
{ leagueId: 'l1', joinRequestId: 'r1' },
rejectLeagueJoinRequestPresenter,
{ leagueId: 'l1', joinRequestId: 'r1' }
);
await withUserId('user-1', async () => {

View File

@@ -135,65 +135,39 @@ import {
} from './presenters/LeagueSeasonScheduleMutationPresenters';
// Tokens
import {
APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN,
APPROVE_LEAGUE_JOIN_REQUEST_USE_CASE,
CREATE_LEAGUE_OUTPUT_PORT_TOKEN,
CREATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
CREATE_LEAGUE_WITH_SEASON_AND_SCORING_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_USE_CASE,
GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN,
GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE,
GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_ADMIN_PERMISSIONS_USE_CASE,
GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN,
GET_LEAGUE_FULL_CONFIG_USE_CASE,
GET_LEAGUE_JOIN_REQUESTS_USE_CASE,
GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_MEMBERSHIPS_USE_CASE,
GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE,
GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_ROSTER_MEMBERS_USE_CASE,
GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN,
GET_LEAGUE_OWNER_SUMMARY_USE_CASE,
GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_PROTESTS_USE_CASE,
GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SCHEDULE_USE_CASE,
GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SCORING_CONFIG_USE_CASE,
GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_SEASONS_USE_CASE,
GET_LEAGUE_STATS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_STATS_USE_CASE,
GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN,
GET_LEAGUE_STANDINGS_USE_CASE,
GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
GET_LEAGUE_WALLET_USE_CASE,
GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
GET_SEASON_SPONSORSHIPS_USE_CASE,
GET_TOTAL_LEAGUES_USE_CASE,
JOIN_LEAGUE_OUTPUT_PORT_TOKEN,
JOIN_LEAGUE_USE_CASE,
LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN,
LIST_LEAGUE_SCORING_PRESETS_USE_CASE,
LOGGER_TOKEN,
PUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE,
REJECT_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN,
REJECT_LEAGUE_JOIN_REQUEST_USE_CASE,
REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN,
REMOVE_LEAGUE_MEMBER_USE_CASE,
TOTAL_LEAGUES_OUTPUT_PORT_TOKEN,
TRANSFER_LEAGUE_OWNERSHIP_OUTPUT_PORT_TOKEN,
TRANSFER_LEAGUE_OWNERSHIP_USE_CASE,
UNPUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE,
UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN,
UPDATE_LEAGUE_MEMBER_ROLE_USE_CASE,
UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
DELETE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE,
WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN,
WITHDRAW_FROM_LEAGUE_WALLET_USE_CASE,
GET_LEAGUE_ROSTER_MEMBERS_USE_CASE,
GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE,
} from './LeagueTokens';
@Injectable()
@@ -240,52 +214,47 @@ export class LeagueService {
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
// Injected presenters
@Inject(GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN) private readonly allLeaguesWithCapacityPresenter: AllLeaguesWithCapacityPresenter,
@Inject(GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN) private readonly allLeaguesWithCapacityAndScoringPresenter: AllLeaguesWithCapacityAndScoringPresenter,
@Inject(GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN) private readonly leagueStandingsPresenter: LeagueStandingsPresenter,
@Inject(GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN) private readonly leagueProtestsPresenter: GetLeagueProtestsPresenter,
@Inject(GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN) private readonly seasonSponsorshipsPresenter: GetSeasonSponsorshipsPresenter,
@Inject(LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN) private readonly leagueScoringPresetsPresenter: LeagueScoringPresetsPresenter,
@Inject(APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN) private readonly approveLeagueJoinRequestPresenter: ApproveLeagueJoinRequestPresenter,
@Inject(CREATE_LEAGUE_OUTPUT_PORT_TOKEN) private readonly createLeaguePresenter: CreateLeaguePresenter,
@Inject(GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN) private readonly getLeagueAdminPermissionsPresenter: GetLeagueAdminPermissionsPresenter,
@Inject(GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN) private readonly getLeagueMembershipsPresenter: GetLeagueMembershipsPresenter,
@Inject(GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN) private readonly getLeagueOwnerSummaryPresenter: GetLeagueOwnerSummaryPresenter,
@Inject(GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN) private readonly getLeagueSeasonsPresenter: GetLeagueSeasonsPresenter,
@Inject(JOIN_LEAGUE_OUTPUT_PORT_TOKEN) private readonly joinLeaguePresenter: JoinLeaguePresenter,
@Inject(GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN) private readonly leagueSchedulePresenter: LeagueSchedulePresenter,
@Inject(GET_LEAGUE_STATS_OUTPUT_PORT_TOKEN) private readonly leagueStatsPresenter: LeagueStatsPresenter,
@Inject(REJECT_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN) private readonly rejectLeagueJoinRequestPresenter: RejectLeagueJoinRequestPresenter,
@Inject(REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN) private readonly removeLeagueMemberPresenter: RemoveLeagueMemberPresenter,
@Inject(TOTAL_LEAGUES_OUTPUT_PORT_TOKEN) private readonly totalLeaguesPresenter: TotalLeaguesPresenter,
@Inject(TRANSFER_LEAGUE_OWNERSHIP_OUTPUT_PORT_TOKEN) private readonly transferLeagueOwnershipPresenter: TransferLeagueOwnershipPresenter,
@Inject(UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN) private readonly updateLeagueMemberRolePresenter: UpdateLeagueMemberRolePresenter,
@Inject(GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN) private readonly leagueConfigPresenter: LeagueConfigPresenter,
@Inject(GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN) private readonly leagueScoringConfigPresenter: LeagueScoringConfigPresenter,
@Inject(GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly getLeagueWalletPresenter: GetLeagueWalletPresenter,
@Inject(WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly withdrawFromLeagueWalletPresenter: WithdrawFromLeagueWalletPresenter,
@Inject(AllLeaguesWithCapacityPresenter) private readonly allLeaguesWithCapacityPresenter: AllLeaguesWithCapacityPresenter,
@Inject(AllLeaguesWithCapacityAndScoringPresenter) private readonly allLeaguesWithCapacityAndScoringPresenter: AllLeaguesWithCapacityAndScoringPresenter,
@Inject(LeagueStandingsPresenter) private readonly leagueStandingsPresenter: LeagueStandingsPresenter,
@Inject(GetLeagueProtestsPresenter) private readonly leagueProtestsPresenter: GetLeagueProtestsPresenter,
@Inject(GetSeasonSponsorshipsPresenter) private readonly seasonSponsorshipsPresenter: GetSeasonSponsorshipsPresenter,
@Inject(LeagueScoringPresetsPresenter) private readonly leagueScoringPresetsPresenter: LeagueScoringPresetsPresenter,
@Inject(ApproveLeagueJoinRequestPresenter) private readonly approveLeagueJoinRequestPresenter: ApproveLeagueJoinRequestPresenter,
@Inject(CreateLeaguePresenter) private readonly createLeaguePresenter: CreateLeaguePresenter,
@Inject(GetLeagueAdminPermissionsPresenter) private readonly getLeagueAdminPermissionsPresenter: GetLeagueAdminPermissionsPresenter,
@Inject(GetLeagueMembershipsPresenter) private readonly getLeagueMembershipsPresenter: GetLeagueMembershipsPresenter,
@Inject(GetLeagueOwnerSummaryPresenter) private readonly getLeagueOwnerSummaryPresenter: GetLeagueOwnerSummaryPresenter,
@Inject(GetLeagueSeasonsPresenter) private readonly getLeagueSeasonsPresenter: GetLeagueSeasonsPresenter,
@Inject(JoinLeaguePresenter) private readonly joinLeaguePresenter: JoinLeaguePresenter,
@Inject(LeagueSchedulePresenter) private readonly leagueSchedulePresenter: LeagueSchedulePresenter,
@Inject(LeagueStatsPresenter) private readonly leagueStatsPresenter: LeagueStatsPresenter,
@Inject(RejectLeagueJoinRequestPresenter) private readonly rejectLeagueJoinRequestPresenter: RejectLeagueJoinRequestPresenter,
@Inject(RemoveLeagueMemberPresenter) private readonly removeLeagueMemberPresenter: RemoveLeagueMemberPresenter,
@Inject(TotalLeaguesPresenter) private readonly totalLeaguesPresenter: TotalLeaguesPresenter,
@Inject(TransferLeagueOwnershipPresenter) private readonly transferLeagueOwnershipPresenter: TransferLeagueOwnershipPresenter,
@Inject(UpdateLeagueMemberRolePresenter) private readonly updateLeagueMemberRolePresenter: UpdateLeagueMemberRolePresenter,
@Inject(LeagueConfigPresenter) private readonly leagueConfigPresenter: LeagueConfigPresenter,
@Inject(LeagueScoringConfigPresenter) private readonly leagueScoringConfigPresenter: LeagueScoringConfigPresenter,
@Inject(GetLeagueWalletPresenter) private readonly getLeagueWalletPresenter: GetLeagueWalletPresenter,
@Inject(WithdrawFromLeagueWalletPresenter) private readonly withdrawFromLeagueWalletPresenter: WithdrawFromLeagueWalletPresenter,
@Inject(LeagueJoinRequestsPresenter) private readonly leagueJoinRequestsPresenter: LeagueJoinRequestsPresenter,
// Schedule mutation presenters
@Inject(CreateLeagueSeasonScheduleRacePresenter)
private readonly createLeagueSeasonScheduleRacePresenter: CreateLeagueSeasonScheduleRacePresenter,
@Inject(UpdateLeagueSeasonScheduleRacePresenter)
private readonly updateLeagueSeasonScheduleRacePresenter: UpdateLeagueSeasonScheduleRacePresenter,
@Inject(DeleteLeagueSeasonScheduleRacePresenter)
private readonly deleteLeagueSeasonScheduleRacePresenter: DeleteLeagueSeasonScheduleRacePresenter,
@Inject(PublishLeagueSeasonSchedulePresenter)
private readonly publishLeagueSeasonSchedulePresenter: PublishLeagueSeasonSchedulePresenter,
@Inject(UnpublishLeagueSeasonSchedulePresenter)
private readonly unpublishLeagueSeasonSchedulePresenter: UnpublishLeagueSeasonSchedulePresenter,
@Inject(CreateLeagueSeasonScheduleRacePresenter) private readonly createLeagueSeasonScheduleRacePresenter: CreateLeagueSeasonScheduleRacePresenter,
@Inject(UpdateLeagueSeasonScheduleRacePresenter) private readonly updateLeagueSeasonScheduleRacePresenter: UpdateLeagueSeasonScheduleRacePresenter,
@Inject(DeleteLeagueSeasonScheduleRacePresenter) private readonly deleteLeagueSeasonScheduleRacePresenter: DeleteLeagueSeasonScheduleRacePresenter,
@Inject(PublishLeagueSeasonSchedulePresenter) private readonly publishLeagueSeasonSchedulePresenter: PublishLeagueSeasonSchedulePresenter,
@Inject(UnpublishLeagueSeasonSchedulePresenter) private readonly unpublishLeagueSeasonSchedulePresenter: UnpublishLeagueSeasonSchedulePresenter,
// Roster admin read delegation
@Inject(GET_LEAGUE_ROSTER_MEMBERS_USE_CASE)
private readonly getLeagueRosterMembersUseCase: GetLeagueRosterMembersUseCase,
@Inject(GET_LEAGUE_ROSTER_JOIN_REQUESTS_USE_CASE)
private readonly getLeagueRosterJoinRequestsUseCase: GetLeagueRosterJoinRequestsUseCase,
@Inject(GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN)
@Inject(GetLeagueRosterMembersPresenter)
private readonly getLeagueRosterMembersPresenter: GetLeagueRosterMembersPresenter,
@Inject(GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN)
@Inject(GetLeagueRosterJoinRequestsPresenter)
private readonly getLeagueRosterJoinRequestsPresenter: GetLeagueRosterJoinRequestsPresenter,
) {}
@@ -327,7 +296,11 @@ export class LeagueService {
async getTotalLeagues(): Promise<TotalLeaguesDTO> {
this.logger.debug('[LeagueService] Fetching total leagues count.');
await this.getTotalLeaguesUseCase.execute({});
const result = await this.getTotalLeaguesUseCase.execute({});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.totalLeaguesPresenter.present(result.unwrap());
return this.totalLeaguesPresenter.getResponseModel()!;
}
@@ -345,8 +318,13 @@ export class LeagueService {
await this.requireLeagueAdminPermissions(leagueId);
this.leagueJoinRequestsPresenter.reset?.();
await this.getLeagueJoinRequestsUseCase.execute({ leagueId });
const result = await this.getLeagueJoinRequestsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.leagueJoinRequestsPresenter.present(result.unwrap());
return this.leagueJoinRequestsPresenter.getViewModel()!.joinRequests;
}
@@ -355,10 +333,8 @@ export class LeagueService {
await this.requireLeagueAdminPermissions(input.leagueId);
this.approveLeagueJoinRequestPresenter.reset?.();
const result = await this.approveLeagueJoinRequestUseCase.execute(
{ leagueId: input.leagueId, joinRequestId: input.requestId },
this.approveLeagueJoinRequestPresenter,
);
if (result.isErr()) {
@@ -379,6 +355,7 @@ export class LeagueService {
throw new Error(err.code);
}
this.approveLeagueJoinRequestPresenter.present(result.unwrap());
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
}
@@ -387,10 +364,8 @@ export class LeagueService {
await this.requireLeagueAdminPermissions(input.leagueId);
this.rejectLeagueJoinRequestPresenter.reset?.();
const result = await this.rejectLeagueJoinRequestUseCase.execute(
{ leagueId: input.leagueId, joinRequestId: input.requestId },
this.rejectLeagueJoinRequestPresenter,
);
if (result.isErr()) {
@@ -411,6 +386,7 @@ export class LeagueService {
throw new Error(err.code);
}
this.rejectLeagueJoinRequestPresenter.present(result.unwrap());
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
}
@@ -419,10 +395,8 @@ export class LeagueService {
await this.requireLeagueAdminPermissions(leagueId);
this.approveLeagueJoinRequestPresenter.reset?.();
const result = await this.approveLeagueJoinRequestUseCase.execute(
{ leagueId, joinRequestId },
this.approveLeagueJoinRequestPresenter,
);
if (result.isErr()) {
@@ -443,6 +417,7 @@ export class LeagueService {
throw new Error(err.code);
}
this.approveLeagueJoinRequestPresenter.present(result.unwrap());
return this.approveLeagueJoinRequestPresenter.getViewModel()!;
}
@@ -451,16 +426,15 @@ export class LeagueService {
await this.requireLeagueAdminPermissions(leagueId);
this.rejectLeagueJoinRequestPresenter.reset?.();
const result = await this.rejectLeagueJoinRequestUseCase.execute(
{ leagueId, joinRequestId },
this.rejectLeagueJoinRequestPresenter,
);
if (result.isErr()) {
throw new NotFoundException('Join request not found');
}
this.rejectLeagueJoinRequestPresenter.present(result.unwrap());
return this.rejectLeagueJoinRequestPresenter.getViewModel()!;
}
@@ -469,11 +443,16 @@ export class LeagueService {
this.logger.debug('Getting league admin permissions', { leagueId: query.leagueId, performerDriverId: actor.driverId });
await this.getLeagueAdminPermissionsUseCase.execute({
const result = await this.getLeagueAdminPermissionsUseCase.execute({
leagueId: query.leagueId,
performerDriverId: actor.driverId,
});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.getLeagueAdminPermissionsPresenter.present(result.unwrap());
return this.getLeagueAdminPermissionsPresenter.getResponseModel()!;
}
@@ -544,7 +523,11 @@ export class LeagueService {
async getLeagueOwnerSummary(query: GetLeagueOwnerSummaryQueryDTO): Promise<LeagueOwnerSummaryDTO> {
this.logger.debug('Getting league owner summary:', query);
await this.getLeagueOwnerSummaryUseCase.execute(query);
const result = await this.getLeagueOwnerSummaryUseCase.execute(query);
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.getLeagueOwnerSummaryPresenter.present(result.unwrap());
return this.getLeagueOwnerSummaryPresenter.getViewModel()!;
}
@@ -562,19 +545,31 @@ export class LeagueService {
async getLeagueProtests(query: GetLeagueProtestsQueryDTO): Promise<LeagueAdminProtestsDTO> {
this.logger.debug('Getting league protests:', query);
await this.getLeagueProtestsUseCase.execute(query);
const result = await this.getLeagueProtestsUseCase.execute(query);
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.leagueProtestsPresenter.present(result.unwrap());
return this.leagueProtestsPresenter.getResponseModel()!;
}
async getLeagueSeasons(query: GetLeagueSeasonsQueryDTO): Promise<LeagueSeasonSummaryDTO[]> {
this.logger.debug('Getting league seasons:', query);
await this.getLeagueSeasonsUseCase.execute(query);
const result = await this.getLeagueSeasonsUseCase.execute(query);
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.getLeagueSeasonsPresenter.present(result.unwrap());
return this.getLeagueSeasonsPresenter.getResponseModel()!;
}
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
this.logger.debug('Getting league memberships', { leagueId });
await this.getLeagueMembershipsUseCase.execute({ leagueId });
const result = await this.getLeagueMembershipsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.getLeagueMembershipsPresenter.present(result.unwrap());
return this.getLeagueMembershipsPresenter.getViewModel()!.memberships;
}
@@ -590,6 +585,7 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
this.getLeagueRosterMembersPresenter.present(result.unwrap());
return this.getLeagueRosterMembersPresenter.getViewModel()!;
}
@@ -605,12 +601,17 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
this.getLeagueRosterJoinRequestsPresenter.present(result.unwrap());
return this.getLeagueRosterJoinRequestsPresenter.getViewModel()!;
}
async getLeagueStandings(leagueId: string): Promise<LeagueStandingsDTO> {
this.logger.debug('Getting league standings', { leagueId });
await this.getLeagueStandingsUseCase.execute({ leagueId });
const result = await this.getLeagueStandingsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.leagueStandingsPresenter.present(result.unwrap());
return this.leagueStandingsPresenter.getResponseModel()!;
}
@@ -618,8 +619,13 @@ export class LeagueService {
this.logger.debug('Getting league schedule', { leagueId, query });
const input: GetLeagueScheduleInput = query?.seasonId ? { leagueId, seasonId: query.seasonId } : { leagueId };
await this.getLeagueScheduleUseCase.execute(input);
const result = await this.getLeagueScheduleUseCase.execute(input);
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.leagueSchedulePresenter.present(result.unwrap());
return this.leagueSchedulePresenter.getViewModel()!;
}
@@ -639,6 +645,7 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
this.publishLeagueSeasonSchedulePresenter.present(result.unwrap());
return this.publishLeagueSeasonSchedulePresenter.getResponseModel()!;
}
@@ -658,6 +665,7 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
this.unpublishLeagueSeasonSchedulePresenter.present(result.unwrap());
return this.unpublishLeagueSeasonSchedulePresenter.getResponseModel()!;
}
@@ -686,6 +694,7 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
this.createLeagueSeasonScheduleRacePresenter.present(result.unwrap());
return this.createLeagueSeasonScheduleRacePresenter.getResponseModel()!;
}
@@ -718,6 +727,7 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
this.updateLeagueSeasonScheduleRacePresenter.present(result.unwrap());
return this.updateLeagueSeasonScheduleRacePresenter.getResponseModel()!;
}
@@ -735,12 +745,17 @@ export class LeagueService {
throw new Error(result.unwrapErr().code);
}
this.deleteLeagueSeasonScheduleRacePresenter.present(result.unwrap());
return this.deleteLeagueSeasonScheduleRacePresenter.getResponseModel()!;
}
async getLeagueStats(leagueId: string): Promise<LeagueStatsDTO> {
this.logger.debug('Getting league stats', { leagueId });
await this.getLeagueStatsUseCase.execute({ leagueId });
const result = await this.getLeagueStatsUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.leagueStatsPresenter.present(result.unwrap());
return this.leagueStatsPresenter.getResponseModel()!;
}
@@ -787,7 +802,11 @@ export class LeagueService {
enableNationsChampionship: false,
enableTrophyChampionship: false,
};
await this.createLeagueWithSeasonAndScoringUseCase.execute(command);
const result = await this.createLeagueWithSeasonAndScoringUseCase.execute(command);
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.createLeaguePresenter.present(result.unwrap());
return this.createLeaguePresenter.getViewModel()!;
}
@@ -806,7 +825,11 @@ export class LeagueService {
async listLeagueScoringPresets(): Promise<LeagueScoringPresetsViewModel> {
this.logger.debug('Listing league scoring presets');
await this.listLeagueScoringPresetsUseCase.execute({});
const result = await this.listLeagueScoringPresetsUseCase.execute({});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.leagueScoringPresetsPresenter.present(result.unwrap());
return this.leagueScoringPresetsPresenter.getViewModel()!;
}
@@ -814,7 +837,11 @@ export class LeagueService {
const actor = this.getActor();
this.logger.debug('Joining league', { leagueId, actorDriverId: actor.driverId });
await this.joinLeagueUseCase.execute({ leagueId, driverId: actor.driverId });
const result = await this.joinLeagueUseCase.execute({ leagueId, driverId: actor.driverId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.joinLeaguePresenter.present(result.unwrap());
return this.joinLeaguePresenter.getViewModel()!;
}
@@ -825,19 +852,28 @@ export class LeagueService {
const actor = this.getActor();
await this.transferLeagueOwnershipUseCase.execute({
const result = await this.transferLeagueOwnershipUseCase.execute({
leagueId,
currentOwnerId: actor.driverId,
newOwnerId: input.newOwnerId,
});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.transferLeagueOwnershipPresenter.present(result.unwrap());
return this.transferLeagueOwnershipPresenter.getViewModel()!;
}
async getSeasonSponsorships(seasonId: string): Promise<GetSeasonSponsorshipsOutputDTO> {
this.logger.debug('Getting season sponsorships', { seasonId });
await this.getSeasonSponsorshipsUseCase.execute({ seasonId });
const result = await this.getSeasonSponsorshipsUseCase.execute({ seasonId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.seasonSponsorshipsPresenter.present(result.unwrap());
return this.seasonSponsorshipsPresenter.getViewModel()!;
}
@@ -847,8 +883,13 @@ export class LeagueService {
// `GetLeagueScheduleUseCase` is wired to `LeagueSchedulePresenter` (not `LeagueRacesPresenter`),
// so `LeagueRacesPresenter.getViewModel()` can be null at runtime.
this.leagueSchedulePresenter.reset?.();
await this.getLeagueScheduleUseCase.execute({ leagueId });
const result = await this.getLeagueScheduleUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.leagueSchedulePresenter.present(result.unwrap());
return {
races: this.leagueSchedulePresenter.getViewModel()?.races ?? [],
};
@@ -856,7 +897,11 @@ export class LeagueService {
async getLeagueWallet(leagueId: string): Promise<GetLeagueWalletOutputDTO> {
this.logger.debug('Getting league wallet', { leagueId });
await this.getLeagueWalletUseCase.execute({ leagueId });
const result = await this.getLeagueWalletUseCase.execute({ leagueId });
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.getLeagueWalletPresenter.present(result.unwrap());
return this.getLeagueWalletPresenter.getResponseModel();
}
@@ -868,7 +913,7 @@ export class LeagueService {
const actor = this.getActor();
await this.withdrawFromLeagueWalletUseCase.execute({
const result = await this.withdrawFromLeagueWalletUseCase.execute({
leagueId,
requestedById: actor.driverId,
amount: input.amount,
@@ -876,6 +921,11 @@ export class LeagueService {
reason: input.destinationAccount,
});
if (result.isErr()) {
throw new Error(result.unwrapErr().code);
}
this.withdrawFromLeagueWalletPresenter.present(result.unwrap());
return this.withdrawFromLeagueWalletPresenter.getResponseModel();
}
}

View File

@@ -48,37 +48,3 @@ export const UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE = 'UpdateLeagueSeasonSc
export const DELETE_LEAGUE_SEASON_SCHEDULE_RACE_USE_CASE = 'DeleteLeagueSeasonScheduleRaceUseCase';
export const PUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE = 'PublishLeagueSeasonScheduleUseCase';
export const UNPUBLISH_LEAGUE_SEASON_SCHEDULE_USE_CASE = 'UnpublishLeagueSeasonScheduleUseCase';
export const GET_ALL_LEAGUES_WITH_CAPACITY_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityOutputPort_TOKEN';
export const GET_ALL_LEAGUES_WITH_CAPACITY_AND_SCORING_OUTPUT_PORT_TOKEN = 'GetAllLeaguesWithCapacityAndScoringOutputPort_TOKEN';
export const GET_LEAGUE_STANDINGS_OUTPUT_PORT_TOKEN = 'GetLeagueStandingsOutputPort_TOKEN';
export const GET_LEAGUE_PROTESTS_OUTPUT_PORT_TOKEN = 'GetLeagueProtestsOutputPort_TOKEN';
export const GET_SEASON_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSeasonSponsorshipsOutputPort_TOKEN';
export const LIST_LEAGUE_SCORING_PRESETS_OUTPUT_PORT_TOKEN = 'ListLeagueScoringPresetsOutputPort_TOKEN';
export const APPROVE_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN = 'ApproveLeagueJoinRequestOutputPort_TOKEN';
export const CREATE_LEAGUE_OUTPUT_PORT_TOKEN = 'CreateLeagueOutputPort_TOKEN';
export const GET_LEAGUE_ADMIN_PERMISSIONS_OUTPUT_PORT_TOKEN = 'GetLeagueAdminPermissionsOutputPort_TOKEN';
export const GET_LEAGUE_MEMBERSHIPS_OUTPUT_PORT_TOKEN = 'GetLeagueMembershipsOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_MEMBERS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterMembersOutputPort_TOKEN';
export const GET_LEAGUE_ROSTER_JOIN_REQUESTS_OUTPUT_PORT_TOKEN = 'GetLeagueRosterJoinRequestsOutputPort_TOKEN';
export const GET_LEAGUE_OWNER_SUMMARY_OUTPUT_PORT_TOKEN = 'GetLeagueOwnerSummaryOutputPort_TOKEN';
export const GET_LEAGUE_SEASONS_OUTPUT_PORT_TOKEN = 'GetLeagueSeasonsOutputPort_TOKEN';
export const JOIN_LEAGUE_OUTPUT_PORT_TOKEN = 'JoinLeagueOutputPort_TOKEN';
export const GET_LEAGUE_SCHEDULE_OUTPUT_PORT_TOKEN = 'GetLeagueScheduleOutputPort_TOKEN';
export const GET_LEAGUE_STATS_OUTPUT_PORT_TOKEN = 'GetLeagueStatsOutputPort_TOKEN';
export const REJECT_LEAGUE_JOIN_REQUEST_OUTPUT_PORT_TOKEN = 'RejectLeagueJoinRequestOutputPort_TOKEN';
export const REMOVE_LEAGUE_MEMBER_OUTPUT_PORT_TOKEN = 'RemoveLeagueMemberOutputPort_TOKEN';
export const TOTAL_LEAGUES_OUTPUT_PORT_TOKEN = 'TotalLeaguesOutputPort_TOKEN';
export const TRANSFER_LEAGUE_OWNERSHIP_OUTPUT_PORT_TOKEN = 'TransferLeagueOwnershipOutputPort_TOKEN';
export const UPDATE_LEAGUE_MEMBER_ROLE_OUTPUT_PORT_TOKEN = 'UpdateLeagueMemberRoleOutputPort_TOKEN';
export const GET_LEAGUE_FULL_CONFIG_OUTPUT_PORT_TOKEN = 'GetLeagueFullConfigOutputPort_TOKEN';
export const GET_LEAGUE_SCORING_CONFIG_OUTPUT_PORT_TOKEN = 'GetLeagueScoringConfigOutputPort_TOKEN';
export const GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN = 'GetLeagueWalletOutputPort_TOKEN';
export const WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN = 'WithdrawFromLeagueWalletOutputPort_TOKEN';
// Schedule mutation output ports
export const CREATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN = 'CreateLeagueSeasonScheduleRaceOutputPort_TOKEN';
export const UPDATE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN = 'UpdateLeagueSeasonScheduleRaceOutputPort_TOKEN';
export const DELETE_LEAGUE_SEASON_SCHEDULE_RACE_OUTPUT_PORT_TOKEN = 'DeleteLeagueSeasonScheduleRaceOutputPort_TOKEN';
export const PUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN = 'PublishLeagueSeasonScheduleOutputPort_TOKEN';
export const UNPUBLISH_LEAGUE_SEASON_SCHEDULE_OUTPUT_PORT_TOKEN = 'UnpublishLeagueSeasonScheduleOutputPort_TOKEN';

View File

@@ -17,6 +17,8 @@ describe('LeagueOwnerSummaryPresenter', () => {
joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
totalMembers: 50,
activeMembers: 45,
rating: 1500,
rank: 100,
};

View File

@@ -1,3 +1,4 @@
import { Injectable } from '@nestjs/common';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetLeagueRosterMembersResult } from '@core/racing/application/use-cases/GetLeagueRosterMembersUseCase';
import type { GetLeagueRosterJoinRequestsResult } from '@core/racing/application/use-cases/GetLeagueRosterJoinRequestsUseCase';
@@ -5,6 +6,7 @@ import type { LeagueRosterMemberDTO } from '../dtos/LeagueRosterMemberDTO';
import type { LeagueRosterJoinRequestDTO } from '../dtos/LeagueRosterJoinRequestDTO';
import type { DriverDTO } from '../../driver/dtos/DriverDTO';
@Injectable()
export class GetLeagueRosterMembersPresenter implements UseCaseOutputPort<GetLeagueRosterMembersResult> {
private viewModel: LeagueRosterMemberDTO[] | null = null;
@@ -37,6 +39,7 @@ export class GetLeagueRosterMembersPresenter implements UseCaseOutputPort<GetLea
}
}
@Injectable()
export class GetLeagueRosterJoinRequestsPresenter implements UseCaseOutputPort<GetLeagueRosterJoinRequestsResult> {
private viewModel: LeagueRosterJoinRequestDTO[] | null = null;

View File

@@ -7,7 +7,7 @@ import { IAvatarRepository } from '@core/media/domain/repositories/IAvatarReposi
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, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
// Import use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
@@ -17,14 +17,6 @@ import { DeleteMediaUseCase } from '@core/media/application/use-cases/DeleteMedi
import { GetAvatarUseCase } from '@core/media/application/use-cases/GetAvatarUseCase';
import { UpdateAvatarUseCase } from '@core/media/application/use-cases/UpdateAvatarUseCase';
// Import result types
import type { RequestAvatarGenerationResult } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import type { UploadMediaResult } from '@core/media/application/use-cases/UploadMediaUseCase';
import type { GetMediaResult } from '@core/media/application/use-cases/GetMediaUseCase';
import type { DeleteMediaResult } from '@core/media/application/use-cases/DeleteMediaUseCase';
import type { GetAvatarResult } from '@core/media/application/use-cases/GetAvatarUseCase';
import type { UpdateAvatarResult } from '@core/media/application/use-cases/UpdateAvatarUseCase';
// Import presenters
import { RequestAvatarGenerationPresenter } from './presenters/RequestAvatarGenerationPresenter';
import { UploadMediaPresenter } from './presenters/UploadMediaPresenter';
@@ -47,12 +39,6 @@ import {
DELETE_MEDIA_USE_CASE_TOKEN,
GET_AVATAR_USE_CASE_TOKEN,
UPDATE_AVATAR_USE_CASE_TOKEN,
REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN,
UPLOAD_MEDIA_OUTPUT_PORT_TOKEN,
GET_MEDIA_OUTPUT_PORT_TOKEN,
DELETE_MEDIA_OUTPUT_PORT_TOKEN,
GET_AVATAR_OUTPUT_PORT_TOKEN,
UPDATE_AVATAR_OUTPUT_PORT_TOKEN,
} from './MediaTokens';
export * from './MediaTokens';
@@ -133,66 +119,41 @@ export const MediaProviders: Provider[] = createLoggedProviders([
provide: LOGGER_TOKEN,
useClass: MockLogger,
},
// Output ports
{
provide: REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN,
useExisting: RequestAvatarGenerationPresenter,
},
{
provide: UPLOAD_MEDIA_OUTPUT_PORT_TOKEN,
useExisting: UploadMediaPresenter,
},
{
provide: GET_MEDIA_OUTPUT_PORT_TOKEN,
useExisting: GetMediaPresenter,
},
{
provide: DELETE_MEDIA_OUTPUT_PORT_TOKEN,
useExisting: DeleteMediaPresenter,
},
{
provide: GET_AVATAR_OUTPUT_PORT_TOKEN,
useExisting: GetAvatarPresenter,
},
{
provide: UPDATE_AVATAR_OUTPUT_PORT_TOKEN,
useExisting: UpdateAvatarPresenter,
},
// Use cases
// Use cases - simplified without output ports
{
provide: REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
useFactory: (avatarRepo: IAvatarGenerationRepository, faceValidation: FaceValidationPort, avatarGeneration: AvatarGenerationPort, output: UseCaseOutputPort<RequestAvatarGenerationResult>, logger: Logger) =>
new RequestAvatarGenerationUseCase(avatarRepo, faceValidation, avatarGeneration, output, logger),
inject: [AVATAR_GENERATION_REPOSITORY_TOKEN, FACE_VALIDATION_PORT_TOKEN, AVATAR_GENERATION_PORT_TOKEN, REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
useFactory: (avatarRepo: IAvatarGenerationRepository, faceValidation: FaceValidationPort, avatarGeneration: AvatarGenerationPort, logger: Logger) =>
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, output: UseCaseOutputPort<UploadMediaResult>, logger: Logger) =>
new UploadMediaUseCase(mediaRepo, mediaStorage, output, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, UPLOAD_MEDIA_OUTPUT_PORT_TOKEN, LOGGER_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, output: UseCaseOutputPort<GetMediaResult>, logger: Logger) =>
new GetMediaUseCase(mediaRepo, output, logger),
inject: [MEDIA_REPOSITORY_TOKEN, GET_MEDIA_OUTPUT_PORT_TOKEN, LOGGER_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, output: UseCaseOutputPort<DeleteMediaResult>, logger: Logger) =>
new DeleteMediaUseCase(mediaRepo, mediaStorage, output, logger),
inject: [MEDIA_REPOSITORY_TOKEN, MEDIA_STORAGE_PORT_TOKEN, DELETE_MEDIA_OUTPUT_PORT_TOKEN, LOGGER_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, output: UseCaseOutputPort<GetAvatarResult>, logger: Logger) =>
new GetAvatarUseCase(avatarRepo, output, logger),
inject: [AVATAR_REPOSITORY_TOKEN, GET_AVATAR_OUTPUT_PORT_TOKEN, LOGGER_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, output: UseCaseOutputPort<UpdateAvatarResult>, logger: Logger) =>
new UpdateAvatarUseCase(avatarRepo, output, logger),
inject: [AVATAR_REPOSITORY_TOKEN, UPDATE_AVATAR_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
useFactory: (avatarRepo: IAvatarRepository, logger: Logger) =>
new UpdateAvatarUseCase(avatarRepo, logger),
inject: [AVATAR_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
], initLogger);

View File

@@ -7,11 +7,12 @@ describe('MediaService', () => {
it('requestAvatarGeneration returns presenter response on success', async () => {
const requestAvatarGenerationPresenter = {
transform: vi.fn((result) => ({ success: true, requestId: result.requestId, avatarUrls: result.avatarUrls, errorMessage: '' })),
responseModel: { success: true, requestId: 'r1', avatarUrls: ['u1'], errorMessage: '' },
};
const requestAvatarGenerationUseCase = {
execute: vi.fn(async () => Result.ok(undefined)),
execute: vi.fn(async () => Result.ok({ requestId: 'r1', status: 'completed', avatarUrls: ['u1'] })),
};
const service = new MediaService(
@@ -39,6 +40,7 @@ describe('MediaService', () => {
facePhotoData: {} as any,
suitColor: 'red',
});
expect(requestAvatarGenerationPresenter.transform).toHaveBeenCalledWith({ requestId: 'r1', status: 'completed', avatarUrls: ['u1'] });
});
it('requestAvatarGeneration returns failure DTO on error', async () => {
@@ -69,8 +71,11 @@ describe('MediaService', () => {
});
it('uploadMedia returns presenter response on success', async () => {
const uploadMediaPresenter = { responseModel: { success: true, mediaId: 'm1' } };
const uploadMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const uploadMediaPresenter = {
transform: vi.fn((result) => ({ success: true, mediaId: result.mediaId })),
responseModel: { success: true, mediaId: 'm1' }
};
const uploadMediaUseCase = { execute: vi.fn(async () => Result.ok({ mediaId: 'm1', url: 'https://example.com/m1.png' })) };
const service = new MediaService(
{ execute: vi.fn() } as any,
@@ -100,10 +105,15 @@ describe('MediaService', () => {
uploadedBy: 'u1',
metadata: { a: 1 },
});
expect(uploadMediaPresenter.transform).toHaveBeenCalledWith({ mediaId: 'm1', url: 'https://example.com/m1.png' });
});
it('uploadMedia uses empty uploadedBy when userId missing', async () => {
const uploadMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const uploadMediaUseCase = { execute: vi.fn(async () => Result.ok({ mediaId: 'm1', url: 'https://example.com/m1.png' })) };
const uploadMediaPresenter = {
transform: vi.fn((result) => ({ success: true, mediaId: result.mediaId })),
responseModel: { success: true, mediaId: 'm1' }
};
const service = new MediaService(
{ execute: vi.fn() } as any,
@@ -114,7 +124,7 @@ describe('MediaService', () => {
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: { success: true, mediaId: 'm1' } } as any,
uploadMediaPresenter as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
@@ -123,6 +133,7 @@ describe('MediaService', () => {
await expect(service.uploadMedia({ file: {} as any } as any)).resolves.toEqual({ success: true, mediaId: 'm1' });
expect(uploadMediaUseCase.execute).toHaveBeenCalledWith({ file: {} as any, uploadedBy: '', metadata: {} });
expect(uploadMediaPresenter.transform).toHaveBeenCalledWith({ mediaId: 'm1', url: 'https://example.com/m1.png' });
});
it('uploadMedia returns failure DTO on error', async () => {
@@ -153,8 +164,12 @@ describe('MediaService', () => {
});
it('getMedia returns presenter response on success', async () => {
const getMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const getMediaPresenter = { responseModel: { mediaId: 'm1' } };
const uploadedAt = new Date();
const getMediaUseCase = { execute: vi.fn(async () => Result.ok({ media: { id: 'm1', filename: 'test.png', originalName: 'test.png', mimeType: 'image/png', size: 100, url: 'https://example.com/m1.png', type: 'image', uploadedBy: 'u1', uploadedAt } })) };
const getMediaPresenter = {
transform: vi.fn((result) => ({ id: result.media.id, url: result.media.url, type: result.media.type, uploadedAt: result.media.uploadedAt, size: result.media.size })),
responseModel: { id: 'm1', url: 'https://example.com/m1.png', type: 'image', uploadedAt, size: 100 }
};
const service = new MediaService(
{ execute: vi.fn() } as any,
@@ -172,8 +187,10 @@ describe('MediaService', () => {
{ responseModel: {} } as any,
);
await expect(service.getMedia('m1')).resolves.toEqual({ mediaId: 'm1' });
const result = await service.getMedia('m1');
expect(result).toEqual({ id: 'm1', url: 'https://example.com/m1.png', type: 'image', uploadedAt, size: 100 });
expect(getMediaUseCase.execute).toHaveBeenCalledWith({ mediaId: 'm1' });
expect(getMediaPresenter.transform).toHaveBeenCalledWith({ media: { id: 'm1', filename: 'test.png', originalName: 'test.png', mimeType: 'image/png', size: 100, url: 'https://example.com/m1.png', type: 'image', uploadedBy: 'u1', uploadedAt } });
});
it('getMedia returns null on MEDIA_NOT_FOUND', async () => {
@@ -217,8 +234,11 @@ describe('MediaService', () => {
});
it('deleteMedia returns presenter response on success', async () => {
const deleteMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const deleteMediaPresenter = { responseModel: { success: true } };
const deleteMediaUseCase = { execute: vi.fn(async () => Result.ok({ mediaId: 'm1', deleted: true })) };
const deleteMediaPresenter = {
transform: vi.fn((result) => ({ success: result.deleted })),
responseModel: { success: true }
};
const service = new MediaService(
{ execute: vi.fn() } as any,
@@ -238,6 +258,7 @@ describe('MediaService', () => {
await expect(service.deleteMedia('m1')).resolves.toEqual({ success: true });
expect(deleteMediaUseCase.execute).toHaveBeenCalledWith({ mediaId: 'm1' });
expect(deleteMediaPresenter.transform).toHaveBeenCalledWith({ mediaId: 'm1', deleted: true });
});
it('deleteMedia returns failure DTO on error', async () => {
@@ -261,8 +282,12 @@ describe('MediaService', () => {
});
it('getAvatar returns presenter response on success', async () => {
const getAvatarUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const getAvatarPresenter = { responseModel: { avatarUrl: 'u1' } };
const selectedAt = new Date();
const getAvatarUseCase = { execute: vi.fn(async () => Result.ok({ avatar: { id: 'a1', driverId: 'd1', mediaUrl: 'https://example.com/avatar.png', selectedAt } })) };
const getAvatarPresenter = {
transform: vi.fn((result) => ({ avatarUrl: result.avatar.mediaUrl })),
responseModel: { avatarUrl: 'https://example.com/avatar.png' }
};
const service = new MediaService(
{ execute: vi.fn() } as any,
@@ -280,8 +305,9 @@ describe('MediaService', () => {
{ responseModel: {} } as any,
);
await expect(service.getAvatar('d1')).resolves.toEqual({ avatarUrl: 'u1' });
await expect(service.getAvatar('d1')).resolves.toEqual({ avatarUrl: 'https://example.com/avatar.png' });
expect(getAvatarUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1' });
expect(getAvatarPresenter.transform).toHaveBeenCalledWith({ avatar: { id: 'a1', driverId: 'd1', mediaUrl: 'https://example.com/avatar.png', selectedAt } });
});
it('getAvatar returns null on AVATAR_NOT_FOUND', async () => {
@@ -325,8 +351,11 @@ describe('MediaService', () => {
});
it('updateAvatar returns presenter response on success', async () => {
const updateAvatarUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const updateAvatarPresenter = { responseModel: { success: true } };
const updateAvatarUseCase = { execute: vi.fn(async () => Result.ok({ avatarId: 'a1', driverId: 'd1' })) };
const updateAvatarPresenter = {
transform: vi.fn(() => ({ success: true })),
responseModel: { success: true }
};
const service = new MediaService(
{ execute: vi.fn() } as any,
@@ -346,6 +375,7 @@ describe('MediaService', () => {
await expect(service.updateAvatar('d1', { avatarUrl: 'u1' } as any)).resolves.toEqual({ success: true });
expect(updateAvatarUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', mediaUrl: 'u1' });
expect(updateAvatarPresenter.transform).toHaveBeenCalledWith({ avatarId: 'a1', driverId: 'd1' });
});
it('updateAvatar returns failure DTO on error', async () => {
@@ -507,4 +537,4 @@ describe('MediaService', () => {
error: 'Failed to update avatar',
});
});
});
});

View File

@@ -25,7 +25,7 @@ import { DeleteMediaUseCase } from '@core/media/application/use-cases/DeleteMedi
import { GetAvatarUseCase } from '@core/media/application/use-cases/GetAvatarUseCase';
import { UpdateAvatarUseCase } from '@core/media/application/use-cases/UpdateAvatarUseCase';
// Presenters
// Presenters (now transformers)
import { RequestAvatarGenerationPresenter } from './presenters/RequestAvatarGenerationPresenter';
import { UploadMediaPresenter } from './presenters/UploadMediaPresenter';
import { GetMediaPresenter } from './presenters/GetMediaPresenter';
@@ -90,7 +90,7 @@ export class MediaService {
};
}
return this.requestAvatarGenerationPresenter.responseModel;
return this.requestAvatarGenerationPresenter.transform(result.unwrap());
}
async uploadMedia(
@@ -112,7 +112,7 @@ export class MediaService {
};
}
return this.uploadMediaPresenter.responseModel;
return this.uploadMediaPresenter.transform(result.unwrap());
}
async getMedia(mediaId: string): Promise<GetMediaOutputDTO | null> {
@@ -128,7 +128,7 @@ export class MediaService {
throw new Error(error.details?.message ?? 'Failed to get media');
}
return this.getMediaPresenter.responseModel;
return this.getMediaPresenter.transform(result.unwrap());
}
async deleteMedia(mediaId: string): Promise<DeleteMediaOutputDTO> {
@@ -144,7 +144,7 @@ export class MediaService {
};
}
return this.deleteMediaPresenter.responseModel;
return this.deleteMediaPresenter.transform(result.unwrap());
}
async getAvatar(driverId: string): Promise<GetAvatarOutputDTO | null> {
@@ -160,7 +160,7 @@ export class MediaService {
throw new Error(error.details?.message ?? 'Failed to get avatar');
}
return this.getAvatarPresenter.responseModel;
return this.getAvatarPresenter.transform(result.unwrap());
}
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutputDTO> {
@@ -189,7 +189,7 @@ export class MediaService {
};
}
return this.updateAvatarPresenter.responseModel;
return this.updateAvatarPresenter.transform(result.unwrap());
}
async validateFacePhoto(input: ValidateFaceInputDTO): Promise<ValidateFaceOutputDTO> {
@@ -211,4 +211,4 @@ export class MediaService {
return { isValid: true };
}
}
}

View File

@@ -11,11 +11,4 @@ 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';
export const REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN = 'RequestAvatarGenerationOutputPort';
export const UPLOAD_MEDIA_OUTPUT_PORT_TOKEN = 'UploadMediaOutputPort';
export const GET_MEDIA_OUTPUT_PORT_TOKEN = 'GetMediaOutputPort';
export const DELETE_MEDIA_OUTPUT_PORT_TOKEN = 'DeleteMediaOutputPort';
export const GET_AVATAR_OUTPUT_PORT_TOKEN = 'GetAvatarOutputPort';
export const UPDATE_AVATAR_OUTPUT_PORT_TOKEN = 'UpdateAvatarOutputPort';
export const UPDATE_AVATAR_USE_CASE_TOKEN = 'UpdateAvatarUseCase';

View File

@@ -1,20 +1,20 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type { DeleteMediaResult } from '@core/media/application/use-cases/DeleteMediaUseCase';
import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO';
type DeleteMediaResponseModel = DeleteMediaOutputDTO;
export class DeleteMediaPresenter implements UseCaseOutputPort<DeleteMediaResult> {
export class DeleteMediaPresenter {
private model: DeleteMediaResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: DeleteMediaResult): void {
transform(result: DeleteMediaResult): DeleteMediaResponseModel {
this.model = {
success: result.deleted,
};
return this.model;
}
getResponseModel(): DeleteMediaResponseModel | null {
@@ -25,4 +25,4 @@ export class DeleteMediaPresenter implements UseCaseOutputPort<DeleteMediaResult
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}
}

View File

@@ -1,20 +1,20 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetAvatarResult } from '@core/media/application/use-cases/GetAvatarUseCase';
import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO';
export type GetAvatarResponseModel = GetAvatarOutputDTO | null;
export class GetAvatarPresenter implements UseCaseOutputPort<GetAvatarResult> {
export class GetAvatarPresenter {
private model: GetAvatarResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: GetAvatarResult): void {
transform(result: GetAvatarResult): GetAvatarResponseModel {
this.model = {
avatarUrl: result.avatar.mediaUrl,
};
return this.model;
}
getResponseModel(): GetAvatarResponseModel | null {

View File

@@ -1,17 +1,16 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type { GetMediaResult } from '@core/media/application/use-cases/GetMediaUseCase';
import type { GetMediaOutputDTO } from '../dtos/GetMediaOutputDTO';
export type GetMediaResponseModel = GetMediaOutputDTO | null;
export class GetMediaPresenter implements UseCaseOutputPort<GetMediaResult> {
export class GetMediaPresenter {
private model: GetMediaResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: GetMediaResult): void {
transform(result: GetMediaResult): GetMediaResponseModel {
const media = result.media;
const model: GetMediaResponseModel = {
@@ -29,6 +28,7 @@ export class GetMediaPresenter implements UseCaseOutputPort<GetMediaResult> {
}
this.model = model;
return model;
}
getResponseModel(): GetMediaResponseModel | null {

View File

@@ -1,22 +1,22 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type { RequestAvatarGenerationResult } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
import type { RequestAvatarGenerationOutputDTO } from '../dtos/RequestAvatarGenerationOutputDTO';
type RequestAvatarGenerationResponseModel = RequestAvatarGenerationOutputDTO;
export class RequestAvatarGenerationPresenter implements UseCaseOutputPort<RequestAvatarGenerationResult> {
export class RequestAvatarGenerationPresenter {
private model: RequestAvatarGenerationResponseModel | null = null;
reset() {
this.model = null;
}
present(result: RequestAvatarGenerationResult): void {
transform(result: RequestAvatarGenerationResult): RequestAvatarGenerationResponseModel {
this.model = {
success: result.status === 'completed',
requestId: result.requestId,
avatarUrls: result.avatarUrls || [],
};
return this.model;
}
getResponseModel(): RequestAvatarGenerationResponseModel | null {

View File

@@ -1,22 +1,22 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type { UpdateAvatarResult } from '@core/media/application/use-cases/UpdateAvatarUseCase';
import type { UpdateAvatarOutputDTO } from '../dtos/UpdateAvatarOutputDTO';
type UpdateAvatarResponseModel = UpdateAvatarOutputDTO;
export class UpdateAvatarPresenter implements UseCaseOutputPort<UpdateAvatarResult> {
export class UpdateAvatarPresenter {
private model: UpdateAvatarResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: UpdateAvatarResult): void {
transform(result: UpdateAvatarResult): UpdateAvatarResponseModel {
void result;
this.model = {
success: true,
};
return this.model;
}
getResponseModel(): UpdateAvatarResponseModel | null {

View File

@@ -1,17 +1,16 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import type { UploadMediaResult } from '@core/media/application/use-cases/UploadMediaUseCase';
import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO';
type UploadMediaResponseModel = UploadMediaOutputDTO;
export class UploadMediaPresenter implements UseCaseOutputPort<UploadMediaResult> {
export class UploadMediaPresenter {
private model: UploadMediaResponseModel | null = null;
reset(): void {
this.model = null;
}
present(result: UploadMediaResult): void {
transform(result: UploadMediaResult): UploadMediaResponseModel {
const model: UploadMediaResponseModel = {
success: true,
mediaId: result.mediaId,
@@ -22,6 +21,7 @@ export class UploadMediaPresenter implements UseCaseOutputPort<UploadMediaResult
}
this.model = model;
return model;
}
getResponseModel(): UploadMediaResponseModel | null {
@@ -32,4 +32,4 @@ export class UploadMediaPresenter implements UseCaseOutputPort<UploadMediaResult
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}
}

View File

@@ -5,7 +5,6 @@ import type { IPaymentRepository } from '@core/payments/domain/repositories/IPay
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '@core/payments/domain/repositories/IMembershipFeeRepository';
import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository';
import type { IWalletRepository, ITransactionRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { UseCaseOutputPort } from '@core/shared/application';
// Import use cases
import { GetPaymentsUseCase } from '@core/payments/application/use-cases/GetPaymentsUseCase';
@@ -24,20 +23,6 @@ import { ProcessWalletTransactionUseCase } from '@core/payments/application/use-
// Import concrete in-memory implementations
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Presenters
import { GetPaymentsPresenter } from './presenters/GetPaymentsPresenter';
import { CreatePaymentPresenter } from './presenters/CreatePaymentPresenter';
import { UpdatePaymentStatusPresenter } from './presenters/UpdatePaymentStatusPresenter';
import { GetMembershipFeesPresenter } from './presenters/GetMembershipFeesPresenter';
import { UpsertMembershipFeePresenter } from './presenters/UpsertMembershipFeePresenter';
import { UpdateMemberPaymentPresenter } from './presenters/UpdateMemberPaymentPresenter';
import { GetPrizesPresenter } from './presenters/GetPrizesPresenter';
import { CreatePrizePresenter } from './presenters/CreatePrizePresenter';
import { AwardPrizePresenter } from './presenters/AwardPrizePresenter';
import { DeletePrizePresenter } from './presenters/DeletePrizePresenter';
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
import {
PAYMENT_REPOSITORY_TOKEN,
MEMBERSHIP_FEE_REPOSITORY_TOKEN,
@@ -58,88 +43,12 @@ import {
DELETE_PRIZE_USE_CASE_TOKEN,
GET_WALLET_USE_CASE_TOKEN,
PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
GET_PAYMENTS_OUTPUT_PORT_TOKEN,
CREATE_PAYMENT_OUTPUT_PORT_TOKEN,
UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN,
GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN,
UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN,
UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN,
GET_PRIZES_OUTPUT_PORT_TOKEN,
CREATE_PRIZE_OUTPUT_PORT_TOKEN,
AWARD_PRIZE_OUTPUT_PORT_TOKEN,
DELETE_PRIZE_OUTPUT_PORT_TOKEN,
GET_WALLET_OUTPUT_PORT_TOKEN,
PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN,
} from './PaymentsTokens';
export * from './PaymentsTokens';
export const PaymentsProviders: Provider[] = [
// Presenters
GetPaymentsPresenter,
CreatePaymentPresenter,
UpdatePaymentStatusPresenter,
GetMembershipFeesPresenter,
UpsertMembershipFeePresenter,
UpdateMemberPaymentPresenter,
GetPrizesPresenter,
CreatePrizePresenter,
AwardPrizePresenter,
DeletePrizePresenter,
GetWalletPresenter,
ProcessWalletTransactionPresenter,
// Output ports
{
provide: GET_PAYMENTS_OUTPUT_PORT_TOKEN,
useExisting: GetPaymentsPresenter,
},
{
provide: CREATE_PAYMENT_OUTPUT_PORT_TOKEN,
useExisting: CreatePaymentPresenter,
},
{
provide: UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN,
useExisting: UpdatePaymentStatusPresenter,
},
{
provide: GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN,
useExisting: GetMembershipFeesPresenter,
},
{
provide: UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN,
useExisting: UpsertMembershipFeePresenter,
},
{
provide: UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN,
useExisting: UpdateMemberPaymentPresenter,
},
{
provide: GET_PRIZES_OUTPUT_PORT_TOKEN,
useExisting: GetPrizesPresenter,
},
{
provide: CREATE_PRIZE_OUTPUT_PORT_TOKEN,
useExisting: CreatePrizePresenter,
},
{
provide: AWARD_PRIZE_OUTPUT_PORT_TOKEN,
useExisting: AwardPrizePresenter,
},
{
provide: DELETE_PRIZE_OUTPUT_PORT_TOKEN,
useExisting: DeletePrizePresenter,
},
{
provide: GET_WALLET_OUTPUT_PORT_TOKEN,
useExisting: GetWalletPresenter,
},
{
provide: PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN,
useExisting: ProcessWalletTransactionPresenter,
},
// Logger
{
provide: LOGGER_TOKEN,
@@ -151,66 +60,66 @@ export const PaymentsProviders: Provider[] = [
// Use cases (use cases receive repositories, services receive use cases)
{
provide: GET_PAYMENTS_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository, output: UseCaseOutputPort<unknown>) => new GetPaymentsUseCase(paymentRepo, output),
inject: [PAYMENT_REPOSITORY_TOKEN, GET_PAYMENTS_OUTPUT_PORT_TOKEN],
useFactory: (paymentRepo: IPaymentRepository) => new GetPaymentsUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
},
{
provide: CREATE_PAYMENT_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository, output: UseCaseOutputPort<unknown>) => new CreatePaymentUseCase(paymentRepo, output),
inject: [PAYMENT_REPOSITORY_TOKEN, CREATE_PAYMENT_OUTPUT_PORT_TOKEN],
useFactory: (paymentRepo: IPaymentRepository) => new CreatePaymentUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
},
{
provide: UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
useFactory: (paymentRepo: IPaymentRepository, output: UseCaseOutputPort<unknown>) => new UpdatePaymentStatusUseCase(paymentRepo, output),
inject: [PAYMENT_REPOSITORY_TOKEN, UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN],
useFactory: (paymentRepo: IPaymentRepository) => new UpdatePaymentStatusUseCase(paymentRepo),
inject: [PAYMENT_REPOSITORY_TOKEN],
},
{
provide: GET_MEMBERSHIP_FEES_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository, output: UseCaseOutputPort<unknown>) =>
new GetMembershipFeesUseCase(membershipFeeRepo, memberPaymentRepo, output),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN, GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN],
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) =>
new GetMembershipFeesUseCase(membershipFeeRepo, memberPaymentRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN],
},
{
provide: UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository, output: UseCaseOutputPort<unknown>) => new UpsertMembershipFeeUseCase(membershipFeeRepo, output),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN],
useFactory: (membershipFeeRepo: IMembershipFeeRepository) => new UpsertMembershipFeeUseCase(membershipFeeRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN],
},
{
provide: UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository, output: UseCaseOutputPort<unknown>) =>
new UpdateMemberPaymentUseCase(membershipFeeRepo, memberPaymentRepo, output),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN, UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN],
useFactory: (membershipFeeRepo: IMembershipFeeRepository, memberPaymentRepo: IMemberPaymentRepository) =>
new UpdateMemberPaymentUseCase(membershipFeeRepo, memberPaymentRepo),
inject: [MEMBERSHIP_FEE_REPOSITORY_TOKEN, MEMBER_PAYMENT_REPOSITORY_TOKEN],
},
{
provide: GET_PRIZES_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<unknown>) => new GetPrizesUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, GET_PRIZES_OUTPUT_PORT_TOKEN],
useFactory: (prizeRepo: IPrizeRepository) => new GetPrizesUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: CREATE_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<unknown>) => new CreatePrizeUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, CREATE_PRIZE_OUTPUT_PORT_TOKEN],
useFactory: (prizeRepo: IPrizeRepository) => new CreatePrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: AWARD_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<unknown>) => new AwardPrizeUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, AWARD_PRIZE_OUTPUT_PORT_TOKEN],
useFactory: (prizeRepo: IPrizeRepository) => new AwardPrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: DELETE_PRIZE_USE_CASE_TOKEN,
useFactory: (prizeRepo: IPrizeRepository, output: UseCaseOutputPort<unknown>) => new DeletePrizeUseCase(prizeRepo, output),
inject: [PRIZE_REPOSITORY_TOKEN, DELETE_PRIZE_OUTPUT_PORT_TOKEN],
useFactory: (prizeRepo: IPrizeRepository) => new DeletePrizeUseCase(prizeRepo),
inject: [PRIZE_REPOSITORY_TOKEN],
},
{
provide: GET_WALLET_USE_CASE_TOKEN,
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository, output: UseCaseOutputPort<unknown>) =>
new GetWalletUseCase(walletRepo, transactionRepo, output),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN, GET_WALLET_OUTPUT_PORT_TOKEN],
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) =>
new GetWalletUseCase(walletRepo, transactionRepo),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN],
},
{
provide: PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository, output: UseCaseOutputPort<unknown>) =>
new ProcessWalletTransactionUseCase(walletRepo, transactionRepo, output),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN, PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN],
useFactory: (walletRepo: IWalletRepository, transactionRepo: ITransactionRepository) =>
new ProcessWalletTransactionUseCase(walletRepo, transactionRepo),
inject: [WALLET_REPOSITORY_TOKEN, TRANSACTION_REPOSITORY_TOKEN],
},
];

View File

@@ -6,42 +6,23 @@ describe('PaymentsService', () => {
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
function makeService(overrides?: Partial<Record<string, any>>) {
const getPaymentsUseCase = overrides?.getPaymentsUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const createPaymentUseCase = overrides?.createPaymentUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getPaymentsUseCase = overrides?.getPaymentsUseCase ?? { execute: vi.fn(async () => Result.ok({ payments: [] })) };
const createPaymentUseCase = overrides?.createPaymentUseCase ?? { execute: vi.fn(async () => Result.ok({ paymentId: 'p1' })) };
const updatePaymentStatusUseCase =
overrides?.updatePaymentStatusUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
overrides?.updatePaymentStatusUseCase ?? { execute: vi.fn(async () => Result.ok({ success: true })) };
const getMembershipFeesUseCase =
overrides?.getMembershipFeesUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
overrides?.getMembershipFeesUseCase ?? { execute: vi.fn(async () => Result.ok({ fee: null, payments: [] })) };
const upsertMembershipFeeUseCase =
overrides?.upsertMembershipFeeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
overrides?.upsertMembershipFeeUseCase ?? { execute: vi.fn(async () => Result.ok({ success: true })) };
const updateMemberPaymentUseCase =
overrides?.updateMemberPaymentUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getPrizesUseCase = overrides?.getPrizesUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const createPrizeUseCase = overrides?.createPrizeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const awardPrizeUseCase = overrides?.awardPrizeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const deletePrizeUseCase = overrides?.deletePrizeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getWalletUseCase = overrides?.getWalletUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
overrides?.updateMemberPaymentUseCase ?? { execute: vi.fn(async () => Result.ok({ success: true })) };
const getPrizesUseCase = overrides?.getPrizesUseCase ?? { execute: vi.fn(async () => Result.ok({ prizes: [] })) };
const createPrizeUseCase = overrides?.createPrizeUseCase ?? { execute: vi.fn(async () => Result.ok({ success: true })) };
const awardPrizeUseCase = overrides?.awardPrizeUseCase ?? { execute: vi.fn(async () => Result.ok({ success: true })) };
const deletePrizeUseCase = overrides?.deletePrizeUseCase ?? { execute: vi.fn(async () => Result.ok({ success: true })) };
const getWalletUseCase = overrides?.getWalletUseCase ?? { execute: vi.fn(async () => Result.ok({ balance: 0 })) };
const processWalletTransactionUseCase =
overrides?.processWalletTransactionUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getPaymentsPresenter = overrides?.getPaymentsPresenter ?? { getResponseModel: vi.fn(() => ({ payments: [] })) };
const createPaymentPresenter =
overrides?.createPaymentPresenter ?? { getResponseModel: vi.fn(() => ({ paymentId: 'p1' })) };
const updatePaymentStatusPresenter =
overrides?.updatePaymentStatusPresenter ?? { getResponseModel: vi.fn(() => ({ success: true })) };
const getMembershipFeesPresenter = overrides?.getMembershipFeesPresenter ?? { viewModel: { fee: null, payments: [] } };
const upsertMembershipFeePresenter = overrides?.upsertMembershipFeePresenter ?? { viewModel: { success: true } };
const updateMemberPaymentPresenter = overrides?.updateMemberPaymentPresenter ?? { viewModel: { success: true } };
const getPrizesPresenter = overrides?.getPrizesPresenter ?? { viewModel: { prizes: [] } };
const createPrizePresenter = overrides?.createPrizePresenter ?? { viewModel: { success: true } };
const awardPrizePresenter = overrides?.awardPrizePresenter ?? { viewModel: { success: true } };
const deletePrizePresenter = overrides?.deletePrizePresenter ?? { viewModel: { success: true } };
const getWalletPresenter = overrides?.getWalletPresenter ?? { viewModel: { balance: 0 } };
const processWalletTransactionPresenter =
overrides?.processWalletTransactionPresenter ?? { viewModel: { success: true } };
overrides?.processWalletTransactionUseCase ?? { execute: vi.fn(async () => Result.ok({ success: true })) };
const service = new PaymentsService(
getPaymentsUseCase as any,
@@ -57,18 +38,6 @@ describe('PaymentsService', () => {
getWalletUseCase as any,
processWalletTransactionUseCase as any,
logger as any,
getPaymentsPresenter as any,
createPaymentPresenter as any,
updatePaymentStatusPresenter as any,
getMembershipFeesPresenter as any,
upsertMembershipFeePresenter as any,
updateMemberPaymentPresenter as any,
getPrizesPresenter as any,
createPrizePresenter as any,
awardPrizePresenter as any,
deletePrizePresenter as any,
getWalletPresenter as any,
processWalletTransactionPresenter as any,
);
return {
@@ -85,26 +54,13 @@ describe('PaymentsService', () => {
deletePrizeUseCase,
getWalletUseCase,
processWalletTransactionUseCase,
getPaymentsPresenter,
createPaymentPresenter,
updatePaymentStatusPresenter,
getMembershipFeesPresenter,
upsertMembershipFeePresenter,
updateMemberPaymentPresenter,
getPrizesPresenter,
createPrizePresenter,
awardPrizePresenter,
deletePrizePresenter,
getWalletPresenter,
processWalletTransactionPresenter,
};
}
it('getPayments returns presenter model on success', async () => {
const { service, getPaymentsUseCase, getPaymentsPresenter } = makeService();
const { service, getPaymentsUseCase } = makeService();
await expect(service.getPayments({ leagueId: 'l1' } as any)).resolves.toEqual({ payments: [] });
expect(getPaymentsUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(getPaymentsPresenter.getResponseModel).toHaveBeenCalled();
});
it('getPayments throws when use case returns error (code message)', async () => {
@@ -115,12 +71,9 @@ describe('PaymentsService', () => {
});
it('createPayment returns presenter model on success', async () => {
const { service, createPaymentUseCase, createPaymentPresenter } = makeService({
createPaymentPresenter: { getResponseModel: vi.fn(() => ({ paymentId: 'p1' })) },
});
const { service, createPaymentUseCase } = makeService();
await expect(service.createPayment({ leagueId: 'l1' } as any)).resolves.toEqual({ paymentId: 'p1' });
expect(createPaymentUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(createPaymentPresenter.getResponseModel).toHaveBeenCalled();
});
it('createPayment throws when use case returns error', async () => {
@@ -131,12 +84,9 @@ describe('PaymentsService', () => {
});
it('updatePaymentStatus returns presenter model on success', async () => {
const { service, updatePaymentStatusUseCase, updatePaymentStatusPresenter } = makeService({
updatePaymentStatusPresenter: { getResponseModel: vi.fn(() => ({ success: true })) },
});
const { service, updatePaymentStatusUseCase } = makeService();
await expect(service.updatePaymentStatus({ paymentId: 'p1' } as any)).resolves.toEqual({ success: true });
expect(updatePaymentStatusUseCase.execute).toHaveBeenCalledWith({ paymentId: 'p1' });
expect(updatePaymentStatusPresenter.getResponseModel).toHaveBeenCalled();
});
it('updatePaymentStatus throws when use case returns error', async () => {
@@ -147,8 +97,8 @@ describe('PaymentsService', () => {
});
it('getMembershipFees returns viewModel on success', async () => {
const { service, getMembershipFeesUseCase, getMembershipFeesPresenter } = makeService({
getMembershipFeesPresenter: { viewModel: { fee: { amount: 1 }, payments: [] } },
const { service, getMembershipFeesUseCase } = makeService({
getMembershipFeesUseCase: { execute: vi.fn(async () => Result.ok({ fee: { amount: 1 }, payments: [] })) }
});
await expect(service.getMembershipFees({ leagueId: 'l1', driverId: 'd1' } as any)).resolves.toEqual({
@@ -156,7 +106,6 @@ describe('PaymentsService', () => {
payments: [],
});
expect(getMembershipFeesUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', driverId: 'd1' });
expect(getMembershipFeesPresenter.viewModel).toBeDefined();
});
it('getMembershipFees throws when use case returns error', async () => {
@@ -167,9 +116,7 @@ describe('PaymentsService', () => {
});
it('upsertMembershipFee returns viewModel on success', async () => {
const { service, upsertMembershipFeeUseCase } = makeService({
upsertMembershipFeePresenter: { viewModel: { success: true } },
});
const { service, upsertMembershipFeeUseCase } = makeService();
await expect(service.upsertMembershipFee({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });
expect(upsertMembershipFeeUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
@@ -186,9 +133,7 @@ describe('PaymentsService', () => {
});
it('updateMemberPayment returns viewModel on success', async () => {
const { service, updateMemberPaymentUseCase } = makeService({
updateMemberPaymentPresenter: { viewModel: { success: true } },
});
const { service, updateMemberPaymentUseCase } = makeService();
await expect(service.updateMemberPayment({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });
expect(updateMemberPaymentUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
@@ -203,10 +148,9 @@ describe('PaymentsService', () => {
});
it('getPrizes maps seasonId optional', async () => {
const getPrizesUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const getPrizesUseCase = { execute: vi.fn(async () => Result.ok({ prizes: [] })) };
const { service } = makeService({
getPrizesUseCase,
getPrizesPresenter: { viewModel: { prizes: [] } },
});
await expect(service.getPrizes({ leagueId: 'l1' } as any)).resolves.toEqual({ prizes: [] });
@@ -217,10 +161,9 @@ describe('PaymentsService', () => {
});
it('createPrize calls use case and returns viewModel', async () => {
const createPrizeUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const createPrizeUseCase = { execute: vi.fn(async () => Result.ok({ success: true })) };
const { service } = makeService({
createPrizeUseCase,
createPrizePresenter: { viewModel: { success: true } },
});
await expect(service.createPrize({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });
@@ -228,10 +171,9 @@ describe('PaymentsService', () => {
});
it('awardPrize calls use case and returns viewModel', async () => {
const awardPrizeUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const awardPrizeUseCase = { execute: vi.fn(async () => Result.ok({ success: true })) };
const { service } = makeService({
awardPrizeUseCase,
awardPrizePresenter: { viewModel: { success: true } },
});
await expect(service.awardPrize({ prizeId: 'p1' } as any)).resolves.toEqual({ success: true });
@@ -239,10 +181,9 @@ describe('PaymentsService', () => {
});
it('deletePrize calls use case and returns viewModel', async () => {
const deletePrizeUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const deletePrizeUseCase = { execute: vi.fn(async () => Result.ok({ success: true })) };
const { service } = makeService({
deletePrizeUseCase,
deletePrizePresenter: { viewModel: { success: true } },
});
await expect(service.deletePrize({ prizeId: 'p1' } as any)).resolves.toEqual({ success: true });
@@ -250,10 +191,9 @@ describe('PaymentsService', () => {
});
it('getWallet calls use case and returns viewModel', async () => {
const getWalletUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const getWalletUseCase = { execute: vi.fn(async () => Result.ok({ balance: 10 })) };
const { service } = makeService({
getWalletUseCase,
getWalletPresenter: { viewModel: { balance: 10 } },
});
await expect(service.getWallet({ leagueId: 'l1' } as any)).resolves.toEqual({ balance: 10 });
@@ -261,10 +201,9 @@ describe('PaymentsService', () => {
});
it('processWalletTransaction calls use case and returns viewModel', async () => {
const processWalletTransactionUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const processWalletTransactionUseCase = { execute: vi.fn(async () => Result.ok({ success: true })) };
const { service } = makeService({
processWalletTransactionUseCase,
processWalletTransactionPresenter: { viewModel: { success: true } },
});
await expect(service.processWalletTransaction({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });

View File

@@ -15,20 +15,6 @@ import type { UpdateMemberPaymentUseCase } from '@core/payments/application/use-
import type { UpdatePaymentStatusUseCase } from '@core/payments/application/use-cases/UpdatePaymentStatusUseCase';
import type { UpsertMembershipFeeUseCase } from '@core/payments/application/use-cases/UpsertMembershipFeeUseCase';
// Presenters
import { AwardPrizePresenter } from './presenters/AwardPrizePresenter';
import { CreatePaymentPresenter } from './presenters/CreatePaymentPresenter';
import { CreatePrizePresenter } from './presenters/CreatePrizePresenter';
import { DeletePrizePresenter } from './presenters/DeletePrizePresenter';
import { GetMembershipFeesPresenter } from './presenters/GetMembershipFeesPresenter';
import { GetPaymentsPresenter } from './presenters/GetPaymentsPresenter';
import { GetPrizesPresenter } from './presenters/GetPrizesPresenter';
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
import { UpdateMemberPaymentPresenter } from './presenters/UpdateMemberPaymentPresenter';
import { UpdatePaymentStatusPresenter } from './presenters/UpdatePaymentStatusPresenter';
import { UpsertMembershipFeePresenter } from './presenters/UpsertMembershipFeePresenter';
// DTOs
import type {
AwardPrizeInput,
@@ -90,18 +76,6 @@ export class PaymentsService {
@Inject(GET_WALLET_USE_CASE_TOKEN) private readonly getWalletUseCase: GetWalletUseCase,
@Inject(PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN) private readonly processWalletTransactionUseCase: ProcessWalletTransactionUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
private readonly getPaymentsPresenter: GetPaymentsPresenter,
private readonly createPaymentPresenter: CreatePaymentPresenter,
private readonly updatePaymentStatusPresenter: UpdatePaymentStatusPresenter,
private readonly getMembershipFeesPresenter: GetMembershipFeesPresenter,
private readonly upsertMembershipFeePresenter: UpsertMembershipFeePresenter,
private readonly updateMemberPaymentPresenter: UpdateMemberPaymentPresenter,
private readonly getPrizesPresenter: GetPrizesPresenter,
private readonly createPrizePresenter: CreatePrizePresenter,
private readonly awardPrizePresenter: AwardPrizePresenter,
private readonly deletePrizePresenter: DeletePrizePresenter,
private readonly getWalletPresenter: GetWalletPresenter,
private readonly processWalletTransactionPresenter: ProcessWalletTransactionPresenter,
) {}
async getPayments(query: GetPaymentsQuery): Promise<GetPaymentsOutput> {
@@ -111,7 +85,11 @@ export class PaymentsService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code ?? 'Failed to get payments');
}
return this.getPaymentsPresenter.getResponseModel();
const value = result.value;
if (!value) {
throw new Error('Failed to get payments: no value returned');
}
return value;
}
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentOutput> {
@@ -121,7 +99,11 @@ export class PaymentsService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code ?? 'Failed to create payment');
}
return this.createPaymentPresenter.getResponseModel();
const value = result.value;
if (!value) {
throw new Error('Failed to create payment: no value returned');
}
return value;
}
async updatePaymentStatus(input: UpdatePaymentStatusInput): Promise<UpdatePaymentStatusOutput> {
@@ -131,7 +113,11 @@ export class PaymentsService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code ?? 'Failed to update payment status');
}
return this.updatePaymentStatusPresenter.getResponseModel();
const value = result.value;
if (!value) {
throw new Error('Failed to update payment status: no value returned');
}
return value;
}
async getMembershipFees(query: GetMembershipFeesQuery): Promise<GetMembershipFeesOutput> {
@@ -141,7 +127,11 @@ export class PaymentsService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code ?? 'Failed to get membership fees');
}
return this.getMembershipFeesPresenter.viewModel;
const value = result.value;
if (!value) {
throw new Error('Failed to get membership fees: no value returned');
}
return value;
}
async upsertMembershipFee(input: UpsertMembershipFeeInput): Promise<UpsertMembershipFeeOutput> {
@@ -153,7 +143,11 @@ export class PaymentsService {
// but we keep the check for consistency
throw new Error('Failed to upsert membership fee');
}
return this.upsertMembershipFeePresenter.viewModel;
const value = result.value;
if (!value) {
throw new Error('Failed to upsert membership fee: no value returned');
}
return value;
}
async updateMemberPayment(input: UpdateMemberPaymentInput): Promise<UpdateMemberPaymentOutput> {
@@ -163,7 +157,11 @@ export class PaymentsService {
if (result.isErr()) {
throw new Error(result.unwrapErr().code ?? 'Failed to update member payment');
}
return this.updateMemberPaymentPresenter.viewModel;
const value = result.value;
if (!value) {
throw new Error('Failed to update member payment: no value returned');
}
return value;
}
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesOutput> {
@@ -175,42 +173,89 @@ export class PaymentsService {
if (query.seasonId !== undefined) {
input.seasonId = query.seasonId;
}
await this.getPrizesUseCase.execute(input);
return this.getPrizesPresenter.viewModel;
const result = await this.getPrizesUseCase.execute(input);
if (result.isErr()) {
throw new Error('Failed to get prizes');
}
const value = result.value;
if (!value) {
throw new Error('Failed to get prizes: no value returned');
}
return value;
}
async createPrize(input: CreatePrizeInput): Promise<CreatePrizeOutput> {
this.logger.debug('[PaymentsService] Creating prize', { input });
await this.createPrizeUseCase.execute(input);
return this.createPrizePresenter.viewModel;
const result = await this.createPrizeUseCase.execute(input);
if (result.isErr()) {
const err = result.unwrapErr();
throw new Error(err.code ?? 'Failed to create prize');
}
const value = result.value;
if (!value) {
throw new Error('Failed to create prize: no value returned');
}
return value;
}
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizeOutput> {
this.logger.debug('[PaymentsService] Awarding prize', { input });
await this.awardPrizeUseCase.execute(input);
return this.awardPrizePresenter.viewModel;
const result = await this.awardPrizeUseCase.execute(input);
if (result.isErr()) {
const err = result.unwrapErr();
throw new Error(err.code ?? 'Failed to award prize');
}
const value = result.value;
if (!value) {
throw new Error('Failed to award prize: no value returned');
}
return value;
}
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizeOutput> {
this.logger.debug('[PaymentsService] Deleting prize', { input });
await this.deletePrizeUseCase.execute(input);
return this.deletePrizePresenter.viewModel;
const result = await this.deletePrizeUseCase.execute(input);
if (result.isErr()) {
const err = result.unwrapErr();
throw new Error(err.code ?? 'Failed to delete prize');
}
const value = result.value;
if (!value) {
throw new Error('Failed to delete prize: no value returned');
}
return value;
}
async getWallet(query: GetWalletQuery): Promise<GetWalletOutput> {
this.logger.debug('[PaymentsService] Getting wallet', { query });
await this.getWalletUseCase.execute({ leagueId: query.leagueId! });
return this.getWalletPresenter.viewModel;
const result = await this.getWalletUseCase.execute({ leagueId: query.leagueId! });
if (result.isErr()) {
const err = result.unwrapErr();
throw new Error(err.code ?? 'Failed to get wallet');
}
const value = result.value;
if (!value) {
throw new Error('Failed to get wallet: no value returned');
}
return value;
}
async processWalletTransaction(input: ProcessWalletTransactionInput): Promise<ProcessWalletTransactionOutput> {
this.logger.debug('[PaymentsService] Processing wallet transaction', { input });
await this.processWalletTransactionUseCase.execute(input);
return this.processWalletTransactionPresenter.viewModel;
const result = await this.processWalletTransactionUseCase.execute(input);
if (result.isErr()) {
const err = result.unwrapErr();
throw new Error(err.code ?? 'Failed to process wallet transaction');
}
const value = result.value;
if (!value) {
throw new Error('Failed to process wallet transaction: no value returned');
}
return value;
}
}

View File

@@ -27,17 +27,4 @@ export const CREATE_PRIZE_USE_CASE_TOKEN = 'CreatePrizeUseCase';
export const AWARD_PRIZE_USE_CASE_TOKEN = 'AwardPrizeUseCase';
export const DELETE_PRIZE_USE_CASE_TOKEN = 'DeletePrizeUseCase';
export const GET_WALLET_USE_CASE_TOKEN = 'GetWalletUseCase';
export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase';
export const GET_PAYMENTS_OUTPUT_PORT_TOKEN = 'GetPaymentsOutputPort_TOKEN';
export const CREATE_PAYMENT_OUTPUT_PORT_TOKEN = 'CreatePaymentOutputPort_TOKEN';
export const UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN = 'UpdatePaymentStatusOutputPort_TOKEN';
export const GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN = 'GetMembershipFeesOutputPort_TOKEN';
export const UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN = 'UpsertMembershipFeeOutputPort_TOKEN';
export const UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN = 'UpdateMemberPaymentOutputPort_TOKEN';
export const GET_PRIZES_OUTPUT_PORT_TOKEN = 'GetPrizesOutputPort_TOKEN';
export const CREATE_PRIZE_OUTPUT_PORT_TOKEN = 'CreatePrizeOutputPort_TOKEN';
export const AWARD_PRIZE_OUTPUT_PORT_TOKEN = 'AwardPrizeOutputPort_TOKEN';
export const DELETE_PRIZE_OUTPUT_PORT_TOKEN = 'DeletePrizeOutputPort_TOKEN';
export const GET_WALLET_OUTPUT_PORT_TOKEN = 'GetWalletOutputPort_TOKEN';
export const PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN = 'ProcessWalletTransactionOutputPort_TOKEN';
export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase';

View File

@@ -20,17 +20,13 @@ 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 REVIEW_PROTEST_PRESENTER_TOKEN = 'ReviewProtestPresenter';
export const ProtestsProviders: Provider[] = [
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
{
provide: REVIEW_PROTEST_PRESENTER_TOKEN,
useClass: ReviewProtestPresenter,
},
ReviewProtestPresenter,
// Use cases
{
provide: ReviewProtestUseCase,
@@ -39,14 +35,12 @@ export const ProtestsProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
output: ReviewProtestPresenter,
) => new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo, logger, output),
) => new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo, logger),
inject: [
PROTEST_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
LOGGER_TOKEN,
REVIEW_PROTEST_PRESENTER_TOKEN,
],
},
];

View File

@@ -40,13 +40,15 @@ describe('ProtestsService', () => {
it('returns DTO with success model on success', async () => {
executeMock.mockImplementation(async (command) => {
presenter.present({ protestId: command.protestId } as ReviewProtestResult);
return Result.ok(undefined);
return Result.ok({
leagueId: 'league-1',
protestId: command.protestId,
status: 'upheld',
});
});
const dto = await service.reviewProtest(baseCommand);
expect(presenter.getResponseModel()).not.toBeNull();
expect(executeMock).toHaveBeenCalledWith(baseCommand);
expect(dto).toEqual<ReviewProtestResponseDTO>({
success: true,
@@ -63,8 +65,7 @@ describe('ProtestsService', () => {
};
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
return Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);
@@ -83,8 +84,7 @@ describe('ProtestsService', () => {
};
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
return Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);
@@ -103,8 +103,7 @@ describe('ProtestsService', () => {
};
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
return Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);
@@ -124,8 +123,7 @@ describe('ProtestsService', () => {
};
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
return Result.err<ReviewProtestResult, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);

View File

@@ -8,14 +8,14 @@ import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewP
import { ReviewProtestPresenter, type ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
// Tokens
import { LOGGER_TOKEN, REVIEW_PROTEST_PRESENTER_TOKEN } from './ProtestsProviders';
import { LOGGER_TOKEN } from './ProtestsProviders';
@Injectable()
export class ProtestsService {
constructor(
private readonly reviewProtestUseCase: ReviewProtestUseCase,
@Inject(REVIEW_PROTEST_PRESENTER_TOKEN) private readonly reviewProtestPresenter: ReviewProtestPresenter,
private readonly reviewProtestPresenter: ReviewProtestPresenter,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
@@ -27,14 +27,20 @@ export class ProtestsService {
}): Promise<ReviewProtestResponseDTO> {
this.logger.debug('[ProtestsService] Reviewing protest:', command);
// Set the command on the presenter so it can include stewardId and decision in the response
this.reviewProtestPresenter.setCommand({
const result = await this.reviewProtestUseCase.execute(command);
if (result.isErr()) {
const err = result.unwrapErr();
this.reviewProtestPresenter.presentError(err);
return this.reviewProtestPresenter.responseModel;
}
// Present the result with the additional context
this.reviewProtestPresenter.present(result.unwrap(), {
stewardId: command.stewardId,
decision: command.decision,
});
await this.reviewProtestUseCase.execute(command);
return this.reviewProtestPresenter.responseModel;
}
}

View File

@@ -12,27 +12,21 @@ export interface ReviewProtestResponseDTO {
export class ReviewProtestPresenter implements UseCaseOutputPort<ReviewProtestResult> {
private model: ReviewProtestResponseDTO | null = null;
private command: { stewardId: string; decision: 'uphold' | 'dismiss' } | null = null;
reset(): void {
this.model = null;
this.command = null;
}
setCommand(command: { stewardId: string; decision: 'uphold' | 'dismiss' }): void {
this.command = command;
}
present(result: ReviewProtestResult): void {
if (!this.command) {
throw new Error('Command must be set before presenting result');
present(result: ReviewProtestResult, context?: { stewardId: string; decision: 'uphold' | 'dismiss' }): void {
if (!context) {
throw new Error('Context must be provided when presenting result');
}
this.model = {
success: true,
protestId: result.protestId,
stewardId: this.command.stewardId,
decision: this.command.decision,
stewardId: context.stewardId,
decision: context.decision,
};
}

View File

@@ -12,8 +12,6 @@ import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepo
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { Logger } from '@core/shared/application/Logger';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
// Import concrete in-memory implementations
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
@@ -43,29 +41,6 @@ import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
// Import use case result types
import type { GetAllRacesResult } from '@core/racing/application/use-cases/GetAllRacesUseCase';
import type { GetTotalRacesResult } from '@core/racing/application/use-cases/GetTotalRacesUseCase';
import type { ImportRaceResultsApiResult } from '@core/racing/application/use-cases/ImportRaceResultsApiUseCase';
import type { GetRaceDetailResult } from '@core/racing/application/use-cases/GetRaceDetailUseCase';
import type { GetRacesPageDataResult } from '@core/racing/application/use-cases/GetRacesPageDataUseCase';
import type { GetAllRacesPageDataResult } from '@core/racing/application/use-cases/GetAllRacesPageDataUseCase';
import type { GetRaceResultsDetailResult } from '@core/racing/application/use-cases/GetRaceResultsDetailUseCase';
import type { GetRaceWithSOFResult } from '@core/racing/application/use-cases/GetRaceWithSOFUseCase';
import type { GetRaceProtestsResult } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import type { GetRacePenaltiesResult } from '@core/racing/application/use-cases/GetRacePenaltiesUseCase';
import type { RegisterForRaceResult } from '@core/racing/application/use-cases/RegisterForRaceUseCase';
import type { WithdrawFromRaceResult } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
import type { CancelRaceResult } from '@core/racing/application/use-cases/CancelRaceUseCase';
import type { CompleteRaceResult } from '@core/racing/application/use-cases/CompleteRaceUseCase';
import type { ReopenRaceResult } from '@core/racing/application/use-cases/ReopenRaceUseCase';
import type { ImportRaceResultsResult } from '@core/racing/application/use-cases/ImportRaceResultsUseCase';
import type { FileProtestResult } from '@core/racing/application/use-cases/FileProtestUseCase';
import type { QuickPenaltyResult } from '@core/racing/application/use-cases/QuickPenaltyUseCase';
import type { ApplyPenaltyResult } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
import type { RequestProtestDefenseResult } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase';
import type { ReviewProtestResult } from '@core/racing/application/use-cases/ReviewProtestUseCase';
// Import presenters
import { AllRacesPageDataPresenter } from './presenters/AllRacesPageDataPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
@@ -107,183 +82,6 @@ import {
export * from './RaceTokens';
// Adapter classes to bridge presenters with UseCaseOutputPort interface
class GetAllRacesOutputAdapter implements UseCaseOutputPort<GetAllRacesResult> {
constructor(private presenter: GetAllRacesPresenter) {}
present(result: GetAllRacesResult): void {
this.presenter.present(result);
}
}
class GetTotalRacesOutputAdapter implements UseCaseOutputPort<GetTotalRacesResult> {
constructor(private presenter: GetTotalRacesPresenter) {}
present(result: GetTotalRacesResult): void {
// Wrap the result in a Result.ok() to match presenter expectations
const resultWrapper = Result.ok<GetTotalRacesResult, { code: 'REPOSITORY_ERROR'; details: { message: string } }>(result);
this.presenter.present(resultWrapper);
}
}
class ImportRaceResultsApiOutputAdapter implements UseCaseOutputPort<ImportRaceResultsApiResult> {
constructor(private presenter: ImportRaceResultsApiPresenter) {}
present(result: ImportRaceResultsApiResult): void {
const resultWrapper = Result.ok<ImportRaceResultsApiResult, { code: 'REPOSITORY_ERROR'; details: { message: string } }>(result);
this.presenter.present(resultWrapper);
}
}
class RaceDetailOutputAdapter implements UseCaseOutputPort<GetRaceDetailResult> {
constructor(private presenter: RaceDetailPresenter) {}
present(result: GetRaceDetailResult): void {
this.presenter.present(result);
}
}
class RacesPageDataOutputAdapter implements UseCaseOutputPort<GetRacesPageDataResult> {
constructor(private presenter: RacesPageDataPresenter) {}
present(result: GetRacesPageDataResult): void {
const resultWrapper = Result.ok<GetRacesPageDataResult, { code: 'REPOSITORY_ERROR'; details: { message: string } }>(result);
this.presenter.present(resultWrapper);
}
}
class AllRacesPageDataOutputAdapter implements UseCaseOutputPort<GetAllRacesPageDataResult> {
constructor(private presenter: AllRacesPageDataPresenter) {}
present(result: GetAllRacesPageDataResult): void {
const resultWrapper = Result.ok<GetAllRacesPageDataResult, { code: 'REPOSITORY_ERROR'; details: { message: string } }>(result);
this.presenter.present(resultWrapper);
}
}
class RaceResultsDetailOutputAdapter implements UseCaseOutputPort<GetRaceResultsDetailResult> {
constructor(private presenter: RaceResultsDetailPresenter) {}
present(result: GetRaceResultsDetailResult): void {
this.presenter.present(result);
}
}
class RaceWithSOFOutputAdapter implements UseCaseOutputPort<GetRaceWithSOFResult> {
constructor(private presenter: RaceWithSOFPresenter) {}
present(result: GetRaceWithSOFResult): void {
const resultWrapper = Result.ok<GetRaceWithSOFResult, { code: 'RACE_NOT_FOUND' | 'REPOSITORY_ERROR'; details: { message: string } }>(result);
this.presenter.present(resultWrapper);
}
}
class RaceProtestsOutputAdapter implements UseCaseOutputPort<GetRaceProtestsResult> {
constructor(private presenter: RaceProtestsPresenter) {}
present(result: GetRaceProtestsResult): void {
const resultWrapper = Result.ok<GetRaceProtestsResult, { code: 'REPOSITORY_ERROR'; details: { message: string } }>(result);
this.presenter.present(resultWrapper);
}
}
class RacePenaltiesOutputAdapter implements UseCaseOutputPort<GetRacePenaltiesResult> {
constructor(private presenter: RacePenaltiesPresenter) {}
present(result: GetRacePenaltiesResult): void {
const resultWrapper = Result.ok<GetRacePenaltiesResult, { code: 'REPOSITORY_ERROR'; details: { message: string } }>(result);
this.presenter.present(resultWrapper);
}
}
class RegisterForRaceOutputAdapter implements UseCaseOutputPort<RegisterForRaceResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Race registered successfully');
}
}
class WithdrawFromRaceOutputAdapter implements UseCaseOutputPort<WithdrawFromRaceResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Race withdrawal successful');
}
}
class CancelRaceOutputAdapter implements UseCaseOutputPort<CancelRaceResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Race cancelled successfully');
}
}
class CompleteRaceOutputAdapter implements UseCaseOutputPort<CompleteRaceResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Race completed successfully');
}
}
class ReopenRaceOutputAdapter implements UseCaseOutputPort<ReopenRaceResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Race reopened successfully');
}
}
class ImportRaceResultsOutputAdapter implements UseCaseOutputPort<ImportRaceResultsResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Race results imported successfully');
}
}
class FileProtestOutputAdapter implements UseCaseOutputPort<FileProtestResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Protest filed successfully');
}
}
class QuickPenaltyOutputAdapter implements UseCaseOutputPort<QuickPenaltyResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Penalty applied successfully');
}
}
class ApplyPenaltyOutputAdapter implements UseCaseOutputPort<ApplyPenaltyResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Penalty applied successfully');
}
}
class RequestProtestDefenseOutputAdapter implements UseCaseOutputPort<RequestProtestDefenseResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Defense request sent successfully');
}
}
class ReviewProtestOutputAdapter implements UseCaseOutputPort<ReviewProtestResult> {
constructor(private presenter: CommandResultPresenter) {}
present(): void {
this.presenter.presentSuccess('Protest reviewed successfully');
}
}
export const RaceProviders: Provider[] = [
{
provide: DRIVER_RATING_PROVIDER_TOKEN,
@@ -354,24 +152,19 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueRepo: ILeagueRepository,
logger: Logger,
presenter: GetAllRacesPresenter,
) => {
const useCase = new GetAllRacesUseCase(raceRepo, leagueRepo, logger);
useCase.setOutput(new GetAllRacesOutputAdapter(presenter));
return useCase;
return new GetAllRacesUseCase(raceRepo, leagueRepo, logger);
},
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN, GET_ALL_RACES_PRESENTER_TOKEN],
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetTotalRacesUseCase,
useFactory: (
raceRepo: IRaceRepository,
logger: Logger,
presenter: GetTotalRacesPresenter,
) => {
return new GetTotalRacesUseCase(raceRepo, logger, new GetTotalRacesOutputAdapter(presenter));
return new GetTotalRacesUseCase(raceRepo);
},
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN, GET_TOTAL_RACES_PRESENTER_TOKEN],
inject: [RACE_REPOSITORY_TOKEN],
},
{
provide: ImportRaceResultsApiUseCase,
@@ -382,7 +175,6 @@ export const RaceProviders: Provider[] = [
driverRepo: IDriverRepository,
standingRepo: IStandingRepository,
logger: Logger,
presenter: ImportRaceResultsApiPresenter,
) => {
return new ImportRaceResultsApiUseCase(
raceRepo,
@@ -391,7 +183,6 @@ export const RaceProviders: Provider[] = [
driverRepo,
standingRepo,
logger,
new ImportRaceResultsApiOutputAdapter(presenter)
);
},
inject: [
@@ -401,7 +192,6 @@ export const RaceProviders: Provider[] = [
DRIVER_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
LOGGER_TOKEN,
IMPORT_RACE_RESULTS_API_PRESENTER_TOKEN,
],
},
{
@@ -413,7 +203,6 @@ export const RaceProviders: Provider[] = [
raceRegRepo: IRaceRegistrationRepository,
resultRepo: IResultRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
presenter: RaceDetailPresenter,
) => {
return new GetRaceDetailUseCase(
raceRepo,
@@ -422,7 +211,6 @@ export const RaceProviders: Provider[] = [
raceRegRepo,
resultRepo,
leagueMembershipRepo,
new RaceDetailOutputAdapter(presenter),
);
},
inject: [
@@ -432,7 +220,6 @@ export const RaceProviders: Provider[] = [
RACE_REGISTRATION_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
RACE_DETAIL_PRESENTER_TOKEN,
],
},
{
@@ -441,16 +228,14 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueRepo: ILeagueRepository,
logger: Logger,
presenter: RacesPageDataPresenter,
) => {
return new GetRacesPageDataUseCase(
raceRepo,
leagueRepo,
logger,
new RacesPageDataOutputAdapter(presenter)
);
},
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN, RACES_PAGE_DATA_PRESENTER_TOKEN],
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetAllRacesPageDataUseCase,
@@ -458,16 +243,14 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueRepo: ILeagueRepository,
logger: Logger,
presenter: AllRacesPageDataPresenter,
) => {
return new GetAllRacesPageDataUseCase(
raceRepo,
leagueRepo,
logger,
new AllRacesPageDataOutputAdapter(presenter)
);
},
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN, ALL_RACES_PAGE_DATA_PRESENTER_TOKEN],
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GetRaceResultsDetailUseCase,
@@ -477,7 +260,6 @@ export const RaceProviders: Provider[] = [
resultRepo: IResultRepository,
driverRepo: IDriverRepository,
penaltyRepo: IPenaltyRepository,
presenter: RaceResultsDetailPresenter,
) => {
return new GetRaceResultsDetailUseCase(
raceRepo,
@@ -485,7 +267,6 @@ export const RaceProviders: Provider[] = [
resultRepo,
driverRepo,
penaltyRepo,
new RaceResultsDetailOutputAdapter(presenter)
);
},
inject: [
@@ -494,7 +275,6 @@ export const RaceProviders: Provider[] = [
RESULT_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
PENALTY_REPOSITORY_TOKEN,
RACE_RESULTS_DETAIL_PRESENTER_TOKEN,
],
},
{
@@ -504,7 +284,6 @@ export const RaceProviders: Provider[] = [
raceRegRepo: IRaceRegistrationRepository,
resultRepo: IResultRepository,
driverRatingProvider: DriverRatingProvider,
presenter: RaceWithSOFPresenter,
) => {
return new GetRaceWithSOFUseCase(
raceRepo,
@@ -514,7 +293,6 @@ export const RaceProviders: Provider[] = [
const rating = driverRatingProvider.getRating(input.driverId);
return { rating };
},
new RaceWithSOFOutputAdapter(presenter)
);
},
inject: [
@@ -522,7 +300,6 @@ export const RaceProviders: Provider[] = [
RACE_REGISTRATION_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
DRIVER_RATING_PROVIDER_TOKEN,
RACE_WITH_SOF_PRESENTER_TOKEN,
],
},
{
@@ -530,30 +307,26 @@ export const RaceProviders: Provider[] = [
useFactory: (
protestRepo: IProtestRepository,
driverRepo: IDriverRepository,
presenter: RaceProtestsPresenter,
) => {
return new GetRaceProtestsUseCase(
protestRepo,
driverRepo,
new RaceProtestsOutputAdapter(presenter)
);
},
inject: [PROTEST_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, RACE_PROTESTS_PRESENTER_TOKEN],
inject: [PROTEST_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN],
},
{
provide: GetRacePenaltiesUseCase,
useFactory: (
penaltyRepo: IPenaltyRepository,
driverRepo: IDriverRepository,
presenter: RacePenaltiesPresenter,
) => {
return new GetRacePenaltiesUseCase(
penaltyRepo,
driverRepo,
new RacePenaltiesOutputAdapter(presenter)
);
},
inject: [PENALTY_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, RACE_PENALTIES_PRESENTER_TOKEN],
inject: [PENALTY_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN],
},
{
provide: RegisterForRaceUseCase,
@@ -561,16 +334,14 @@ export const RaceProviders: Provider[] = [
raceRegRepo: IRaceRegistrationRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new RegisterForRaceUseCase(
raceRegRepo,
leagueMembershipRepo,
logger,
new RegisterForRaceOutputAdapter(presenter)
);
},
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: WithdrawFromRaceUseCase,
@@ -578,31 +349,27 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
raceRegRepo: IRaceRegistrationRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new WithdrawFromRaceUseCase(
raceRepo,
raceRegRepo,
logger,
new WithdrawFromRaceOutputAdapter(presenter)
);
},
inject: [RACE_REPOSITORY_TOKEN, RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [RACE_REPOSITORY_TOKEN, RACE_REGISTRATION_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: CancelRaceUseCase,
useFactory: (
raceRepo: IRaceRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new CancelRaceUseCase(
raceRepo,
logger,
new CancelRaceOutputAdapter(presenter)
);
},
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: CompleteRaceUseCase,
@@ -612,7 +379,6 @@ export const RaceProviders: Provider[] = [
resultRepo: IResultRepository,
standingRepo: IStandingRepository,
driverRatingProvider: DriverRatingProvider,
presenter: CommandResultPresenter,
) => {
return new CompleteRaceUseCase(
raceRepo,
@@ -623,7 +389,6 @@ export const RaceProviders: Provider[] = [
const rating = driverRatingProvider.getRating(input.driverId);
return { rating, ratingChange: null };
},
new CompleteRaceOutputAdapter(presenter)
);
},
inject: [
@@ -632,7 +397,6 @@ export const RaceProviders: Provider[] = [
RESULT_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
DRIVER_RATING_PROVIDER_TOKEN,
COMMAND_RESULT_PRESENTER_TOKEN,
],
},
{
@@ -640,15 +404,13 @@ export const RaceProviders: Provider[] = [
useFactory: (
raceRepo: IRaceRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new ReopenRaceUseCase(
raceRepo,
logger,
new ReopenRaceOutputAdapter(presenter)
);
},
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: ImportRaceResultsUseCase,
@@ -659,7 +421,6 @@ export const RaceProviders: Provider[] = [
driverRepo: IDriverRepository,
standingRepo: IStandingRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new ImportRaceResultsUseCase(
raceRepo,
@@ -668,7 +429,6 @@ export const RaceProviders: Provider[] = [
driverRepo,
standingRepo,
logger,
new ImportRaceResultsOutputAdapter(presenter)
);
},
inject: [
@@ -678,7 +438,6 @@ export const RaceProviders: Provider[] = [
DRIVER_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
LOGGER_TOKEN,
COMMAND_RESULT_PRESENTER_TOKEN,
],
},
{
@@ -687,16 +446,14 @@ export const RaceProviders: Provider[] = [
protestRepo: IProtestRepository,
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
presenter: CommandResultPresenter,
) => {
return new FileProtestUseCase(
protestRepo,
raceRepo,
leagueMembershipRepo,
new FileProtestOutputAdapter(presenter)
);
},
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: QuickPenaltyUseCase,
@@ -705,17 +462,15 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new QuickPenaltyUseCase(
penaltyRepo,
raceRepo,
leagueMembershipRepo,
logger,
new QuickPenaltyOutputAdapter(presenter)
);
},
inject: [PENALTY_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [PENALTY_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: ApplyPenaltyUseCase,
@@ -725,7 +480,6 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new ApplyPenaltyUseCase(
penaltyRepo,
@@ -733,10 +487,9 @@ export const RaceProviders: Provider[] = [
raceRepo,
leagueMembershipRepo,
logger,
new ApplyPenaltyOutputAdapter(presenter)
);
},
inject: [PENALTY_REPOSITORY_TOKEN, PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [PENALTY_REPOSITORY_TOKEN, PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: RequestProtestDefenseUseCase,
@@ -745,17 +498,15 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new RequestProtestDefenseUseCase(
protestRepo,
raceRepo,
leagueMembershipRepo,
logger,
new RequestProtestDefenseOutputAdapter(presenter)
);
},
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: ReviewProtestUseCase,
@@ -764,16 +515,14 @@ export const RaceProviders: Provider[] = [
raceRepo: IRaceRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
logger: Logger,
presenter: CommandResultPresenter,
) => {
return new ReviewProtestUseCase(
protestRepo,
raceRepo,
leagueMembershipRepo,
logger,
new ReviewProtestOutputAdapter(presenter)
);
},
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -1,20 +1,24 @@
import { describe, expect, it, vi } from 'vitest';
import { RaceService } from './RaceService';
import { Result } from '@core/shared/application/Result';
describe('RaceService', () => {
it('invokes each use case and returns the corresponding presenter', async () => {
const mkUseCase = () => ({ execute: vi.fn(async () => {}) });
// Mock use cases to return Result.ok()
const mkUseCase = (resultValue: any = { success: true }) => ({
execute: vi.fn(async () => Result.ok(resultValue))
});
const getAllRacesUseCase = mkUseCase();
const getTotalRacesUseCase = mkUseCase();
const importRaceResultsApiUseCase = mkUseCase();
const getRaceDetailUseCase = mkUseCase();
const getRacesPageDataUseCase = mkUseCase();
const getAllRacesPageDataUseCase = mkUseCase();
const getRaceResultsDetailUseCase = mkUseCase();
const getRaceWithSOFUseCase = mkUseCase();
const getRaceProtestsUseCase = mkUseCase();
const getRacePenaltiesUseCase = mkUseCase();
const getAllRacesUseCase = mkUseCase({ races: [], leagues: [] });
const getTotalRacesUseCase = mkUseCase({ totalRaces: 0 });
const importRaceResultsApiUseCase = mkUseCase({ success: true, raceId: 'r1', driversProcessed: 0, resultsRecorded: 0, errors: [] });
const getRaceDetailUseCase = mkUseCase({ race: null, league: null, drivers: [], isUserRegistered: false, canRegister: false });
const getRacesPageDataUseCase = mkUseCase({ races: [] });
const getAllRacesPageDataUseCase = mkUseCase({ races: [], filters: { statuses: [], leagues: [] } });
const getRaceResultsDetailUseCase = mkUseCase({ race: null, results: [], penalties: [] });
const getRaceWithSOFUseCase = mkUseCase({ race: null, strengthOfField: 0, participantCount: 0, registeredCount: 0, maxParticipants: 0 });
const getRaceProtestsUseCase = mkUseCase({ protests: [], drivers: [] });
const getRacePenaltiesUseCase = mkUseCase({ penalties: [], drivers: [] });
const registerForRaceUseCase = mkUseCase();
const withdrawFromRaceUseCase = mkUseCase();
const cancelRaceUseCase = mkUseCase();
@@ -28,17 +32,17 @@ describe('RaceService', () => {
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const getAllRacesPresenter = {} as any;
const getTotalRacesPresenter = {} as any;
const importRaceResultsApiPresenter = {} as any;
const raceDetailPresenter = {} as any;
const racesPageDataPresenter = {} as any;
const allRacesPageDataPresenter = {} as any;
const raceResultsDetailPresenter = {} as any;
const raceWithSOFPresenter = {} as any;
const raceProtestsPresenter = {} as any;
const racePenaltiesPresenter = {} as any;
const commandResultPresenter = {} as any;
const getAllRacesPresenter = { present: vi.fn() } as any;
const getTotalRacesPresenter = { present: vi.fn() } as any;
const importRaceResultsApiPresenter = { present: vi.fn() } as any;
const raceDetailPresenter = { present: vi.fn() } as any;
const racesPageDataPresenter = { present: vi.fn() } as any;
const allRacesPageDataPresenter = { present: vi.fn() } as any;
const raceResultsDetailPresenter = { present: vi.fn() } as any;
const raceWithSOFPresenter = { present: vi.fn() } as any;
const raceProtestsPresenter = { present: vi.fn() } as any;
const racePenaltiesPresenter = { present: vi.fn() } as any;
const commandResultPresenter = { present: vi.fn() } as any;
const service = new RaceService(
getAllRacesUseCase as any,
@@ -77,62 +81,81 @@ describe('RaceService', () => {
expect(await service.getAllRaces()).toBe(getAllRacesPresenter);
expect(getAllRacesUseCase.execute).toHaveBeenCalledWith({});
expect(getAllRacesPresenter.present).toHaveBeenCalledWith({ races: [], leagues: [] });
expect(await service.getTotalRaces()).toBe(getTotalRacesPresenter);
expect(getTotalRacesUseCase.execute).toHaveBeenCalledWith({});
expect(getTotalRacesPresenter.present).toHaveBeenCalledWith({ totalRaces: 0 });
expect(await service.importRaceResults({ raceId: 'r1', resultsFileContent: 'x' } as any)).toBe(importRaceResultsApiPresenter);
expect(importRaceResultsApiUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', resultsFileContent: 'x' });
expect(importRaceResultsApiPresenter.present).toHaveBeenCalledWith({ success: true, raceId: 'r1', driversProcessed: 0, resultsRecorded: 0, errors: [] });
expect(await service.getRaceDetail({ raceId: 'r1' } as any)).toBe(raceDetailPresenter);
expect(getRaceDetailUseCase.execute).toHaveBeenCalled();
expect(await service.getRacesPageData('l1')).toBe(racesPageDataPresenter);
expect(getRacesPageDataUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(racesPageDataPresenter.present).toHaveBeenCalledWith({ races: [] });
expect(await service.getAllRacesPageData()).toBe(allRacesPageDataPresenter);
expect(getAllRacesPageDataUseCase.execute).toHaveBeenCalledWith({});
expect(allRacesPageDataPresenter.present).toHaveBeenCalledWith({ races: [], filters: { statuses: [], leagues: [] } });
expect(await service.getRaceResultsDetail('r1')).toBe(raceResultsDetailPresenter);
expect(getRaceResultsDetailUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(raceResultsDetailPresenter.present).toHaveBeenCalledWith({ race: null, results: [], penalties: [] });
expect(await service.getRaceWithSOF('r1')).toBe(raceWithSOFPresenter);
expect(getRaceWithSOFUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(raceWithSOFPresenter.present).toHaveBeenCalledWith({ race: null, strengthOfField: 0, participantCount: 0, registeredCount: 0, maxParticipants: 0 });
expect(await service.getRaceProtests('r1')).toBe(raceProtestsPresenter);
expect(getRaceProtestsUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(raceProtestsPresenter.present).toHaveBeenCalledWith({ protests: [], drivers: [] });
expect(await service.getRacePenalties('r1')).toBe(racePenaltiesPresenter);
expect(getRacePenaltiesUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(racePenaltiesPresenter.present).toHaveBeenCalledWith({ penalties: [], drivers: [] });
expect(await service.registerForRace({ raceId: 'r1', driverId: 'd1' } as any)).toBe(commandResultPresenter);
expect(registerForRaceUseCase.execute).toHaveBeenCalled();
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.withdrawFromRace({ raceId: 'r1', driverId: 'd1' } as any)).toBe(commandResultPresenter);
expect(withdrawFromRaceUseCase.execute).toHaveBeenCalled();
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.cancelRace({ raceId: 'r1' } as any, 'admin')).toBe(commandResultPresenter);
expect(cancelRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', cancelledById: 'admin' });
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.completeRace({ raceId: 'r1' } as any)).toBe(commandResultPresenter);
expect(completeRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.reopenRace({ raceId: 'r1' } as any, 'admin')).toBe(commandResultPresenter);
expect(reopenRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', reopenedById: 'admin' });
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.fileProtest({} as any)).toBe(commandResultPresenter);
expect(fileProtestUseCase.execute).toHaveBeenCalled();
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.applyQuickPenalty({} as any)).toBe(commandResultPresenter);
expect(quickPenaltyUseCase.execute).toHaveBeenCalled();
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.applyPenalty({} as any)).toBe(commandResultPresenter);
expect(applyPenaltyUseCase.execute).toHaveBeenCalled();
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.requestProtestDefense({} as any)).toBe(commandResultPresenter);
expect(requestProtestDefenseUseCase.execute).toHaveBeenCalled();
expect(commandResultPresenter.present).toHaveBeenCalled();
expect(await service.reviewProtest({} as any)).toBe(commandResultPresenter);
expect(reviewProtestUseCase.execute).toHaveBeenCalled();
expect(commandResultPresenter.present).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
// DTOs
import { GetRaceDetailParamsDTO } from './dtos/GetRaceDetailParamsDTO';
@@ -116,55 +116,127 @@ export class RaceService {
async getAllRaces(): Promise<GetAllRacesPresenter> {
this.logger.debug('[RaceService] Fetching all races.');
await this.getAllRacesUseCase.execute({});
const result = await this.getAllRacesUseCase.execute({});
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get all races');
}
const value = result.unwrap();
this.getAllRacesPresenter.present(value);
return this.getAllRacesPresenter;
}
async getTotalRaces(): Promise<GetTotalRacesPresenter> {
this.logger.debug('[RaceService] Fetching total races count.');
await this.getTotalRacesUseCase.execute({});
const result = await this.getTotalRacesUseCase.execute({});
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get total races');
}
const value = result.unwrap();
this.getTotalRacesPresenter.present(value);
return this.getTotalRacesPresenter;
}
async importRaceResults(input: ImportRaceResultsDTO): Promise<ImportRaceResultsApiPresenter> {
this.logger.debug('Importing race results:', input);
await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent });
const result = await this.importRaceResultsApiUseCase.execute({ raceId: input.raceId, resultsFileContent: input.resultsFileContent });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to import race results');
}
const value = result.unwrap();
this.importRaceResultsApiPresenter.present(value);
return this.importRaceResultsApiPresenter;
}
async getRaceDetail(params: GetRaceDetailParamsDTO): Promise<RaceDetailPresenter> {
this.logger.debug('[RaceService] Fetching race detail:', params);
await this.getRaceDetailUseCase.execute(params);
const result = await this.getRaceDetailUseCase.execute(params);
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get race detail');
}
const value = result.unwrap();
this.raceDetailPresenter.present(value);
return this.raceDetailPresenter;
}
async getRacesPageData(leagueId: string): Promise<RacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching races page data.');
await this.getRacesPageDataUseCase.execute({ leagueId });
const result = await this.getRacesPageDataUseCase.execute({ leagueId });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get races page data');
}
const value = result.unwrap();
this.racesPageDataPresenter.present(value);
return this.racesPageDataPresenter;
}
async getAllRacesPageData(): Promise<AllRacesPageDataPresenter> {
this.logger.debug('[RaceService] Fetching all races page data.');
await this.getAllRacesPageDataUseCase.execute({});
const result = await this.getAllRacesPageDataUseCase.execute({});
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get all races page data');
}
const value = result.unwrap();
this.allRacesPageDataPresenter.present(value);
return this.allRacesPageDataPresenter;
}
async getRaceResultsDetail(raceId: string): Promise<RaceResultsDetailPresenter> {
this.logger.debug('[RaceService] Fetching race results detail:', { raceId });
await this.getRaceResultsDetailUseCase.execute({ raceId });
const result = await this.getRaceResultsDetailUseCase.execute({ raceId });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get race results detail');
}
const value = result.unwrap();
this.raceResultsDetailPresenter.present(value);
return this.raceResultsDetailPresenter;
}
async getRaceWithSOF(raceId: string): Promise<RaceWithSOFPresenter> {
this.logger.debug('[RaceService] Fetching race with SOF:', { raceId });
await this.getRaceWithSOFUseCase.execute({ raceId });
const result = await this.getRaceWithSOFUseCase.execute({ raceId });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get race with SOF');
}
const value = result.unwrap();
this.raceWithSOFPresenter.present(value);
return this.raceWithSOFPresenter;
}
async getRaceProtests(raceId: string): Promise<RaceProtestsPresenter> {
this.logger.debug('[RaceService] Fetching race protests:', { raceId });
await this.getRaceProtestsUseCase.execute({ raceId });
const result = await this.getRaceProtestsUseCase.execute({ raceId });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get race protests');
}
const value = result.unwrap();
this.raceProtestsPresenter.present(value);
return this.raceProtestsPresenter;
}
@@ -186,67 +258,145 @@ export class RaceService {
async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
await this.getRacePenaltiesUseCase.execute({ raceId });
const result = await this.getRacePenaltiesUseCase.execute({ raceId });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to get race penalties');
}
const value = result.unwrap();
this.racePenaltiesPresenter.present(value);
return this.racePenaltiesPresenter;
}
async registerForRace(params: RegisterForRaceParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Registering for race:', params);
await this.registerForRaceUseCase.execute(params);
const result = await this.registerForRaceUseCase.execute(params);
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to register for race');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async withdrawFromRace(params: WithdrawFromRaceParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Withdrawing from race:', params);
await this.withdrawFromRaceUseCase.execute(params);
const result = await this.withdrawFromRaceUseCase.execute(params);
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to withdraw from race');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async cancelRace(params: RaceActionParamsDTO, cancelledById: string): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Cancelling race:', params);
await this.cancelRaceUseCase.execute({ raceId: params.raceId, cancelledById });
const result = await this.cancelRaceUseCase.execute({ raceId: params.raceId, cancelledById });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to cancel race');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async completeRace(params: RaceActionParamsDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Completing race:', params);
await this.completeRaceUseCase.execute({ raceId: params.raceId });
const result = await this.completeRaceUseCase.execute({ raceId: params.raceId });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to complete race');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async reopenRace(params: RaceActionParamsDTO, reopenedById: string): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Re-opening race:', params);
await this.reopenRaceUseCase.execute({ raceId: params.raceId, reopenedById });
const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId, reopenedById });
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to reopen race');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async fileProtest(command: FileProtestCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Filing protest:', command);
await this.fileProtestUseCase.execute(command);
const result = await this.fileProtestUseCase.execute(command);
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to file protest');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async applyQuickPenalty(command: QuickPenaltyCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Applying quick penalty:', command);
await this.quickPenaltyUseCase.execute(command);
const result = await this.quickPenaltyUseCase.execute(command);
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to apply quick penalty');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async applyPenalty(command: ApplyPenaltyCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Applying penalty:', command);
await this.applyPenaltyUseCase.execute(command);
const result = await this.applyPenaltyUseCase.execute(command);
if (result.isErr()) {
// ApplyPenaltyUseCase errors don't have details, just code
throw new NotFoundException('Failed to apply penalty');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async requestProtestDefense(command: RequestProtestDefenseCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Requesting protest defense:', command);
await this.requestProtestDefenseUseCase.execute(command);
const result = await this.requestProtestDefenseUseCase.execute(command);
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to request protest defense');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
async reviewProtest(command: ReviewProtestCommandDTO): Promise<CommandResultPresenter> {
this.logger.debug('[RaceService] Reviewing protest:', command);
await this.reviewProtestUseCase.execute(command);
const result = await this.reviewProtestUseCase.execute(command);
if (result.isErr()) {
const error = result.unwrapErr();
throw new NotFoundException(error.details?.message ?? 'Failed to review protest');
}
this.commandResultPresenter.present();
return this.commandResultPresenter;
}
}

View File

@@ -1,18 +1,8 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetAllRacesPageDataResult,
GetAllRacesPageDataErrorCode,
} from '@core/racing/application/use-cases/GetAllRacesPageDataUseCase';
import type { GetAllRacesPageDataResult } from '@core/racing/application/use-cases/GetAllRacesPageDataUseCase';
import type { AllRacesPageDTO } from '../dtos/AllRacesPageDTO';
export type AllRacesPageDataResponseModel = AllRacesPageDTO;
export type GetAllRacesPageDataApplicationError = ApplicationErrorCode<
GetAllRacesPageDataErrorCode,
{ message: string }
>;
export class AllRacesPageDataPresenter {
private model: AllRacesPageDataResponseModel | null = null;
@@ -20,19 +10,10 @@ export class AllRacesPageDataPresenter {
this.model = null;
}
present(
result: Result<GetAllRacesPageDataResult, GetAllRacesPageDataApplicationError>,
): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get all races page data');
}
const output = result.unwrap();
present(result: GetAllRacesPageDataResult): void {
this.model = {
races: output.races,
filters: output.filters,
races: result.races,
filters: result.filters,
};
}
@@ -51,4 +32,4 @@ export class AllRacesPageDataPresenter {
get viewModel(): AllRacesPageDataResponseModel {
return this.responseModel;
}
}
}

View File

@@ -1,17 +1,9 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface CommandResultDTO {
success: boolean;
errorCode?: string;
message?: string;
}
export type CommandApplicationError = ApplicationErrorCode<
string,
{ message: string }
>;
export class CommandResultPresenter {
private model: CommandResultDTO | null = null;
@@ -19,17 +11,9 @@ export class CommandResultPresenter {
this.model = null;
}
present(result: Result<unknown, CommandApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
this.model = {
success: false,
errorCode: error.code,
message: error.details?.message,
};
return;
}
present(): void {
// For command use cases, if we get here, it was successful
// The service handles errors by throwing exceptions
this.model = { success: true };
}
@@ -63,4 +47,4 @@ export class CommandResultPresenter {
get viewModel(): CommandResultDTO {
return this.responseModel;
}
}
}

View File

@@ -1,10 +1,9 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { GetAllRacesResult } from '@core/racing/application/use-cases/GetAllRacesUseCase';
import type { AllRacesPageDTO } from '../dtos/AllRacesPageDTO';
export type GetAllRacesResponseModel = AllRacesPageDTO;
export class GetAllRacesPresenter implements UseCaseOutputPort<GetAllRacesResult> {
export class GetAllRacesPresenter {
private model: GetAllRacesResponseModel | null = null;
present(result: GetAllRacesResult): void {

View File

@@ -1,18 +1,8 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetTotalRacesResult,
GetTotalRacesErrorCode,
} from '@core/racing/application/use-cases/GetTotalRacesUseCase';
import type { GetTotalRacesResult } from '@core/racing/application/use-cases/GetTotalRacesUseCase';
import type { RaceStatsDTO } from '../dtos/RaceStatsDTO';
export type GetTotalRacesResponseModel = RaceStatsDTO;
export type GetTotalRacesApplicationError = ApplicationErrorCode<
GetTotalRacesErrorCode,
{ message: string }
>;
export class GetTotalRacesPresenter {
private model: GetTotalRacesResponseModel | null = null;
@@ -20,16 +10,9 @@ export class GetTotalRacesPresenter {
this.model = null;
}
present(result: Result<GetTotalRacesResult, GetTotalRacesApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get total races');
}
const output = result.unwrap();
present(result: GetTotalRacesResult): void {
this.model = {
totalRaces: output.totalRaces,
totalRaces: result.totalRaces,
};
}

View File

@@ -1,18 +1,8 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
ImportRaceResultsApiResult,
ImportRaceResultsApiErrorCode,
} from '@core/racing/application/use-cases/ImportRaceResultsApiUseCase';
import type { ImportRaceResultsApiResult } from '@core/racing/application/use-cases/ImportRaceResultsApiUseCase';
import { ImportRaceResultsSummaryDTO } from '../dtos/ImportRaceResultsSummaryDTO';
export type ImportRaceResultsApiResponseModel = ImportRaceResultsSummaryDTO;
export type ImportRaceResultsApiApplicationError = ApplicationErrorCode<
ImportRaceResultsApiErrorCode,
{ message: string }
>;
export class ImportRaceResultsApiPresenter {
private model: ImportRaceResultsApiResponseModel | null = null;
@@ -20,22 +10,13 @@ export class ImportRaceResultsApiPresenter {
this.model = null;
}
present(
result: Result<ImportRaceResultsApiResult, ImportRaceResultsApiApplicationError>,
): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to import race results');
}
const output = result.unwrap();
present(result: ImportRaceResultsApiResult): void {
this.model = {
success: output.success,
raceId: output.raceId,
driversProcessed: output.driversProcessed,
resultsRecorded: output.resultsRecorded,
errors: output.errors,
success: result.success,
raceId: result.raceId,
driversProcessed: result.driversProcessed,
resultsRecorded: result.resultsRecorded,
errors: result.errors,
};
}

View File

@@ -1,4 +1,3 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { GetRaceDetailResult } from '@core/racing/application/use-cases/GetRaceDetailUseCase';
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
@@ -12,7 +11,7 @@ import type { RaceDetailUserResultDTO } from '../dtos/RaceDetailUserResultDTO';
export type GetRaceDetailResponseModel = RaceDetailDTO;
export class RaceDetailPresenter implements UseCaseOutputPort<GetRaceDetailResult> {
export class RaceDetailPresenter {
private result: GetRaceDetailResult | null = null;
constructor(
@@ -118,4 +117,4 @@ export class RaceDetailPresenter implements UseCaseOutputPort<GetRaceDetailResul
const positionBonus = Math.max(0, (20 - position) * 2);
return baseChange + positionBonus;
}
}
}

View File

@@ -1,19 +1,9 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetRacePenaltiesResult,
GetRacePenaltiesErrorCode,
} from '@core/racing/application/use-cases/GetRacePenaltiesUseCase';
import type { GetRacePenaltiesResult } from '@core/racing/application/use-cases/GetRacePenaltiesUseCase';
import type { RacePenaltiesDTO } from '../dtos/RacePenaltiesDTO';
import type { RacePenaltyDTO } from '../dtos/RacePenaltyDTO';
export type GetRacePenaltiesResponseModel = RacePenaltiesDTO;
export type GetRacePenaltiesApplicationError = ApplicationErrorCode<
GetRacePenaltiesErrorCode,
{ message: string }
>;
export class RacePenaltiesPresenter {
private model: GetRacePenaltiesResponseModel | null = null;
@@ -21,15 +11,8 @@ export class RacePenaltiesPresenter {
this.model = null;
}
present(result: Result<GetRacePenaltiesResult, GetRacePenaltiesApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get race penalties');
}
const output = result.unwrap();
const penalties: RacePenaltyDTO[] = output.penalties.map(penalty => ({
present(result: GetRacePenaltiesResult): void {
const penalties: RacePenaltyDTO[] = result.penalties.map(penalty => ({
id: penalty.id,
driverId: penalty.driverId,
type: penalty.type,
@@ -41,7 +24,7 @@ export class RacePenaltiesPresenter {
} as RacePenaltyDTO));
const driverMap: Record<string, string> = {};
output.drivers.forEach(driver => {
result.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
@@ -66,4 +49,4 @@ export class RacePenaltiesPresenter {
get viewModel(): GetRacePenaltiesResponseModel {
return this.responseModel;
}
}
}

View File

@@ -1,19 +1,9 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetRaceProtestsResult,
GetRaceProtestsErrorCode,
} from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import type { GetRaceProtestsResult } from '@core/racing/application/use-cases/GetRaceProtestsUseCase';
import type { RaceProtestsDTO } from '../dtos/RaceProtestsDTO';
import type { RaceProtestDTO } from '../dtos/RaceProtestDTO';
export type GetRaceProtestsResponseModel = RaceProtestsDTO;
export type GetRaceProtestsApplicationError = ApplicationErrorCode<
GetRaceProtestsErrorCode,
{ message: string }
>;
export class RaceProtestsPresenter {
private model: GetRaceProtestsResponseModel | null = null;
@@ -21,15 +11,8 @@ export class RaceProtestsPresenter {
this.model = null;
}
present(result: Result<GetRaceProtestsResult, GetRaceProtestsApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get race protests');
}
const output = result.unwrap();
const protests: RaceProtestDTO[] = output.protests.map(protest => ({
present(result: GetRaceProtestsResult): void {
const protests: RaceProtestDTO[] = result.protests.map(protest => ({
id: protest.id,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
@@ -42,7 +25,7 @@ export class RaceProtestsPresenter {
} as RaceProtestDTO));
const driverMap: Record<string, string> = {};
output.drivers.forEach(driver => {
result.drivers.forEach(driver => {
driverMap[driver.id] = driver.name.toString();
});
@@ -67,4 +50,4 @@ export class RaceProtestsPresenter {
get viewModel(): GetRaceProtestsResponseModel {
return this.responseModel;
}
}
}

View File

@@ -1,18 +1,8 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetRaceWithSOFResult,
GetRaceWithSOFErrorCode,
} from '@core/racing/application/use-cases/GetRaceWithSOFUseCase';
import type { GetRaceWithSOFResult } from '@core/racing/application/use-cases/GetRaceWithSOFUseCase';
import type { RaceWithSOFDTO } from '../dtos/RaceWithSOFDTO';
export type GetRaceWithSOFResponseModel = RaceWithSOFDTO;
export type GetRaceWithSOFApplicationError = ApplicationErrorCode<
GetRaceWithSOFErrorCode,
{ message: string }
>;
export class RaceWithSOFPresenter {
private model: GetRaceWithSOFResponseModel | null = null;
@@ -20,27 +10,11 @@ export class RaceWithSOFPresenter {
this.model = null;
}
present(result: Result<GetRaceWithSOFResult, GetRaceWithSOFApplicationError>): void {
if (result.isErr()) {
const error = result.unwrapErr();
if (error.code === 'RACE_NOT_FOUND') {
this.model = {
id: '',
track: '',
strengthOfField: null,
} as RaceWithSOFDTO;
return;
}
throw new Error(error.details?.message ?? 'Failed to get race with SOF');
}
const output = result.unwrap();
present(result: GetRaceWithSOFResult): void {
this.model = {
id: output.race.id,
track: output.race.track,
strengthOfField: output.strengthOfField,
id: result.race.id,
track: result.race.track,
strengthOfField: result.strengthOfField,
} as RaceWithSOFDTO;
}
@@ -59,4 +33,4 @@ export class RaceWithSOFPresenter {
get viewModel(): GetRaceWithSOFResponseModel {
return this.responseModel;
}
}
}

View File

@@ -1,19 +1,9 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type {
GetRacesPageDataResult,
GetRacesPageDataErrorCode,
} from '@core/racing/application/use-cases/GetRacesPageDataUseCase';
import type { GetRacesPageDataResult } from '@core/racing/application/use-cases/GetRacesPageDataUseCase';
import type { RacesPageDataDTO } from '../dtos/RacesPageDataDTO';
import type { RacesPageDataRaceDTO } from '../dtos/RacesPageDataRaceDTO';
export type GetRacesPageDataResponseModel = RacesPageDataDTO;
export type GetRacesPageDataApplicationError = ApplicationErrorCode<
GetRacesPageDataErrorCode,
{ message: string }
>;
export class RacesPageDataPresenter {
private model: GetRacesPageDataResponseModel | null = null;
@@ -21,17 +11,8 @@ export class RacesPageDataPresenter {
this.model = null;
}
present(
result: Result<GetRacesPageDataResult, GetRacesPageDataApplicationError>,
): void {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get races page data');
}
const output = result.unwrap();
const races: RacesPageDataRaceDTO[] = output.races.map(({ race, leagueName }) => ({
present(result: GetRacesPageDataResult): void {
const races: RacesPageDataRaceDTO[] = result.races.map(({ race, leagueName }) => ({
id: race.id,
track: race.track,
car: race.car,
@@ -63,4 +44,4 @@ export class RacesPageDataPresenter {
get viewModel(): GetRacesPageDataResponseModel {
return this.responseModel;
}
}
}

View File

@@ -28,7 +28,7 @@ import { SponsorRaceDTO } from './dtos/RaceDTO';
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
import type { AcceptSponsorshipRequestResultViewModel } from './presenters/AcceptSponsorshipRequestPresenter';
import { AcceptSponsorshipRequestResultViewModel } from './presenters/AcceptSponsorshipRequestPresenter';
import type { RejectSponsorshipRequestResult } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
@ApiTags('sponsors')
@@ -166,7 +166,7 @@ export class SponsorController {
async acceptSponsorshipRequest(
@Param('requestId') requestId: string,
@Body() input: AcceptSponsorshipRequestInputDTO,
): Promise<AcceptSponsorshipRequestResultViewModel | null> {
): Promise<AcceptSponsorshipRequestResultViewModel> {
return await this.sponsorService.acceptSponsorshipRequest(
requestId,
input.respondedBy,
@@ -185,7 +185,7 @@ export class SponsorController {
async rejectSponsorshipRequest(
@Param('requestId') requestId: string,
@Body() input: RejectSponsorshipRequestInputDTO,
): Promise<RejectSponsorshipRequestResult | null> {
): Promise<RejectSponsorshipRequestResult> {
return await this.sponsorService.rejectSponsorshipRequest(
requestId,
input.respondedBy,
@@ -219,9 +219,13 @@ export class SponsorController {
description: 'Available leagues',
type: [AvailableLeagueDTO],
})
async getAvailableLeagues(): Promise<AvailableLeagueDTO[] | null> {
async getAvailableLeagues(): Promise<AvailableLeagueDTO[]> {
const presenter = await this.sponsorService.getAvailableLeagues();
return presenter.viewModel;
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('Available leagues not found');
}
return viewModel;
}
@Get('leagues/:leagueId/detail')
@@ -236,9 +240,13 @@ export class SponsorController {
league: LeagueDetailDTO;
drivers: SponsorDriverDTO[];
races: SponsorRaceDTO[];
} | null> {
}> {
const presenter = await this.sponsorService.getLeagueDetail(leagueId);
return presenter.viewModel;
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('League detail not found');
}
return viewModel;
}
@Get('settings/:sponsorId')
@@ -253,9 +261,13 @@ export class SponsorController {
profile: SponsorProfileDTO;
notifications: NotificationSettingsDTO;
privacy: PrivacySettingsDTO;
} | null> {
}> {
const presenter = await this.sponsorService.getSponsorSettings(sponsorId);
return presenter.viewModel;
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('Sponsor settings not found');
}
return viewModel;
}
@Put('settings/:sponsorId')
@@ -273,8 +285,12 @@ export class SponsorController {
notifications?: Partial<NotificationSettingsDTO>;
privacy?: Partial<PrivacySettingsDTO>;
},
): Promise<{ success: boolean; errorCode?: string; message?: string } | null> {
): Promise<{ success: boolean; errorCode?: string; message?: string }> {
const presenter = await this.sponsorService.updateSponsorSettings(sponsorId, input);
return presenter.viewModel;
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('Update failed');
}
return viewModel;
}
}
}

View File

@@ -5,8 +5,6 @@ import { SponsorService } from './SponsorService';
import type { NotificationService } from '@core/notifications/application/ports/NotificationService';
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
// Remove the missing import
// import { IPaymentGateway } from '@core/payments/domain/ports/IPaymentGateway';
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
@@ -16,7 +14,7 @@ import { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/I
import { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository';
import { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ISponsorshipPricingRepository';
import { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { GetSponsorBillingUseCase } from '@core/payments/application/use-cases/GetSponsorBillingUseCase';
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
@@ -24,7 +22,6 @@ import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateS
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
import { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
@@ -35,18 +32,6 @@ import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository';
import { InMemoryWalletRepository } from '@adapters/payments/persistence/inmemory/InMemoryWalletRepository';
// Import presenters
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
import { GetSponsorPresenter } from './presenters/GetSponsorPresenter';
import { GetPendingSponsorshipRequestsPresenter } from './presenters/GetPendingSponsorshipRequestsPresenter';
import { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter';
import { RejectSponsorshipRequestPresenter } from './presenters/RejectSponsorshipRequestPresenter';
import { SponsorBillingPresenter } from './presenters/SponsorBillingPresenter';
// Define injection tokens
export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository';
export const SEASON_SPONSORSHIP_REPOSITORY_TOKEN = 'ISeasonSponsorshipRepository';
@@ -62,20 +47,7 @@ export const LEAGUE_WALLET_REPOSITORY_TOKEN = 'ILeagueWalletRepository';
export const NOTIFICATION_SERVICE_TOKEN = 'INotificationService';
export const LOGGER_TOKEN = 'Logger';
// Presenter tokens
export const GET_ENTITY_SPONSORSHIP_PRICING_PRESENTER_TOKEN = 'GetEntitySponsorshipPricingPresenter';
export const GET_SPONSORS_PRESENTER_TOKEN = 'GetSponsorsPresenter';
export const CREATE_SPONSOR_PRESENTER_TOKEN = 'CreateSponsorPresenter';
export const GET_SPONSOR_DASHBOARD_PRESENTER_TOKEN = 'GetSponsorDashboardPresenter';
export const GET_SPONSOR_SPONSORSHIPS_PRESENTER_TOKEN = 'GetSponsorSponsorshipsPresenter';
export const GET_SPONSOR_PRESENTER_TOKEN = 'GetSponsorPresenter';
export const GET_PENDING_SPONSORSHIP_REQUESTS_PRESENTER_TOKEN = 'GetPendingSponsorshipRequestsPresenter';
export const ACCEPT_SPONSORSHIP_REQUEST_PRESENTER_TOKEN = 'AcceptSponsorshipRequestPresenter';
export const REJECT_SPONSORSHIP_REQUEST_PRESENTER_TOKEN = 'RejectSponsorshipRequestPresenter';
export const GET_SPONSOR_BILLING_PRESENTER_TOKEN = 'SponsorBillingPresenter';
// Use case / application service tokens
export const GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN = 'GetSponsorshipPricingUseCase';
export const GET_SPONSORS_USE_CASE_TOKEN = 'GetSponsorsUseCase';
export const CREATE_SPONSOR_USE_CASE_TOKEN = 'CreateSponsorUseCase';
export const GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN = 'GetSponsorDashboardUseCase';
@@ -87,19 +59,6 @@ export const ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'AcceptSponsorshipReque
export const REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'RejectSponsorshipRequestUseCase';
export const GET_SPONSOR_BILLING_USE_CASE_TOKEN = 'GetSponsorBillingUseCase';
// Output port tokens
export const GET_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN = 'GetSponsorshipPricingOutputPort_TOKEN';
export const GET_SPONSORS_OUTPUT_PORT_TOKEN = 'GetSponsorsOutputPort_TOKEN';
export const CREATE_SPONSOR_OUTPUT_PORT_TOKEN = 'CreateSponsorOutputPort_TOKEN';
export const GET_SPONSOR_DASHBOARD_OUTPUT_PORT_TOKEN = 'GetSponsorDashboardOutputPort_TOKEN';
export const GET_SPONSOR_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSponsorSponsorshipsOutputPort_TOKEN';
export const GET_ENTITY_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN = 'GetEntitySponsorshipPricingOutputPort_TOKEN';
export const GET_SPONSOR_OUTPUT_PORT_TOKEN = 'GetSponsorOutputPort_TOKEN';
export const GET_PENDING_SPONSORSHIP_REQUESTS_OUTPUT_PORT_TOKEN = 'GetPendingSponsorshipRequestsOutputPort_TOKEN';
export const ACCEPT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN = 'AcceptSponsorshipRequestOutputPort_TOKEN';
export const REJECT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN = 'RejectSponsorshipRequestOutputPort_TOKEN';
export const GET_SPONSOR_BILLING_OUTPUT_PORT_TOKEN = 'GetSponsorBillingOutputPort_TOKEN';
export const SponsorProviders: Provider[] = [
SponsorService,
// Repositories (payments repos are local to this module; racing repos come from InMemoryRacingPersistenceModule)
@@ -126,77 +85,16 @@ export const SponsorProviders: Provider[] = [
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Presenters
GetEntitySponsorshipPricingPresenter,
GetSponsorsPresenter,
CreateSponsorPresenter,
GetSponsorDashboardPresenter,
GetSponsorSponsorshipsPresenter,
GetSponsorPresenter,
GetPendingSponsorshipRequestsPresenter,
AcceptSponsorshipRequestPresenter,
RejectSponsorshipRequestPresenter,
SponsorBillingPresenter,
// Output ports
{
provide: GET_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN,
useExisting: GetEntitySponsorshipPricingPresenter,
},
{
provide: GET_ENTITY_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN,
useExisting: GetEntitySponsorshipPricingPresenter,
},
{
provide: GET_SPONSORS_OUTPUT_PORT_TOKEN,
useExisting: GetSponsorsPresenter,
},
{
provide: CREATE_SPONSOR_OUTPUT_PORT_TOKEN,
useExisting: CreateSponsorPresenter,
},
{
provide: GET_SPONSOR_DASHBOARD_OUTPUT_PORT_TOKEN,
useExisting: GetSponsorDashboardPresenter,
},
{
provide: GET_SPONSOR_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
useExisting: GetSponsorSponsorshipsPresenter,
},
{
provide: GET_SPONSOR_OUTPUT_PORT_TOKEN,
useExisting: GetSponsorPresenter,
},
{
provide: GET_PENDING_SPONSORSHIP_REQUESTS_OUTPUT_PORT_TOKEN,
useExisting: GetPendingSponsorshipRequestsPresenter,
},
{
provide: ACCEPT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN,
useExisting: AcceptSponsorshipRequestPresenter,
},
{
provide: REJECT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN,
useExisting: RejectSponsorshipRequestPresenter,
},
{
provide: GET_SPONSOR_BILLING_OUTPUT_PORT_TOKEN,
useExisting: SponsorBillingPresenter,
},
// Use cases
{
provide: GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
useFactory: (output: UseCaseOutputPort<unknown>) => new GetSponsorshipPricingUseCase(output),
inject: [GET_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN],
},
{
provide: GET_SPONSORS_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository, output: UseCaseOutputPort<unknown>) => new GetSponsorsUseCase(sponsorRepo, output),
inject: [SPONSOR_REPOSITORY_TOKEN, GET_SPONSORS_OUTPUT_PORT_TOKEN],
useFactory: (sponsorRepo: ISponsorRepository) => new GetSponsorsUseCase(sponsorRepo),
inject: [SPONSOR_REPOSITORY_TOKEN],
},
{
provide: CREATE_SPONSOR_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository, logger: Logger, output: UseCaseOutputPort<unknown>) => new CreateSponsorUseCase(sponsorRepo, logger, output),
inject: [SPONSOR_REPOSITORY_TOKEN, LOGGER_TOKEN, CREATE_SPONSOR_OUTPUT_PORT_TOKEN],
useFactory: (sponsorRepo: ISponsorRepository, logger: Logger) => new CreateSponsorUseCase(sponsorRepo, logger),
inject: [SPONSOR_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
{
provide: GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN,
@@ -207,8 +105,7 @@ export const SponsorProviders: Provider[] = [
leagueRepo: ILeagueRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
raceRepo: IRaceRepository,
output: UseCaseOutputPort<unknown>,
) => new GetSponsorDashboardUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo, output),
) => new GetSponsorDashboardUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo),
inject: [
SPONSOR_REPOSITORY_TOKEN,
SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
@@ -216,7 +113,6 @@ export const SponsorProviders: Provider[] = [
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
GET_SPONSOR_DASHBOARD_OUTPUT_PORT_TOKEN,
],
},
{
@@ -228,8 +124,7 @@ export const SponsorProviders: Provider[] = [
leagueRepo: ILeagueRepository,
leagueMembershipRepo: ILeagueMembershipRepository,
raceRepo: IRaceRepository,
output: UseCaseOutputPort<unknown>,
) => new GetSponsorSponsorshipsUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo, output),
) => new GetSponsorSponsorshipsUseCase(sponsorRepo, seasonSponsorshipRepo, seasonRepo, leagueRepo, leagueMembershipRepo, raceRepo),
inject: [
SPONSOR_REPOSITORY_TOKEN,
SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
@@ -237,7 +132,6 @@ export const SponsorProviders: Provider[] = [
LEAGUE_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
RACE_REPOSITORY_TOKEN,
GET_SPONSOR_SPONSORSHIPS_OUTPUT_PORT_TOKEN,
],
},
{
@@ -255,27 +149,24 @@ export const SponsorProviders: Provider[] = [
useFactory: (
sponsorshipPricingRepo: ISponsorshipPricingRepository,
logger: Logger,
output: UseCaseOutputPort<unknown>,
) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, logger, output),
) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, logger),
inject: [
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_ENTITY_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN,
],
},
{
provide: GET_SPONSOR_USE_CASE_TOKEN,
useFactory: (sponsorRepo: ISponsorRepository, output: UseCaseOutputPort<unknown>) => new GetSponsorUseCase(sponsorRepo, output),
inject: [SPONSOR_REPOSITORY_TOKEN, GET_SPONSOR_OUTPUT_PORT_TOKEN],
useFactory: (sponsorRepo: ISponsorRepository) => new GetSponsorUseCase(sponsorRepo),
inject: [SPONSOR_REPOSITORY_TOKEN],
},
{
provide: GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN,
useFactory: (
sponsorshipRequestRepo: ISponsorshipRequestRepository,
sponsorRepo: ISponsorRepository,
output: UseCaseOutputPort<unknown>,
) => new GetPendingSponsorshipRequestsUseCase(sponsorshipRequestRepo, sponsorRepo, output),
inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN, GET_PENDING_SPONSORSHIP_REQUESTS_OUTPUT_PORT_TOKEN],
) => new GetPendingSponsorshipRequestsUseCase(sponsorshipRequestRepo, sponsorRepo),
inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN],
},
{
provide: ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
@@ -287,7 +178,6 @@ export const SponsorProviders: Provider[] = [
walletRepository: IWalletRepository,
leagueWalletRepository: ILeagueWalletRepository,
logger: Logger,
output: UseCaseOutputPort<unknown>,
) => {
// Create a mock payment processor function
const paymentProcessor = async (input: unknown) => {
@@ -303,8 +193,7 @@ export const SponsorProviders: Provider[] = [
paymentProcessor,
walletRepository,
leagueWalletRepository,
logger,
output
logger
);
},
inject: [
@@ -315,7 +204,6 @@ export const SponsorProviders: Provider[] = [
WALLET_REPOSITORY_TOKEN,
LEAGUE_WALLET_REPOSITORY_TOKEN,
LOGGER_TOKEN,
ACCEPT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN,
],
},
{
@@ -323,8 +211,7 @@ export const SponsorProviders: Provider[] = [
useFactory: (
sponsorshipRequestRepo: ISponsorshipRequestRepository,
logger: Logger,
output: UseCaseOutputPort<unknown>,
) => new RejectSponsorshipRequestUseCase(sponsorshipRequestRepo, logger, output),
inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, LOGGER_TOKEN, REJECT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN],
) => new RejectSponsorshipRequestUseCase(sponsorshipRequestRepo, logger),
inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -3,7 +3,7 @@ import type { AcceptSponsorshipRequestUseCase } from '@core/racing/application/u
import type { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import type { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
import type { GetSponsorDashboardInput, GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import type { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import type { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
import type { GetSponsorSponsorshipsInput, GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import type { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import type { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
@@ -14,21 +14,11 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import type { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO';
import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
import { Money } from '@core/racing/domain/value-objects/Money';
import { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
import { GetPendingSponsorshipRequestsPresenter } from './presenters/GetPendingSponsorshipRequestsPresenter';
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
import { GetSponsorPresenter } from './presenters/GetSponsorPresenter';
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
import { RejectSponsorshipRequestPresenter } from './presenters/RejectSponsorshipRequestPresenter';
import { SponsorBillingPresenter } from './presenters/SponsorBillingPresenter';
import { SponsorService } from './SponsorService';
describe('SponsorService', () => {
let service: SponsorService;
let getSponsorshipPricingUseCase: { execute: Mock };
let getEntitySponsorshipPricingUseCase: { execute: Mock };
let getSponsorsUseCase: { execute: Mock };
let createSponsorUseCase: { execute: Mock };
let getSponsorDashboardUseCase: { execute: Mock };
@@ -40,20 +30,8 @@ describe('SponsorService', () => {
let getSponsorBillingUseCase: { execute: Mock };
let logger: Logger;
// Presenters
let getEntitySponsorshipPricingPresenter: GetEntitySponsorshipPricingPresenter;
let getSponsorsPresenter: GetSponsorsPresenter;
let createSponsorPresenter: CreateSponsorPresenter;
let getSponsorDashboardPresenter: GetSponsorDashboardPresenter;
let getSponsorSponsorshipsPresenter: GetSponsorSponsorshipsPresenter;
let getSponsorPresenter: GetSponsorPresenter;
let getPendingSponsorshipRequestsPresenter: GetPendingSponsorshipRequestsPresenter;
let acceptSponsorshipRequestPresenter: AcceptSponsorshipRequestPresenter;
let rejectSponsorshipRequestPresenter: RejectSponsorshipRequestPresenter;
let sponsorBillingPresenter: SponsorBillingPresenter;
beforeEach(() => {
getSponsorshipPricingUseCase = { execute: vi.fn() };
getEntitySponsorshipPricingUseCase = { execute: vi.fn() };
getSponsorsUseCase = { execute: vi.fn() };
createSponsorUseCase = { execute: vi.fn() };
getSponsorDashboardUseCase = { execute: vi.fn() };
@@ -70,20 +48,8 @@ describe('SponsorService', () => {
error: vi.fn(),
} as unknown as Logger;
// Initialize presenters
getEntitySponsorshipPricingPresenter = new GetEntitySponsorshipPricingPresenter();
getSponsorsPresenter = new GetSponsorsPresenter();
createSponsorPresenter = new CreateSponsorPresenter();
getSponsorDashboardPresenter = new GetSponsorDashboardPresenter();
getSponsorSponsorshipsPresenter = new GetSponsorSponsorshipsPresenter();
getSponsorPresenter = new GetSponsorPresenter();
getPendingSponsorshipRequestsPresenter = new GetPendingSponsorshipRequestsPresenter();
acceptSponsorshipRequestPresenter = new AcceptSponsorshipRequestPresenter();
rejectSponsorshipRequestPresenter = new RejectSponsorshipRequestPresenter();
sponsorBillingPresenter = new SponsorBillingPresenter();
service = new SponsorService(
getSponsorshipPricingUseCase as unknown as GetSponsorshipPricingUseCase,
getEntitySponsorshipPricingUseCase as unknown as GetEntitySponsorshipPricingUseCase,
getSponsorsUseCase as unknown as GetSponsorsUseCase,
createSponsorUseCase as unknown as CreateSponsorUseCase,
getSponsorDashboardUseCase as unknown as GetSponsorDashboardUseCase,
@@ -94,31 +60,19 @@ describe('SponsorService', () => {
rejectSponsorshipRequestUseCase as unknown as RejectSponsorshipRequestUseCase,
getSponsorBillingUseCase as unknown as GetSponsorBillingUseCase,
logger,
getEntitySponsorshipPricingPresenter,
getSponsorsPresenter,
createSponsorPresenter,
getSponsorDashboardPresenter,
getSponsorSponsorshipsPresenter,
getSponsorPresenter,
getPendingSponsorshipRequestsPresenter,
acceptSponsorshipRequestPresenter,
rejectSponsorshipRequestPresenter,
sponsorBillingPresenter,
);
});
describe('getEntitySponsorshipPricing', () => {
it('returns pricing data on success', async () => {
const outputPort = {
const output = {
entityType: 'season',
entityId: 'season-1',
acceptingApplications: true,
tiers: [{ name: 'Gold', price: { amount: 500, currency: 'USD' }, benefits: ['Main slot'] }],
};
getSponsorshipPricingUseCase.execute.mockImplementation(async () => {
getEntitySponsorshipPricingPresenter.present(outputPort as any);
return Result.ok(undefined);
});
getEntitySponsorshipPricingUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.getEntitySponsorshipPricing();
@@ -130,7 +84,7 @@ describe('SponsorService', () => {
});
it('returns empty pricing on error', async () => {
getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
getEntitySponsorshipPricingUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
const result = await service.getEntitySponsorshipPricing();
@@ -153,10 +107,7 @@ describe('SponsorService', () => {
}),
];
getSponsorsUseCase.execute.mockImplementation(async () => {
getSponsorsPresenter.present(sponsors);
return Result.ok(undefined);
});
getSponsorsUseCase.execute.mockResolvedValue(Result.ok({ sponsors }));
const result = await service.getSponsors();
@@ -191,10 +142,7 @@ describe('SponsorService', () => {
createdAt: new Date('2024-01-01T00:00:00Z'),
});
createSponsorUseCase.execute.mockImplementation(async () => {
createSponsorPresenter.present(sponsor);
return Result.ok(undefined);
});
createSponsorUseCase.execute.mockResolvedValue(Result.ok({ sponsor }));
const result = await service.createSponsor(input);
@@ -235,7 +183,7 @@ describe('SponsorService', () => {
describe('getSponsorDashboard', () => {
it('returns dashboard on success', async () => {
const params: GetSponsorDashboardInput = { sponsorId: 's1' };
const outputPort = {
const output = {
sponsorId: 's1',
sponsorName: 'S1',
metrics: {
@@ -254,19 +202,25 @@ describe('SponsorService', () => {
totalInvestment: Money.create(0, 'USD'),
costPerThousandViews: 0,
},
sponsorships: {
leagues: [],
teams: [],
drivers: [],
races: [],
platform: [],
},
recentActivity: [],
upcomingRenewals: [],
};
getSponsorDashboardUseCase.execute.mockImplementation(async () => {
getSponsorDashboardPresenter.present(outputPort as any);
return Result.ok(undefined);
});
getSponsorDashboardUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.getSponsorDashboard(params);
expect(result).toEqual({
sponsorId: 's1',
sponsorName: 'S1',
metrics: outputPort.metrics,
metrics: output.metrics,
sponsoredLeagues: [],
investment: {
activeSponsorships: 0,
@@ -296,7 +250,7 @@ describe('SponsorService', () => {
describe('getSponsorSponsorships', () => {
it('returns sponsorships on success', async () => {
const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' };
const outputPort = {
const output = {
sponsor: Sponsor.create({
id: 's1',
name: 'S1',
@@ -311,10 +265,7 @@ describe('SponsorService', () => {
},
};
getSponsorSponsorshipsUseCase.execute.mockImplementation(async () => {
getSponsorSponsorshipsPresenter.present(outputPort as any);
return Result.ok(undefined);
});
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.getSponsorSponsorships(params);
@@ -345,16 +296,25 @@ describe('SponsorService', () => {
describe('getSponsor', () => {
it('returns sponsor when found', async () => {
const sponsorId = 's1';
const output = { sponsor: { id: sponsorId, name: 'S1' } };
getSponsorUseCase.execute.mockImplementation(async () => {
getSponsorPresenter.present(output);
return Result.ok(undefined);
const sponsor = Sponsor.create({
id: sponsorId,
name: 'S1',
contactEmail: 's1@example.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
});
getSponsorUseCase.execute.mockResolvedValue(Result.ok({ sponsor }));
const result = await service.getSponsor(sponsorId);
expect(result).toEqual(output);
expect(result).toEqual({
sponsor: {
id: sponsorId,
name: 'S1',
logoUrl: undefined,
websiteUrl: undefined,
},
});
});
it('throws when not found', async () => {
@@ -375,21 +335,18 @@ describe('SponsorService', () => {
describe('getPendingSponsorshipRequests', () => {
it('returns requests on success', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
const outputPort = {
const output = {
entityType: 'season',
entityId: 'season-1',
requests: [],
totalCount: 0,
};
getPendingSponsorshipRequestsUseCase.execute.mockImplementation(async () => {
getPendingSponsorshipRequestsPresenter.present(outputPort as any);
return Result.ok(undefined);
});
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.getPendingSponsorshipRequests(params);
expect(result).toEqual(outputPort);
expect(result).toEqual(output);
});
it('returns empty result on error', async () => {
@@ -405,20 +362,13 @@ describe('SponsorService', () => {
totalCount: 0,
});
});
it('throws when presenter viewModel is missing on success', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(undefined));
await expect(service.getPendingSponsorshipRequests(params)).rejects.toThrow('Pending sponsorship requests not found');
});
});
describe('SponsorshipRequest', () => {
it('returns accept result on success', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
const outputPort = {
const output = {
requestId,
sponsorshipId: 'sp1',
status: 'accepted' as const,
@@ -427,14 +377,11 @@ describe('SponsorService', () => {
netAmount: 90,
};
acceptSponsorshipRequestUseCase.execute.mockImplementation(async () => {
acceptSponsorshipRequestPresenter.present(outputPort as any);
return Result.ok(undefined);
});
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
expect(result).toEqual(outputPort);
expect(result).toEqual(output);
});
it('throws on error', async () => {
@@ -448,16 +395,6 @@ describe('SponsorService', () => {
'Accept sponsorship request failed',
);
});
it('throws when presenter viewModel is missing on success', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(undefined));
await expect(service.acceptSponsorshipRequest(requestId, respondedBy)).rejects.toThrow(
'Accept sponsorship request failed',
);
});
});
describe('rejectSponsorshipRequest', () => {
@@ -472,10 +409,7 @@ describe('SponsorService', () => {
rejectionReason: reason,
};
rejectSponsorshipRequestUseCase.execute.mockImplementation(async () => {
rejectSponsorshipRequestPresenter.present(output as any);
return Result.ok(undefined);
});
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(output));
const result = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
@@ -485,19 +419,18 @@ describe('SponsorService', () => {
it('passes no reason when reason is undefined', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
const output = {
requestId,
status: 'rejected' as const,
respondedAt: new Date(),
rejectionReason: '',
};
rejectSponsorshipRequestUseCase.execute.mockImplementation(async (input: any) => {
expect(input).toEqual({ requestId, respondedBy });
rejectSponsorshipRequestPresenter.present({
requestId,
status: 'rejected' as const,
respondedAt: new Date(),
rejectionReason: '',
} as any);
return Result.ok(undefined);
});
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(output));
await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).resolves.toMatchObject({
const result = await service.rejectSponsorshipRequest(requestId, respondedBy);
expect(result).toMatchObject({
requestId,
status: 'rejected',
});
@@ -514,16 +447,6 @@ describe('SponsorService', () => {
'Reject sponsorship request failed',
);
});
it('throws when presenter viewModel is missing on success', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(undefined));
await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).rejects.toThrow(
'Reject sponsorship request failed',
);
});
});
describe('getSponsorBilling', () => {

View File

@@ -21,7 +21,7 @@ import { InvoiceDTO } from './dtos/InvoiceDTO';
import { BillingStatsDTO } from './dtos/BillingStatsDTO';
// Use cases
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
import { GetSponsorsUseCase } from '@core/racing/application/use-cases/GetSponsorsUseCase';
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
import { GetSponsorDashboardUseCase } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
@@ -37,26 +37,8 @@ import { GetSponsorBillingUseCase } from '@core/payments/application/use-cases/G
import type { SponsorableEntityType } from '@core/racing/domain/entities/SponsorshipRequest';
import type { Logger } from '@core/shared/application';
// Presenters
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
import { GetSponsorPresenter } from './presenters/GetSponsorPresenter';
import { GetPendingSponsorshipRequestsPresenter } from './presenters/GetPendingSponsorshipRequestsPresenter';
import { AcceptSponsorshipRequestPresenter, AcceptSponsorshipRequestResultViewModel } from './presenters/AcceptSponsorshipRequestPresenter';
import { RejectSponsorshipRequestPresenter } from './presenters/RejectSponsorshipRequestPresenter';
import { SponsorBillingPresenter } from './presenters/SponsorBillingPresenter';
import { AvailableLeaguesPresenter } from './presenters/AvailableLeaguesPresenter';
import { LeagueDetailPresenter } from './presenters/LeagueDetailPresenter';
import { SponsorSettingsPresenter } from './presenters/SponsorSettingsPresenter';
import { SponsorSettingsUpdatePresenter } from './presenters/SponsorSettingsUpdatePresenter';
import type { RejectSponsorshipRequestResult } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
// Tokens
import {
GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
GET_SPONSORS_USE_CASE_TOKEN,
CREATE_SPONSOR_USE_CASE_TOKEN,
GET_SPONSOR_DASHBOARD_USE_CASE_TOKEN,
@@ -66,14 +48,33 @@ import {
ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
GET_SPONSOR_BILLING_USE_CASE_TOKEN,
GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
LOGGER_TOKEN,
} from './SponsorTokens';
// Presenters (for view model transformation only)
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
import { GetSponsorsPresenter } from './presenters/GetSponsorsPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetSponsorDashboardPresenter } from './presenters/GetSponsorDashboardPresenter';
import { GetSponsorSponsorshipsPresenter } from './presenters/GetSponsorSponsorshipsPresenter';
import { GetSponsorPresenter } from './presenters/GetSponsorPresenter';
import { GetPendingSponsorshipRequestsPresenter } from './presenters/GetPendingSponsorshipRequestsPresenter';
import { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter';
import { RejectSponsorshipRequestPresenter } from './presenters/RejectSponsorshipRequestPresenter';
import { SponsorBillingPresenter } from './presenters/SponsorBillingPresenter';
import { AvailableLeaguesPresenter } from './presenters/AvailableLeaguesPresenter';
import { LeagueDetailPresenter } from './presenters/LeagueDetailPresenter';
import { SponsorSettingsPresenter } from './presenters/SponsorSettingsPresenter';
import { SponsorSettingsUpdatePresenter } from './presenters/SponsorSettingsUpdatePresenter';
import type { RejectSponsorshipRequestResult } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
import type { AcceptSponsorshipRequestResultViewModel } from './presenters/AcceptSponsorshipRequestPresenter';
@Injectable()
export class SponsorService {
constructor(
@Inject(GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN)
private readonly getSponsorshipPricingUseCase: GetSponsorshipPricingUseCase,
@Inject(GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN)
private readonly getEntitySponsorshipPricingUseCase: GetEntitySponsorshipPricingUseCase,
@Inject(GET_SPONSORS_USE_CASE_TOKEN)
private readonly getSponsorsUseCase: GetSponsorsUseCase,
@Inject(CREATE_SPONSOR_USE_CASE_TOKEN)
@@ -94,22 +95,14 @@ export class SponsorService {
private readonly getSponsorBillingUseCase: GetSponsorBillingUseCase,
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
// Injected presenters
private readonly getEntitySponsorshipPricingPresenter: GetEntitySponsorshipPricingPresenter,
private readonly getSponsorsPresenter: GetSponsorsPresenter,
private readonly createSponsorPresenter: CreateSponsorPresenter,
private readonly getSponsorDashboardPresenter: GetSponsorDashboardPresenter,
private readonly getSponsorSponsorshipsPresenter: GetSponsorSponsorshipsPresenter,
private readonly getSponsorPresenter: GetSponsorPresenter,
private readonly getPendingSponsorshipRequestsPresenter: GetPendingSponsorshipRequestsPresenter,
private readonly acceptSponsorshipRequestPresenter: AcceptSponsorshipRequestPresenter,
private readonly rejectSponsorshipRequestPresenter: RejectSponsorshipRequestPresenter,
private readonly sponsorBillingPresenter: SponsorBillingPresenter,
) {}
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
const result = await this.getSponsorshipPricingUseCase.execute({});
const result = await this.getEntitySponsorshipPricingUseCase.execute({
entityType: 'season',
entityId: 'default',
});
if (result.isErr()) {
return {
@@ -119,18 +112,22 @@ export class SponsorService {
};
}
return this.getEntitySponsorshipPricingPresenter.viewModel;
const presenter = new GetEntitySponsorshipPricingPresenter();
presenter.present(result.value);
return presenter.viewModel;
}
async getSponsors(): Promise<GetSponsorsOutputDTO> {
this.logger.debug('[SponsorService] Fetching sponsors.');
const result = await this.getSponsorsUseCase.execute();
const result = await this.getSponsorsUseCase.execute({});
if (result.isErr()) {
return { sponsors: [] };
}
return this.getSponsorsPresenter.responseModel;
const presenter = new GetSponsorsPresenter();
presenter.present(result.unwrap().sponsors);
return presenter.responseModel;
}
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
@@ -142,7 +139,14 @@ export class SponsorService {
throw new Error(error.details?.message ?? error.message ?? 'Failed to create sponsor');
}
return this.createSponsorPresenter.viewModel;
const resultValue = result.value;
if (!resultValue) {
throw new Error('Create sponsor failed');
}
const presenter = new CreateSponsorPresenter();
presenter.present(resultValue.sponsor);
return presenter.viewModel;
}
async getSponsorDashboard(
@@ -155,7 +159,14 @@ export class SponsorService {
throw new Error('Sponsor dashboard not found');
}
return this.getSponsorDashboardPresenter.viewModel;
const resultValue = result.value;
if (!resultValue) {
throw new Error('Sponsor dashboard not found');
}
const presenter = new GetSponsorDashboardPresenter();
presenter.present(resultValue);
return presenter.viewModel;
}
async getSponsorSponsorships(
@@ -168,7 +179,14 @@ export class SponsorService {
throw new Error('Sponsor sponsorships not found');
}
return this.getSponsorSponsorshipsPresenter.viewModel;
const resultValue = result.value;
if (!resultValue) {
throw new Error('Sponsor sponsorships not found');
}
const presenter = new GetSponsorSponsorshipsPresenter();
presenter.present(resultValue);
return presenter.viewModel;
}
async getSponsor(sponsorId: string): Promise<GetSponsorOutputDTO> {
@@ -179,7 +197,14 @@ export class SponsorService {
throw new Error('Sponsor not found');
}
const viewModel = this.getSponsorPresenter.viewModel;
const resultValue = result.value;
if (!resultValue) {
throw new Error('Sponsor not found');
}
const presenter = new GetSponsorPresenter();
presenter.present(resultValue.sponsor);
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('Sponsor not found');
}
@@ -205,7 +230,14 @@ export class SponsorService {
};
}
const viewModel = this.getPendingSponsorshipRequestsPresenter.viewModel;
const resultValue = result.value;
if (!resultValue) {
throw new Error('Pending sponsorship requests not found');
}
const presenter = new GetPendingSponsorshipRequestsPresenter();
presenter.present(resultValue);
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('Pending sponsorship requests not found');
}
@@ -231,7 +263,14 @@ export class SponsorService {
throw new Error('Accept sponsorship request failed');
}
const viewModel = this.acceptSponsorshipRequestPresenter.viewModel;
const resultValue = result.value;
if (!resultValue) {
throw new Error('Accept sponsorship request failed');
}
const presenter = new AcceptSponsorshipRequestPresenter();
presenter.present(resultValue);
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('Accept sponsorship request failed');
}
@@ -263,7 +302,14 @@ export class SponsorService {
throw new Error('Reject sponsorship request failed');
}
const viewModel = this.rejectSponsorshipRequestPresenter.viewModel;
const resultValue = result.value;
if (!resultValue) {
throw new Error('Reject sponsorship request failed');
}
const presenter = new RejectSponsorshipRequestPresenter();
presenter.present(resultValue);
const viewModel = presenter.viewModel;
if (!viewModel) {
throw new Error('Reject sponsorship request failed');
}
@@ -284,7 +330,8 @@ export class SponsorService {
}
const billingData = result.unwrap();
this.sponsorBillingPresenter.present({
const presenter = new SponsorBillingPresenter();
presenter.present({
paymentMethods: billingData.paymentMethods,
invoices: billingData.invoices,
stats: {
@@ -294,7 +341,7 @@ export class SponsorService {
},
});
return this.sponsorBillingPresenter.viewModel;
return presenter.viewModel;
}
async getAvailableLeagues(): Promise<AvailableLeaguesPresenter> {
@@ -498,4 +545,4 @@ export class SponsorService {
presenter.present({ success: true });
return presenter;
}
}
}

View File

@@ -9,18 +9,6 @@ export const SPONSORSHIP_REQUEST_REPOSITORY_TOKEN = 'ISponsorshipRequestReposito
export const LOGGER_TOKEN = 'Logger';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
// Presenter tokens
export const GET_ENTITY_SPONSORSHIP_PRICING_PRESENTER_TOKEN = 'GetEntitySponsorshipPricingPresenter';
export const GET_SPONSORS_PRESENTER_TOKEN = 'GetSponsorsPresenter';
export const CREATE_SPONSOR_PRESENTER_TOKEN = 'CreateSponsorPresenter';
export const GET_SPONSOR_DASHBOARD_PRESENTER_TOKEN = 'GetSponsorDashboardPresenter';
export const GET_SPONSOR_SPONSORSHIPS_PRESENTER_TOKEN = 'GetSponsorSponsorshipsPresenter';
export const GET_SPONSOR_PRESENTER_TOKEN = 'GetSponsorPresenter';
export const GET_PENDING_SPONSORSHIP_REQUESTS_PRESENTER_TOKEN = 'GetPendingSponsorshipRequestsPresenter';
export const ACCEPT_SPONSORSHIP_REQUEST_PRESENTER_TOKEN = 'AcceptSponsorshipRequestPresenter';
export const REJECT_SPONSORSHIP_REQUEST_PRESENTER_TOKEN = 'RejectSponsorshipRequestPresenter';
export const GET_SPONSOR_BILLING_PRESENTER_TOKEN = 'SponsorBillingPresenter';
// Use case / application service tokens
export const GET_SPONSORSHIP_PRICING_USE_CASE_TOKEN = 'GetSponsorshipPricingUseCase';
export const GET_SPONSORS_USE_CASE_TOKEN = 'GetSponsorsUseCase';
@@ -32,17 +20,4 @@ export const GET_SPONSOR_USE_CASE_TOKEN = 'GetSponsorUseCase';
export const GET_PENDING_SPONSORSHIP_REQUESTS_USE_CASE_TOKEN = 'GetPendingSponsorshipRequestsUseCase';
export const ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'AcceptSponsorshipRequestUseCase';
export const REJECT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN = 'RejectSponsorshipRequestUseCase';
export const GET_SPONSOR_BILLING_USE_CASE_TOKEN = 'GetSponsorBillingUseCase';
// Output port tokens
export const GET_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN = 'GetSponsorshipPricingOutputPort_TOKEN';
export const GET_SPONSORS_OUTPUT_PORT_TOKEN = 'GetSponsorsOutputPort_TOKEN';
export const CREATE_SPONSOR_OUTPUT_PORT_TOKEN = 'CreateSponsorOutputPort_TOKEN';
export const GET_SPONSOR_DASHBOARD_OUTPUT_PORT_TOKEN = 'GetSponsorDashboardOutputPort_TOKEN';
export const GET_SPONSOR_SPONSORSHIPS_OUTPUT_PORT_TOKEN = 'GetSponsorSponsorshipsOutputPort_TOKEN';
export const GET_ENTITY_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN = 'GetEntitySponsorshipPricingOutputPort_TOKEN';
export const GET_SPONSOR_OUTPUT_PORT_TOKEN = 'GetSponsorOutputPort_TOKEN';
export const GET_PENDING_SPONSORSHIP_REQUESTS_OUTPUT_PORT_TOKEN = 'GetPendingSponsorshipRequestsOutputPort_TOKEN';
export const ACCEPT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN = 'AcceptSponsorshipRequestOutputPort_TOKEN';
export const REJECT_SPONSORSHIP_REQUEST_OUTPUT_PORT_TOKEN = 'RejectSponsorshipRequestOutputPort_TOKEN';
export const GET_SPONSOR_BILLING_OUTPUT_PORT_TOKEN = 'GetSponsorBillingOutputPort_TOKEN';
export const GET_SPONSOR_BILLING_USE_CASE_TOKEN = 'GetSponsorBillingUseCase';

View File

@@ -8,7 +8,7 @@ export class GetEntitySponsorshipPricingPresenter {
this.result = null;
}
present(output: GetEntitySponsorshipPricingResult | null) {
present(output: GetEntitySponsorshipPricingResult | null | undefined) {
if (!output) {
this.result = {
entityType: 'season',

View File

@@ -1,13 +1,5 @@
import { GetSponsorOutputDTO } from '../dtos/GetSponsorOutputDTO';
interface GetSponsorOutputPort {
sponsor: {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
};
}
import type { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
export class GetSponsorPresenter {
private result: GetSponsorOutputDTO | null = null;
@@ -16,18 +8,18 @@ export class GetSponsorPresenter {
this.result = null;
}
present(output: GetSponsorOutputPort | null) {
if (!output) {
present(sponsor: Sponsor) {
if (!sponsor) {
this.result = null;
return;
}
this.result = {
sponsor: {
id: output.sponsor.id,
name: output.sponsor.name,
...(output.sponsor.logoUrl !== undefined ? { logoUrl: output.sponsor.logoUrl } : {}),
...(output.sponsor.websiteUrl !== undefined ? { websiteUrl: output.sponsor.websiteUrl } : {}),
id: sponsor.id.toString(),
name: sponsor.name.toString(),
...(sponsor.logoUrl !== undefined ? { logoUrl: sponsor.logoUrl.toString() } : {}),
...(sponsor.websiteUrl !== undefined ? { websiteUrl: sponsor.websiteUrl.toString() } : {}),
},
} as GetSponsorOutputDTO;
}

View File

@@ -1,95 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { GetSponsorshipPricingPresenter } from './GetSponsorshipPricingPresenter';
import type { GetSponsorshipPricingResult } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
describe('GetSponsorshipPricingPresenter', () => {
let presenter: GetSponsorshipPricingPresenter;
beforeEach(() => {
presenter = new GetSponsorshipPricingPresenter();
});
describe('reset', () => {
it('should reset the result to null', () => {
const mockResult: GetSponsorshipPricingResult = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
presenter.present(mockResult);
const expectedViewModel = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
expect(presenter.viewModel).toEqual(expectedViewModel);
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
describe('present', () => {
it('should store the result', () => {
const mockResult: GetSponsorshipPricingResult = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
presenter.present(mockResult);
const expectedViewModel = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
expect(presenter.viewModel).toEqual(expectedViewModel);
});
});
describe('getViewModel', () => {
it('should return null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('should return the result when presented', () => {
const mockResult: GetSponsorshipPricingResult = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
presenter.present(mockResult);
const expectedViewModel = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
expect(presenter.getViewModel()).toEqual(expectedViewModel);
});
});
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: GetSponsorshipPricingResult = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
presenter.present(mockResult);
const expectedViewModel = {
entityType: 'season',
entityId: 'season-1',
pricing: []
};
expect(presenter.viewModel).toEqual(expectedViewModel);
});
});
});

View File

@@ -1,32 +0,0 @@
import type { GetSponsorshipPricingResult } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
import { GetEntitySponsorshipPricingResultDTO } from '../dtos/GetEntitySponsorshipPricingResultDTO';
export class GetSponsorshipPricingPresenter {
private result: GetEntitySponsorshipPricingResultDTO | null = null;
reset() {
this.result = null;
}
present(outputPort: GetSponsorshipPricingResult): void {
this.result = {
entityType: outputPort.entityType,
entityId: outputPort.entityId,
pricing: outputPort.pricing.map(item => ({
id: item.id,
level: item.level,
price: item.price,
currency: item.currency,
})),
};
}
getViewModel(): GetEntitySponsorshipPricingResultDTO | null {
return this.result;
}
get viewModel(): GetEntitySponsorshipPricingResultDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -124,42 +124,13 @@ describe('TeamService', () => {
clear: vi.fn(),
};
const resultRepository = {
findAll: vi.fn().mockResolvedValue([]),
};
// Mock presenter that stores result synchronously
const allTeamsPresenter = {
reset: vi.fn(),
present: vi.fn((result: any) => {
// Store immediately and synchronously
allTeamsPresenter.responseModel = {
teams: result.teams.map((t: any) => ({
id: t.id,
name: t.name,
tag: t.tag,
description: t.description,
memberCount: t.memberCount,
leagues: t.leagues,
logoUrl: t.logoUrl ?? null,
})),
totalCount: result.totalCount,
};
}),
getResponseModel: vi.fn(() => allTeamsPresenter.responseModel || { teams: [], totalCount: 0 }),
responseModel: { teams: [], totalCount: 0 },
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
};
service = new TeamService(
teamRepository as unknown as never,
membershipRepository as unknown as never,
driverRepository as unknown as never,
logger,
teamStatsRepository as unknown as never,
resultRepository as unknown as never,
allTeamsPresenter as any
teamStatsRepository as unknown as never
);
});
@@ -178,7 +149,15 @@ describe('TeamService', () => {
description: 'Desc',
memberCount: 3,
leagues: ['league-1'],
logoUrl: null,
totalWins: 0,
totalRaces: 0,
performanceLevel: 'intermediate',
specialization: 'mixed',
region: '',
languages: [],
rating: 0,
logoUrl: '/media/teams/team-1/logo',
isRecruiting: false,
},
],
totalCount: 1,
@@ -283,8 +262,16 @@ describe('TeamService', () => {
isActive: true,
avatarUrl: '',
},
{
driverId: '',
driverName: '',
role: 'owner',
joinedAt: '2023-02-02T00:00:00.000Z',
isActive: true,
avatarUrl: '',
},
],
totalCount: 1,
totalCount: 2,
ownerCount: 1,
managerCount: 0,
memberCount: 1,

View File

@@ -26,20 +26,9 @@ import { UpdateTeamUseCase, UpdateTeamInput } from '@core/racing/application/use
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';
import { TeamMembershipPresenter } from './presenters/TeamMembershipPresenter';
import { CreateTeamPresenter } from './presenters/CreateTeamPresenter';
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
// Tokens
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, RESULT_REPOSITORY_TOKEN } from './TeamTokens';
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN } from './TeamTokens';
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
@Injectable()
export class TeamService {
@@ -49,8 +38,6 @@ export class TeamService {
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository,
@Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository: IResultRepository,
private readonly allTeamsPresenter: AllTeamsPresenter,
) {}
async getAll(): Promise<GetAllTeamsOutputDTO> {
@@ -60,38 +47,82 @@ export class TeamService {
this.teamRepository,
this.membershipRepository,
this.teamStatsRepository,
this.resultRepository,
this.logger,
this.allTeamsPresenter
this.logger
);
const result = await useCase.execute();
const result = await useCase.execute({});
if (result.isErr()) {
this.logger.error('Error fetching all teams', new Error(result.error?.details?.message || 'Unknown error'));
return { teams: [], totalCount: 0 };
}
return this.allTeamsPresenter.getResponseModel()!;
const value = result.value;
if (!value) {
return { teams: [], totalCount: 0 };
}
return {
teams: value.teams.map(t => ({
id: t.team.id,
name: t.team.name.toString(),
tag: t.team.tag.toString(),
description: t.description,
memberCount: t.memberCount,
leagues: t.leagues,
totalWins: t.totalWins,
totalRaces: t.totalRaces,
performanceLevel: t.performanceLevel,
specialization: t.specialization,
region: t.region,
languages: t.languages,
rating: t.rating,
logoUrl: t.logoUrl,
isRecruiting: t.isRecruiting,
})),
totalCount: value.totalCount,
};
}
async getDetails(teamId: string, userId?: string): Promise<GetTeamDetailsOutputDTO | null> {
this.logger.debug(`[TeamService] Fetching team details for teamId: ${teamId}, userId: ${userId}`);
const presenter = new TeamDetailsPresenter();
const useCase = new GetTeamDetailsUseCase(this.teamRepository, this.membershipRepository, presenter);
const useCase = new GetTeamDetailsUseCase(this.teamRepository, this.membershipRepository);
const result = await useCase.execute({ teamId, driverId: userId || '' });
if (result.isErr()) {
this.logger.error(`Error fetching team details for teamId: ${teamId}: ${result.error?.details?.message || 'Unknown error'}`);
return null;
}
return presenter.getResponseModel()!;
const value = result.value;
if (!value) {
return null;
}
// Convert to DTO
return {
team: {
id: value.team.id,
name: value.team.name.toString(),
tag: value.team.tag.toString(),
description: value.team.description.toString(),
ownerId: value.team.ownerId.toString(),
leagues: value.team.leagues.map(l => l.toString()),
isRecruiting: value.team.isRecruiting,
createdAt: value.team.createdAt?.toDate()?.toISOString?.() || new Date().toISOString(),
category: undefined,
},
membership: value.membership ? {
role: value.membership.role === 'driver' ? 'member' : (value.membership.role as 'owner' | 'manager' | 'member'),
joinedAt: value.membership.joinedAt.toISOString(),
isActive: value.membership.status === 'active',
} : null,
canManage: value.canManage,
};
}
async getMembers(teamId: string): Promise<GetTeamMembersOutputDTO> {
this.logger.debug(`[TeamService] Fetching team members for teamId: ${teamId}`);
const presenter = new TeamMembersPresenter();
const useCase = new GetTeamMembersUseCase(this.membershipRepository, this.driverRepository, this.teamRepository, this.logger, presenter);
const useCase = new GetTeamMembersUseCase(this.membershipRepository, this.driverRepository, this.teamRepository, this.logger);
const result = await useCase.execute({ teamId });
if (result.isErr()) {
this.logger.error(`Error fetching team members for teamId: ${teamId}: ${result.error?.details?.message || 'Unknown error'}`);
@@ -104,14 +135,37 @@ export class TeamService {
};
}
return presenter.getResponseModel()!;
const value = result.value;
if (!value) {
return {
members: [],
totalCount: 0,
ownerCount: 0,
managerCount: 0,
memberCount: 0,
};
}
return {
members: value.members.map(m => ({
driverId: m.driver?.id || '',
driverName: m.driver?.name?.toString() || '',
role: m.membership.role === 'driver' ? 'member' : (m.membership.role as 'owner' | 'manager' | 'member'),
joinedAt: m.membership.joinedAt.toISOString(),
isActive: m.membership.status === 'active',
avatarUrl: '', // Would need MediaResolver here
})),
totalCount: value.members.length,
ownerCount: value.members.filter(m => m.membership.role === 'owner').length,
managerCount: value.members.filter(m => m.membership.role === 'manager').length,
memberCount: value.members.filter(m => m.membership.role === 'driver').length,
};
}
async getJoinRequests(teamId: string): Promise<GetTeamJoinRequestsOutputDTO> {
this.logger.debug(`[TeamService] Fetching team join requests for teamId: ${teamId}`);
const presenter = new TeamJoinRequestsPresenter();
const useCase = new GetTeamJoinRequestsUseCase(this.membershipRepository, this.driverRepository, this.teamRepository, presenter);
const useCase = new GetTeamJoinRequestsUseCase(this.membershipRepository, this.driverRepository, this.teamRepository);
const result = await useCase.execute({ teamId });
if (result.isErr()) {
this.logger.error(`Error fetching team join requests for teamId: ${teamId}`, new Error(result.error?.details?.message || 'Unknown error'));
@@ -122,14 +176,33 @@ export class TeamService {
};
}
return presenter.getResponseModel()!;
const value = result.value;
if (!value) {
return {
requests: [],
pendingCount: 0,
totalCount: 0,
};
}
return {
requests: value.joinRequests.map(r => ({
requestId: r.id,
driverId: r.driverId,
driverName: r.driver.name.toString(),
teamId: r.teamId,
status: 'pending',
requestedAt: r.requestedAt.toISOString(),
avatarUrl: '', // Would need MediaResolver here
})),
pendingCount: value.joinRequests.length,
totalCount: value.joinRequests.length,
};
}
async create(input: CreateTeamInputDTO, userId?: string): Promise<CreateTeamOutputDTO> {
this.logger.debug('[TeamService] Creating team', { input, userId });
const presenter = new CreateTeamPresenter();
const command: CreateTeamInput = {
name: input.name,
tag: input.tag,
@@ -138,21 +211,24 @@ export class TeamService {
leagues: [],
};
const useCase = new CreateTeamUseCase(this.teamRepository, this.membershipRepository, this.logger, presenter);
const useCase = new CreateTeamUseCase(this.teamRepository, this.membershipRepository, this.logger);
const result = await useCase.execute(command);
if (result.isErr()) {
this.logger.error(`Error creating team: ${result.error?.details?.message || 'Unknown error'}`);
return { id: '', success: false };
}
return presenter.responseModel;
const value = result.value;
if (!value) {
return { id: '', success: false };
}
return { id: value.team.id, success: true };
}
async update(teamId: string, input: UpdateTeamInputDTO, userId?: string): Promise<UpdateTeamOutputDTO> {
this.logger.debug(`[TeamService] Updating team ${teamId}`, { input, userId });
const presenter = new UpdateTeamPresenter();
const command: UpdateTeamInput = {
teamId,
updates: {
@@ -163,41 +239,72 @@ export class TeamService {
updatedBy: userId || '',
};
const useCase = new UpdateTeamUseCase(this.teamRepository, this.membershipRepository, presenter);
const useCase = new UpdateTeamUseCase(this.teamRepository, this.membershipRepository);
const result = await useCase.execute(command);
if (result.isErr()) {
this.logger.error(`Error updating team ${teamId}: ${result.error?.details?.message || 'Unknown error'}`);
return { success: false };
}
return presenter.responseModel;
return { success: true };
}
async getDriverTeam(driverId: string): Promise<GetDriverTeamOutputDTO | null> {
this.logger.debug(`[TeamService] Fetching team for driverId: ${driverId}`);
const presenter = new DriverTeamPresenter();
const useCase = new GetDriverTeamUseCase(this.teamRepository, this.membershipRepository, this.logger, presenter);
const useCase = new GetDriverTeamUseCase(this.teamRepository, this.membershipRepository, this.logger);
const result = await useCase.execute({ driverId });
if (result.isErr()) {
this.logger.error(`Error fetching team for driverId: ${driverId}: ${result.error?.details?.message || 'Unknown error'}`);
return null;
}
return presenter.getResponseModel();
const value = result.value;
if (!value || !value.team) {
return null;
}
return {
team: {
id: value.team.id,
name: value.team.name.toString(),
tag: value.team.tag.toString(),
description: value.team.description.toString(),
ownerId: value.team.ownerId.toString(),
leagues: value.team.leagues.map(l => l.toString()),
isRecruiting: value.team.isRecruiting,
createdAt: value.team.createdAt?.toDate?.()?.toISOString?.() || new Date().toISOString(),
category: undefined,
},
membership: {
role: value.membership.role === 'driver' ? 'member' : (value.membership.role as 'owner' | 'manager' | 'member'),
joinedAt: value.membership.joinedAt.toISOString(),
isActive: value.membership.status === 'active',
},
isOwner: value.membership.role === 'owner',
canManage: value.membership.role === 'owner' || value.membership.role === 'manager',
};
}
async getMembership(teamId: string, driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
this.logger.debug(`[TeamService] Fetching team membership for teamId: ${teamId}, driverId: ${driverId}`);
const presenter = new TeamMembershipPresenter();
const useCase = new GetTeamMembershipUseCase(this.membershipRepository, this.logger, presenter);
const useCase = new GetTeamMembershipUseCase(this.membershipRepository, this.logger);
const result = await useCase.execute({ teamId, driverId });
if (result.isErr()) {
this.logger.error(`Error fetching team membership for teamId: ${teamId}, driverId: ${driverId}: ${result.error?.details?.message || 'Unknown error'}`);
return null;
}
return presenter.getResponseModel();
const value = result.value;
if (!value) {
return null;
}
return value.membership ? {
role: value.membership.role,
joinedAt: value.membership.joinedAt,
isActive: value.membership.isActive,
} : null;
}
}

View File

@@ -19,40 +19,40 @@ export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
async present(result: GetAllTeamsResult): Promise<void> {
const teams: TeamListItemDTO[] = await Promise.all(
result.teams.map(async (team) => {
result.teams.map(async (enrichedTeam) => {
const dto = new TeamListItemDTO();
dto.id = team.id;
dto.name = team.name;
dto.tag = team.tag;
dto.description = team.description || '';
dto.memberCount = team.memberCount;
dto.leagues = team.leagues || [];
dto.totalWins = team.totalWins ?? 0;
dto.totalRaces = team.totalRaces ?? 0;
dto.performanceLevel = (team.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro') ?? 'intermediate';
dto.specialization = (team.specialization as 'endurance' | 'sprint' | 'mixed') ?? 'mixed';
dto.region = team.region ?? '';
dto.languages = team.languages ?? [];
dto.id = enrichedTeam.team.id;
dto.name = enrichedTeam.team.name.toString();
dto.tag = enrichedTeam.team.tag.toString();
dto.description = enrichedTeam.team.description.toString() || '';
dto.memberCount = enrichedTeam.memberCount;
dto.leagues = enrichedTeam.team.leagues.map(l => l.toString()) || [];
dto.totalWins = enrichedTeam.totalWins;
dto.totalRaces = enrichedTeam.totalRaces;
dto.performanceLevel = enrichedTeam.performanceLevel;
dto.specialization = enrichedTeam.specialization;
dto.region = enrichedTeam.region;
dto.languages = enrichedTeam.languages;
// Resolve logo URL using MediaResolverPort if available
if (this.mediaResolver && team.logoRef) {
const ref = team.logoRef instanceof MediaReference ? team.logoRef : MediaReference.fromJSON(team.logoRef);
if (this.mediaResolver && enrichedTeam.team.logoRef) {
const ref = enrichedTeam.team.logoRef instanceof MediaReference ? enrichedTeam.team.logoRef : MediaReference.fromJSON(enrichedTeam.team.logoRef);
dto.logoUrl = await this.mediaResolver.resolve(ref);
} else {
// Fallback to existing logoUrl or null
dto.logoUrl = team.logoUrl ?? null;
// Fallback to enriched logoUrl or null
dto.logoUrl = enrichedTeam.logoUrl;
}
dto.rating = team.rating ?? 0;
dto.category = team.category;
dto.isRecruiting = team.isRecruiting;
dto.rating = enrichedTeam.rating;
dto.category = enrichedTeam.team.category;
dto.isRecruiting = enrichedTeam.team.isRecruiting;
return dto;
})
);
this.model = {
teams,
totalCount: result.totalCount ?? result.teams.length,
totalCount: result.totalCount,
};
}

View File

@@ -65,6 +65,7 @@ export class SessionGateway {
cookie: cookieString,
},
cache: 'no-store',
credentials: 'include',
});
console.log(`[SESSION] Response status:`, response.status);

View File

@@ -2,7 +2,6 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { ListUsersUseCase, ListUsersResult } from './ListUsersUseCase';
import { IAdminUserRepository } from '../ports/IAdminUserRepository';
import { AdminUser } from '../../domain/entities/AdminUser';
import { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { AuthorizationService } from '../../domain/services/AuthorizationService';
// Mock the authorization service
@@ -20,11 +19,6 @@ const mockRepository = {
delete: vi.fn(),
} as unknown as IAdminUserRepository;
// Mock output port
const mockOutputPort = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<ListUsersResult>;
describe('ListUsersUseCase', () => {
let useCase: ListUsersUseCase;
let actor: AdminUser;
@@ -41,7 +35,7 @@ describe('ListUsersUseCase', () => {
// Setup default successful authorization
vi.mocked(AuthorizationService.canListUsers).mockReturnValue(true);
useCase = new ListUsersUseCase(mockRepository, mockOutputPort);
useCase = new ListUsersUseCase(mockRepository);
// Create actor (owner)
actor = AdminUser.create({
@@ -76,7 +70,8 @@ describe('ListUsersUseCase', () => {
// Assert
expect(result.isOk()).toBe(true);
expect(mockOutputPort.present).toHaveBeenCalledWith({
const data = result.unwrap();
expect(data).toEqual({
users: [],
total: 0,
page: 1,
@@ -120,13 +115,12 @@ describe('ListUsersUseCase', () => {
// Assert
expect(result.isOk()).toBe(true);
expect(mockOutputPort.present).toHaveBeenCalledWith({
users: [user1, user2],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
});
const data = result.unwrap();
expect(data.users).toEqual([user1, user2]);
expect(data.total).toBe(2);
expect(data.page).toBe(1);
expect(data.limit).toBe(10);
expect(data.totalPages).toBe(1);
});
it('should filter by role', async () => {

View File

@@ -1,6 +1,5 @@
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { IAdminUserRepository } from '../ports/IAdminUserRepository';
import { AuthorizationService } from '../../domain/services/AuthorizationService';
import { UserId } from '../../domain/value-objects/UserId';
@@ -46,14 +45,13 @@ export type ListUsersApplicationError = ApplicationErrorCode<ListUsersErrorCode,
export class ListUsersUseCase {
constructor(
private readonly adminUserRepository: IAdminUserRepository,
private readonly output: UseCaseOutputPort<ListUsersResult>,
) {}
async execute(
input: ListUsersInput,
): Promise<
Result<
void,
ListUsersResult,
ListUsersApplicationError
>
> {
@@ -137,16 +135,15 @@ export class ListUsersUseCase {
const result = await this.adminUserRepository.list(query);
// Pass domain objects to output port
this.output.present({
const output: ListUsersResult = {
users: result.users,
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
});
};
return Result.ok(undefined);
return Result.ok(output);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list users';

View File

@@ -1,10 +1,9 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput, type GetAnalyticsMetricsOutput } from './GetAnalyticsMetricsUseCase';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput } from './GetAnalyticsMetricsUseCase';
import type { Logger } from '@core/shared/application';
describe('GetAnalyticsMetricsUseCase', () => {
let logger: Logger;
let output: UseCaseOutputPort<GetAnalyticsMetricsOutput> & { present: Mock };
let useCase: GetAnalyticsMetricsUseCase;
beforeEach(() => {
@@ -15,21 +14,15 @@ describe('GetAnalyticsMetricsUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetAnalyticsMetricsUseCase(
logger,
output,
);
useCase = new GetAnalyticsMetricsUseCase(logger);
});
it('presents default metrics and logs retrieval when no input is provided', async () => {
it('returns default metrics when no input is provided', async () => {
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({
const data = result.unwrap();
expect(data).toEqual({
pageViews: 0,
uniqueVisitors: 0,
averageSessionDuration: 0,
@@ -38,7 +31,21 @@ describe('GetAnalyticsMetricsUseCase', () => {
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
});
it('uses provided date range and presents error when execute throws', async () => {
it('uses provided date range and returns metrics', async () => {
const input: GetAnalyticsMetricsInput = {
startDate: new Date('2024-01-01'),
endDate: new Date('2024-01-31'),
};
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const data = result.unwrap();
expect(data.pageViews).toBe(0);
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
});
it('returns error when execute throws', async () => {
const input: GetAnalyticsMetricsInput = {
startDate: new Date('2024-01-01'),
endDate: new Date('2024-01-31'),

View File

@@ -1,4 +1,4 @@
import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { IPageViewRepository } from '../repositories/IPageViewRepository';
@@ -17,16 +17,15 @@ export interface GetAnalyticsMetricsOutput {
export type GetAnalyticsMetricsErrorCode = 'REPOSITORY_ERROR';
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, void, GetAnalyticsMetricsErrorCode> {
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, GetAnalyticsMetricsOutput, GetAnalyticsMetricsErrorCode> {
constructor(
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetAnalyticsMetricsOutput>,
private readonly pageViewRepository?: IPageViewRepository,
) {}
async execute(
input: GetAnalyticsMetricsInput = {},
): Promise<Result<void, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>> {
): Promise<Result<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>> {
try {
const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
const endDate = input.endDate ?? new Date();
@@ -47,8 +46,6 @@ export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsIn
bounceRate,
};
this.output.present(resultModel);
this.logger.info('Analytics metrics retrieved', {
startDate,
endDate,
@@ -56,7 +53,7 @@ export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsIn
uniqueVisitors,
});
return Result.ok(undefined);
return Result.ok(resultModel);
} catch (error) {
const err = error as Error;
this.logger.error('Failed to get analytics metrics', err, { input });

View File

@@ -1,10 +1,9 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetDashboardDataUseCase, type GetDashboardDataOutput } from './GetDashboardDataUseCase';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { GetDashboardDataUseCase } from './GetDashboardDataUseCase';
import type { Logger } from '@core/shared/application';
describe('GetDashboardDataUseCase', () => {
let logger: Logger;
let output: UseCaseOutputPort<GetDashboardDataOutput> & { present: Mock };
let useCase: GetDashboardDataUseCase;
beforeEach(() => {
@@ -15,18 +14,15 @@ describe('GetDashboardDataUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetDashboardDataUseCase(logger, output);
useCase = new GetDashboardDataUseCase(logger);
});
it('presents placeholder dashboard metrics and logs retrieval', async () => {
it('returns placeholder dashboard metrics and logs retrieval', async () => {
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({
const data = result.unwrap();
expect(data).toEqual({
totalUsers: 0,
activeUsers: 0,
totalRaces: 0,

View File

@@ -1,4 +1,4 @@
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -13,13 +13,12 @@ export interface GetDashboardDataOutput {
export type GetDashboardDataErrorCode = 'REPOSITORY_ERROR';
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, void, GetDashboardDataErrorCode> {
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, GetDashboardDataOutput, GetDashboardDataErrorCode> {
constructor(
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetDashboardDataOutput>,
) {}
async execute(): Promise<Result<void, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
async execute(): Promise<Result<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
try {
// Placeholder implementation - would need repositories from identity and racing domains
const totalUsers = 0;
@@ -34,8 +33,6 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, v
totalLeagues,
};
this.output.present(resultModel);
this.logger.info('Dashboard data retrieved', {
totalUsers,
activeUsers,
@@ -43,7 +40,7 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, v
totalLeagues,
});
return Result.ok(undefined);
return Result.ok(resultModel);
} catch (error) {
const err = error as Error;
this.logger.error('Failed to get dashboard data', err);

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { RecordEngagementUseCase, type RecordEngagementInput, type RecordEngagementOutput } from './RecordEngagementUseCase';
import { RecordEngagementUseCase, type RecordEngagementInput } from './RecordEngagementUseCase';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent';
describe('RecordEngagementUseCase', () => {
@@ -10,7 +10,6 @@ describe('RecordEngagementUseCase', () => {
save: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<RecordEngagementOutput> & { present: Mock };
let useCase: RecordEngagementUseCase;
beforeEach(() => {
@@ -25,18 +24,13 @@ describe('RecordEngagementUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new RecordEngagementUseCase(
engagementRepository as unknown as IEngagementRepository,
logger,
output,
);
});
it('creates and saves an EngagementEvent and presents its id and weight', async () => {
it('creates and saves an EngagementEvent and returns its id and weight', async () => {
const input: RecordEngagementInput = {
action: 'view' as EngagementAction,
entityType: 'league' as EngagementEntityType,
@@ -52,6 +46,7 @@ describe('RecordEngagementUseCase', () => {
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const data = result.unwrap();
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
const saved = (engagementRepository.save as unknown as Mock).mock.calls?.[0]?.[0] as EngagementEvent;
@@ -60,14 +55,12 @@ describe('RecordEngagementUseCase', () => {
expect(saved.entityId).toBe(input.entityId);
expect(saved.entityType).toBe(input.entityType);
expect(output.present).toHaveBeenCalledWith({
eventId: saved.id,
engagementWeight: saved.getEngagementWeight(),
});
expect(data.eventId).toBe(saved.id);
expect(data.engagementWeight).toBe(saved.getEngagementWeight());
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
});
it('logs and presents error when repository save fails', async () => {
it('logs and returns error when repository save fails', async () => {
const input: RecordEngagementInput = {
action: 'view' as EngagementAction,
entityType: 'league' as EngagementEntityType,

View File

@@ -1,4 +1,4 @@
import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
@@ -22,14 +22,13 @@ export interface RecordEngagementOutput {
export type RecordEngagementErrorCode = 'REPOSITORY_ERROR';
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, void, RecordEngagementErrorCode> {
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, RecordEngagementOutput, RecordEngagementErrorCode> {
constructor(
private readonly engagementRepository: IEngagementRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<RecordEngagementOutput>,
) {}
async execute(input: RecordEngagementInput): Promise<Result<void, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>> {
async execute(input: RecordEngagementInput): Promise<Result<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>> {
try {
const engagementEvent = EngagementEvent.create({
id: crypto.randomUUID(),
@@ -49,8 +48,6 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, v
engagementWeight: engagementEvent.getEngagementWeight(),
};
this.output.present(resultModel);
this.logger.info('Engagement event recorded', {
engagementId: engagementEvent.id,
action: input.action,
@@ -58,7 +55,7 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, v
entityType: input.entityType,
});
return Result.ok(undefined);
return Result.ok(resultModel);
} catch (error) {
const err = error as Error;
this.logger.error('Failed to record engagement event', err, { input });

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { RecordPageViewUseCase, type RecordPageViewInput, type RecordPageViewOutput } from './RecordPageViewUseCase';
import { RecordPageViewUseCase, type RecordPageViewInput } from './RecordPageViewUseCase';
import { PageView } from '../../domain/entities/PageView';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import type { EntityType, VisitorType } from '../../domain/types/PageView';
describe('RecordPageViewUseCase', () => {
@@ -9,7 +9,6 @@ describe('RecordPageViewUseCase', () => {
save: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<RecordPageViewOutput> & { present: Mock };
let useCase: RecordPageViewUseCase;
beforeEach(() => {
@@ -26,18 +25,13 @@ describe('RecordPageViewUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new RecordPageViewUseCase(
pageViewRepository as unknown as PageViewRepository,
logger,
output,
);
});
it('creates and saves a PageView and presents its id', async () => {
it('creates and saves a PageView and returns its id', async () => {
const input: RecordPageViewInput = {
entityType: 'league' as EntityType,
entityId: 'league-1',
@@ -54,6 +48,7 @@ describe('RecordPageViewUseCase', () => {
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const data = result.unwrap();
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
const saved = (pageViewRepository.save as unknown as Mock).mock.calls?.[0]?.[0] as PageView;
@@ -62,13 +57,11 @@ describe('RecordPageViewUseCase', () => {
expect(saved.entityId).toBe(input.entityId);
expect(saved.entityType).toBe(input.entityType);
expect(output.present).toHaveBeenCalledWith({
pageViewId: saved.id,
});
expect(data.pageViewId).toBe(saved.id);
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
});
it('logs and presents error when repository save fails', async () => {
it('logs and returns error when repository save fails', async () => {
const input: RecordPageViewInput = {
entityType: 'league' as EntityType,
entityId: 'league-1',

View File

@@ -1,4 +1,4 @@
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
import type { IPageViewRepository } from '../repositories/IPageViewRepository';
import { PageView } from '../../domain/entities/PageView';
import type { EntityType, VisitorType } from '../../domain/types/PageView';
@@ -22,14 +22,13 @@ export interface RecordPageViewOutput {
export type RecordPageViewErrorCode = 'REPOSITORY_ERROR';
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, void, RecordPageViewErrorCode> {
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, RecordPageViewOutput, RecordPageViewErrorCode> {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<RecordPageViewOutput>,
) {}
async execute(input: RecordPageViewInput): Promise<Result<void, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
async execute(input: RecordPageViewInput): Promise<Result<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
try {
type PageViewCreateProps = Parameters<(typeof PageView)['create']>[0];
@@ -53,15 +52,13 @@ export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, void,
pageViewId: pageView.id,
};
this.output.present(resultModel);
this.logger.info('Page view recorded', {
pageViewId: pageView.id,
entityId: input.entityId,
entityType: input.entityType,
});
return Result.ok(undefined);
return Result.ok(resultModel);
} catch (error) {
const err = error as Error;
this.logger.error('Failed to record page view', err, { input });

View File

@@ -1,22 +1,18 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import { describe, it, expect, vi, type Mock } from 'vitest';
import { ForgotPasswordUseCase } from './ForgotPasswordUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
type ForgotPasswordOutput = {
message: string;
magicLink?: string | null;
};
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
describe('ForgotPasswordUseCase', () => {
let authRepo: {
findByEmail: Mock;
save: Mock;
};
let magicLinkRepo: {
checkRateLimit: Mock;
@@ -26,218 +22,89 @@ describe('ForgotPasswordUseCase', () => {
sendMagicLink: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<ForgotPasswordOutput> & { present: Mock };
let useCase: ForgotPasswordUseCase;
beforeEach(() => {
authRepo = {
findByEmail: vi.fn(),
save: vi.fn(),
};
magicLinkRepo = {
checkRateLimit: vi.fn(),
createPasswordResetRequest: vi.fn(),
};
notificationPort = {
sendMagicLink: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new ForgotPasswordUseCase(
authRepo as unknown as IAuthRepository,
magicLinkRepo as unknown as IMagicLinkRepository,
notificationPort as any,
notificationPort as unknown as IMagicLinkNotificationPort,
logger,
output,
);
});
it('should create magic link for existing user', async () => {
const input = { email: 'test@example.com' };
it('generates and sends magic link when user exists', async () => {
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('hashed-password'),
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'test@example.com' });
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(magicLinkRepo.checkRateLimit).toHaveBeenCalledWith(input.email);
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
expect(output.present).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
const forgotPasswordResult = result.unwrap();
expect(forgotPasswordResult.message).toBe('Password reset link generated successfully');
expect(forgotPasswordResult.magicLink).toBeDefined();
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
expect(notificationPort.sendMagicLink).toHaveBeenCalled();
});
it('should return success for non-existent email (security)', async () => {
const input = { email: 'nonexistent@example.com' };
it('returns success even when user does not exist (for security)', async () => {
authRepo.findByEmail.mockResolvedValue(null);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'nonexistent@example.com' });
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
expect(output.present).toHaveBeenCalledWith({
message: 'If an account exists with this email, a password reset link will be sent',
magicLink: null,
});
expect(result.isOk()).toBe(true);
const forgotPasswordResult = result.unwrap();
expect(forgotPasswordResult.message).toBe('If an account exists with this email, a password reset link will be sent');
expect(forgotPasswordResult.magicLink).toBeNull();
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
expect(notificationPort.sendMagicLink).not.toHaveBeenCalled();
});
it('should handle rate limiting', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
it('returns error when rate limit exceeded', async () => {
magicLinkRepo.checkRateLimit.mockResolvedValue(
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limited' } })
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limit exceeded' } })
);
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'test@example.com' });
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('RATE_LIMIT_EXCEEDED');
expect(result.unwrapErr().code).toBe('RATE_LIMIT_EXCEEDED');
});
it('should validate email format', async () => {
const input = { email: 'invalid-email' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
});
it('should generate secure tokens', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
let capturedToken: string | undefined;
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
capturedToken = data.token;
return Promise.resolve();
});
await useCase.execute(input);
expect(capturedToken).toMatch(/^[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
});
it('should set correct expiration time (15 minutes)', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const beforeCreate = Date.now();
let capturedExpiresAt: Date | undefined;
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
capturedExpiresAt = data.expiresAt;
return Promise.resolve();
});
await useCase.execute(input);
const afterCreate = Date.now();
expect(capturedExpiresAt).toBeDefined();
const timeDiff = capturedExpiresAt!.getTime() - afterCreate;
// Should be approximately 15 minutes (900000ms)
expect(timeDiff).toBeGreaterThan(890000);
expect(timeDiff).toBeLessThan(910000);
});
it('should return magic link in development mode', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
await useCase.execute(input);
expect(output.present).toHaveBeenCalledWith(
expect.objectContaining({
magicLink: expect.stringContaining('token='),
})
);
process.env.NODE_ENV = originalEnv ?? 'test';
});
it('should not return magic link in production mode', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
await useCase.execute(input);
expect(output.present).toHaveBeenCalledWith(
expect.objectContaining({
magicLink: null,
})
);
process.env.NODE_ENV = originalEnv ?? 'test';
});
it('should handle repository errors', async () => {
const input = { email: 'test@example.com' };
it('returns error when repository call fails', async () => {
authRepo.findByEmail.mockRejectedValue(new Error('Database error'));
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'test@example.com' });
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toContain('Database error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
});
});

View File

@@ -4,7 +4,7 @@ import { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkReposi
import { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
import { randomBytes } from 'crypto';
export type ForgotPasswordInput = {
@@ -27,16 +27,15 @@ export type ForgotPasswordApplicationError = ApplicationErrorCode<ForgotPassword
* In production, this would send an email with the magic link.
* In development, it returns the link for testing purposes.
*/
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void, ForgotPasswordErrorCode> {
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, ForgotPasswordResult, ForgotPasswordErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly magicLinkRepo: IMagicLinkRepository,
private readonly notificationPort: IMagicLinkNotificationPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<ForgotPasswordResult>,
) {}
async execute(input: ForgotPasswordInput): Promise<Result<void, ForgotPasswordApplicationError>> {
async execute(input: ForgotPasswordInput): Promise<Result<ForgotPasswordResult, ForgotPasswordApplicationError>> {
try {
// Validate email format
const emailVO = EmailAddress.create(input.email);
@@ -86,7 +85,7 @@ export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void,
expiresAt,
});
this.output.present({
return Result.ok({
message: 'Password reset link generated successfully',
magicLink: process.env.NODE_ENV === 'development' ? magicLink : null,
});
@@ -96,13 +95,11 @@ export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void,
email: input.email,
});
this.output.present({
return Result.ok({
message: 'If an account exists with this email, a password reset link will be sent',
magicLink: null,
});
}
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -2,11 +2,8 @@ import { vi, type Mock } from 'vitest';
import { GetCurrentSessionUseCase } from './GetCurrentSessionUseCase';
import { User } from '../../domain/entities/User';
import { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
type GetCurrentSessionOutput = {
user: User;
};
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('GetCurrentSessionUseCase', () => {
let useCase: GetCurrentSessionUseCase;
@@ -18,7 +15,6 @@ describe('GetCurrentSessionUseCase', () => {
emailExists: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<GetCurrentSessionOutput> & { present: Mock };
beforeEach(() => {
mockUserRepo = {
@@ -34,13 +30,9 @@ describe('GetCurrentSessionUseCase', () => {
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetCurrentSessionUseCase(
mockUserRepo as IUserRepository,
logger,
output,
);
});
@@ -60,11 +52,10 @@ describe('GetCurrentSessionUseCase', () => {
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalled();
const callArgs = output.present.mock.calls?.[0]?.[0];
expect(callArgs?.user).toBeInstanceOf(User);
expect(callArgs?.user.getId().value).toBe(userId);
expect(callArgs?.user.getDisplayName()).toBe('John Smith');
const sessionResult = result.unwrap();
expect(sessionResult.user).toBeInstanceOf(User);
expect(sessionResult.user.getId().value).toBe(userId);
expect(sessionResult.user.getDisplayName()).toBe('John Smith');
});
it('should return error when user does not exist', async () => {
@@ -75,5 +66,6 @@ describe('GetCurrentSessionUseCase', () => {
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('USER_NOT_FOUND');
});
});

View File

@@ -2,7 +2,7 @@ import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
export type GetCurrentSessionInput = {
userId: string;
@@ -28,11 +28,10 @@ export class GetCurrentSessionUseCase {
constructor(
private readonly userRepo: IUserRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetCurrentSessionResult>,
) {}
async execute(input: GetCurrentSessionInput): Promise<
Result<void, GetCurrentSessionApplicationError>
Result<GetCurrentSessionResult, GetCurrentSessionApplicationError>
> {
try {
const stored = await this.userRepo.findById(input.userId);
@@ -45,9 +44,8 @@ export class GetCurrentSessionUseCase {
const user = User.fromStored(stored);
const result: GetCurrentSessionResult = { user };
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message =
error instanceof Error && error.message
@@ -66,4 +64,4 @@ export class GetCurrentSessionUseCase {
} as GetCurrentSessionApplicationError);
}
}
}
}

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetCurrentUserSessionUseCase } from './GetCurrentUserSessionUseCase';
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('GetCurrentUserSessionUseCase', () => {
let sessionPort: {
@@ -10,7 +11,6 @@ describe('GetCurrentUserSessionUseCase', () => {
clearSession: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<AuthSession | null> & { present: Mock };
let useCase: GetCurrentUserSessionUseCase;
beforeEach(() => {
@@ -27,14 +27,9 @@ describe('GetCurrentUserSessionUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetCurrentUserSessionUseCase(
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
});
@@ -57,7 +52,7 @@ describe('GetCurrentUserSessionUseCase', () => {
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith(session);
expect(result.unwrap()).toBe(session);
});
it('returns null when there is no active session', async () => {
@@ -67,6 +62,6 @@ describe('GetCurrentUserSessionUseCase', () => {
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith(null);
expect(result.unwrap()).toBe(null);
});
});

View File

@@ -1,7 +1,7 @@
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
export type GetCurrentUserSessionInput = void;
@@ -18,16 +18,13 @@ export class GetCurrentUserSessionUseCase {
constructor(
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetCurrentUserSessionResult>,
) {}
async execute(): Promise<Result<void, GetCurrentUserSessionApplicationError>> {
async execute(): Promise<Result<GetCurrentUserSessionResult, GetCurrentUserSessionApplicationError>> {
try {
const session = await this.sessionPort.getCurrentSession();
this.output.present(session);
return Result.ok(undefined);
return Result.ok(session);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,22 +1,22 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetUserUseCase } from './GetUserUseCase';
import { User } from '../../domain/entities/User';
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
type GetUserOutput = Result<{ user: User }, unknown>;
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
describe('GetUserUseCase', () => {
let userRepository: {
let userRepo: {
findById: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<GetUserOutput> & { present: Mock };
let useCase: GetUserUseCase;
beforeEach(() => {
userRepository = {
userRepo = {
findById: vi.fn(),
};
@@ -27,48 +27,48 @@ describe('GetUserUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetUserUseCase(
userRepository as unknown as IUserRepository,
userRepo as unknown as IUserRepository,
logger,
output,
);
});
it('returns a User when the user exists', async () => {
const storedUser: StoredUser = {
it('returns user when found', async () => {
const storedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'John Smith',
passwordHash: 'hash',
primaryDriverId: 'driver-1',
passwordHash: 'hashed-password',
createdAt: new Date(),
};
userRepository.findById.mockResolvedValue(storedUser);
userRepo.findById.mockResolvedValue(storedUser);
const result = await useCase.execute({ userId: 'user-1' });
expect(userRepository.findById).toHaveBeenCalledWith('user-1');
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalled();
const callArgs = output.present.mock.calls?.[0]?.[0];
expect(callArgs).toBeInstanceOf(Result);
const user = (callArgs as GetUserOutput).unwrap().user;
expect(user).toBeInstanceOf(User);
expect(user.getId().value).toBe('user-1');
expect(user.getDisplayName()).toBe('John Smith');
const getUserResult = result.unwrap();
expect(getUserResult.user).toBeDefined();
expect(getUserResult.user.getId().value).toBe('user-1');
expect(getUserResult.user.getEmail()).toBe('test@example.com');
expect(userRepo.findById).toHaveBeenCalledWith('user-1');
});
it('returns error when the user does not exist', async () => {
userRepository.findById.mockResolvedValue(null);
it('returns error when user not found', async () => {
userRepo.findById.mockResolvedValue(null);
const result = await useCase.execute({ userId: 'missing-user' });
const result = await useCase.execute({ userId: 'nonexistent' });
expect(userRepository.findById).toHaveBeenCalledWith('missing-user');
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('USER_NOT_FOUND');
});
it('returns error on repository failure', async () => {
userRepo.findById.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute({ userId: 'user-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@ import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type GetUserInput = {
userId: string;
@@ -23,25 +23,20 @@ export class GetUserUseCase implements UseCase<GetUserInput, GetUserResult, GetU
constructor(
private readonly userRepo: IUserRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<Result<GetUserResult, GetUserApplicationError>>,
) {}
async execute(input: GetUserInput): Promise<Result<GetUserResult, GetUserApplicationError>> {
try {
const stored = await this.userRepo.findById(input.userId);
if (!stored) {
const result = Result.err<GetUserResult, GetUserApplicationError>({
return Result.err<GetUserResult, GetUserApplicationError>({
code: 'USER_NOT_FOUND',
details: { message: 'User not found' },
});
this.output.present(result);
return result;
}
const user = User.fromStored(stored);
const result = Result.ok<GetUserResult, GetUserApplicationError>({ user });
this.output.present(result);
return result;
return Result.ok<GetUserResult, GetUserApplicationError>({ user });
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : 'Failed to get user';
@@ -50,12 +45,10 @@ export class GetUserUseCase implements UseCase<GetUserInput, GetUserResult, GetU
input,
});
const result = Result.err<GetUserResult, GetUserApplicationError>({
return Result.err<GetUserResult, GetUserApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message },
});
this.output.present(result);
return result;
}
}
}

View File

@@ -1,12 +1,9 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { HandleAuthCallbackUseCase } from './HandleAuthCallbackUseCase';
import type {
AuthCallbackCommand,
AuthenticatedUser,
IdentityProviderPort,
} from '../ports/IdentityProviderPort';
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('HandleAuthCallbackUseCase', () => {
let provider: {
@@ -14,69 +11,97 @@ describe('HandleAuthCallbackUseCase', () => {
};
let sessionPort: {
createSession: Mock;
getCurrentSession: Mock;
clearSession: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<AuthSession> & { present: Mock };
let logger: Logger & { error: Mock };
let useCase: HandleAuthCallbackUseCase;
beforeEach(() => {
provider = {
completeAuth: vi.fn(),
};
sessionPort = {
createSession: vi.fn(),
getCurrentSession: vi.fn(),
clearSession: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
} as unknown as Logger & { error: Mock };
useCase = new HandleAuthCallbackUseCase(
provider as unknown as IdentityProviderPort,
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
});
it('completes auth and creates a session', async () => {
const command: AuthCallbackCommand = {
provider: 'IRACING_DEMO',
code: 'auth-code',
state: 'state-123',
returnTo: 'https://app/callback',
};
const user: AuthenticatedUser = {
it('successfully handles auth callback and creates session', async () => {
const authenticatedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
email: 'test@example.com',
};
const session: AuthSession = {
user,
const session = {
token: 'session-token',
user: authenticatedUser,
issuedAt: Date.now(),
expiresAt: Date.now() + 1000,
token: 'session-token',
};
provider.completeAuth.mockResolvedValue(user);
provider.completeAuth.mockResolvedValue(authenticatedUser);
sessionPort.createSession.mockResolvedValue(session);
const result = await useCase.execute(command);
const result = await useCase.execute({
code: 'auth-code',
state: 'state-123',
returnTo: '/dashboard',
});
expect(provider.completeAuth).toHaveBeenCalledWith(command);
expect(sessionPort.createSession).toHaveBeenCalledWith(user);
expect(output.present).toHaveBeenCalledWith(session);
expect(result.isOk()).toBe(true);
const callbackResult = result.unwrap();
expect(callbackResult.token).toBe('session-token');
expect(callbackResult.user).toBe(authenticatedUser);
expect(provider.completeAuth).toHaveBeenCalledWith({
code: 'auth-code',
state: 'state-123',
returnTo: '/dashboard',
});
expect(sessionPort.createSession).toHaveBeenCalledWith(authenticatedUser);
});
});
it('returns error when provider call fails', async () => {
provider.completeAuth.mockRejectedValue(new Error('Auth failed'));
const result = await useCase.execute({
code: 'invalid-code',
state: 'state-123',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
it('returns error when session creation fails', async () => {
const authenticatedUser = {
id: 'user-1',
displayName: 'Test User',
email: 'test@example.com',
};
provider.completeAuth.mockResolvedValue(authenticatedUser);
sessionPort.createSession.mockRejectedValue(new Error('Session creation failed'));
const result = await useCase.execute({
code: 'auth-code',
state: 'state-123',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@ import type { AuthCallbackCommand, AuthenticatedUser, IdentityProviderPort } fro
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
export type HandleAuthCallbackInput = AuthCallbackCommand;
@@ -20,19 +20,16 @@ export class HandleAuthCallbackUseCase {
private readonly provider: IdentityProviderPort,
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<HandleAuthCallbackResult>,
) {}
async execute(input: HandleAuthCallbackInput): Promise<
Result<void, HandleAuthCallbackApplicationError>
Result<HandleAuthCallbackResult, HandleAuthCallbackApplicationError>
> {
try {
const user: AuthenticatedUser = await this.provider.completeAuth(input);
const session = await this.sessionPort.createSession(user);
this.output.present(session);
return Result.ok(undefined);
return Result.ok(session);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,19 +1,13 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import {
LoginUseCase,
type LoginInput,
type LoginResult,
type LoginErrorCode,
} from './LoginUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { LoginUseCase } from './LoginUseCase';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import { User } from '../../domain/entities/User';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
describe('LoginUseCase', () => {
let authRepo: {
@@ -22,129 +16,82 @@ describe('LoginUseCase', () => {
let passwordService: {
verify: Mock;
};
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LoginResult> & { present: Mock };
let logger: Logger;
let useCase: LoginUseCase;
beforeEach(() => {
authRepo = {
findByEmail: vi.fn(),
};
passwordService = {
verify: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<LoginResult> & { present: Mock };
} as unknown as Logger;
useCase = new LoginUseCase(
authRepo as unknown as IAuthRepository,
passwordService as unknown as IPasswordHashingService,
logger,
output,
);
});
it('returns ok and presents user when credentials are valid', async () => {
const input: LoginInput = {
email: 'test@example.com',
password: 'password123',
};
const emailVO = EmailAddress.create(input.email);
it('successfully logs in with valid credentials', async () => {
const user = User.create({
id: UserId.fromString('user-1'),
id: UserId.create(),
displayName: 'John Smith',
email: emailVO.value,
passwordHash: PasswordHash.fromHash('stored-hash'),
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('hashed-password'),
});
authRepo.findByEmail.mockResolvedValue(user);
passwordService.verify.mockResolvedValue(true);
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(authRepo.findByEmail).toHaveBeenCalledWith(emailVO);
expect(passwordService.verify).toHaveBeenCalledWith(input.password, 'stored-hash');
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as LoginResult;
expect(presented.user).toBe(user);
});
it('returns INVALID_CREDENTIALS when user is not found', async () => {
const input: LoginInput = {
email: 'missing@example.com',
password: 'password123',
};
authRepo.findByEmail.mockResolvedValue(null);
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details?.message).toBe('Invalid credentials');
expect(output.present).not.toHaveBeenCalled();
});
it('returns INVALID_CREDENTIALS when password is invalid', async () => {
const input: LoginInput = {
const result = await useCase.execute({
email: 'test@example.com',
password: 'wrong-password',
};
const emailVO = EmailAddress.create(input.email);
const user = User.create({
id: UserId.fromString('user-1'),
displayName: 'Jane Smith',
email: emailVO.value,
passwordHash: PasswordHash.fromHash('stored-hash'),
password: 'Password123',
});
expect(result.isOk()).toBe(true);
const loginResult = result.unwrap();
expect(loginResult.user).toBe(user);
expect(authRepo.findByEmail).toHaveBeenCalledTimes(1);
expect(passwordService.verify).toHaveBeenCalledTimes(1);
});
it('returns error for invalid credentials', async () => {
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('hashed-password'),
});
authRepo.findByEmail.mockResolvedValue(user);
passwordService.verify.mockResolvedValue(false);
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details?.message).toBe('Invalid credentials');
expect(output.present).not.toHaveBeenCalled();
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const input: LoginInput = {
const result = await useCase.execute({
email: 'test@example.com',
password: 'password123',
};
authRepo.findByEmail.mockRejectedValue(new Error('DB failure'));
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
await useCase.execute(input);
password: 'WrongPassword',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('DB failure');
expect(output.present).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
});
it('returns error when user does not exist', async () => {
authRepo.findByEmail.mockResolvedValue(null);
const result = await useCase.execute({
email: 'nonexistent@example.com',
password: 'Password123',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
});

View File

@@ -4,7 +4,7 @@ import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type LoginInput = {
email: string;
@@ -24,15 +24,14 @@ export type LoginApplicationError = ApplicationErrorCode<LoginErrorCode, { messa
*
* Handles user login by verifying credentials.
*/
export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
export class LoginUseCase implements UseCase<LoginInput, LoginResult, LoginErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LoginResult>,
) {}
async execute(input: LoginInput): Promise<Result<void, LoginApplicationError>> {
async execute(input: LoginInput): Promise<Result<LoginResult, LoginApplicationError>> {
try {
const emailVO = EmailAddress.create(input.email);
const user = await this.authRepo.findByEmail(emailVO);
@@ -48,14 +47,13 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
const isValid = await this.passwordService.verify(input.password, passwordHash.value);
if (!isValid) {
return Result.err<void, LoginApplicationError>({
return Result.err<LoginResult, LoginApplicationError>({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid credentials' },
});
}
this.output.present({ user });
return Result.ok(undefined);
return Result.ok({ user });
} catch (error) {
const message =
error instanceof Error && error.message
@@ -66,7 +64,7 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
input,
});
return Result.err<void, LoginApplicationError>({
return Result.err<LoginResult, LoginApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message },
});

View File

@@ -1,15 +1,18 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import {
LoginWithEmailUseCase,
type LoginWithEmailInput,
type LoginWithEmailResult,
type LoginWithEmailErrorCode,
} from './LoginWithEmailUseCase';
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import { LoginWithEmailUseCase } from './LoginWithEmailUseCase';
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
import type { Logger } from '@core/shared/application';
// Mock the PasswordHash module
vi.mock('@core/identity/domain/value-objects/PasswordHash', () => ({
PasswordHash: {
fromHash: vi.fn((hash: string) => ({
verify: vi.fn().mockResolvedValue(hash === 'hashed-password'),
value: hash,
})),
},
}));
describe('LoginWithEmailUseCase', () => {
let userRepository: {
@@ -17,169 +20,119 @@ describe('LoginWithEmailUseCase', () => {
};
let sessionPort: {
createSession: Mock;
getCurrentSession: Mock;
clearSession: Mock;
};
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LoginWithEmailResult> & { present: Mock };
let logger: Logger;
let useCase: LoginWithEmailUseCase;
beforeEach(() => {
userRepository = {
findByEmail: vi.fn(),
};
sessionPort = {
createSession: vi.fn(),
getCurrentSession: vi.fn(),
clearSession: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger & { error: Mock };
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<LoginWithEmailResult> & { present: Mock };
} as unknown as Logger;
useCase = new LoginWithEmailUseCase(
userRepository as unknown as IUserRepository,
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
});
it('returns ok and presents session result for valid credentials', async () => {
const input: LoginWithEmailInput = {
email: 'Test@Example.com',
password: 'password123',
};
// Import PasswordHash to create a proper hash
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const passwordHash = await PasswordHash.create('password123');
const storedUser: StoredUser = {
const storedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
passwordHash: passwordHash.value,
displayName: 'John Smith',
passwordHash: 'hashed-password',
createdAt: new Date(),
};
const session = {
userRepository.findByEmail.mockResolvedValue(storedUser);
sessionPort.createSession.mockResolvedValue({
token: 'token-123',
user: {
id: storedUser.id,
email: storedUser.email,
displayName: storedUser.displayName,
id: 'user-1',
email: 'test@example.com',
displayName: 'John Smith',
},
issuedAt: Date.now(),
expiresAt: Date.now() + 1000,
token: 'token-123',
};
userRepository.findByEmail.mockResolvedValue(storedUser);
sessionPort.createSession.mockResolvedValue(session);
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
expect(sessionPort.createSession).toHaveBeenCalledWith({
id: storedUser.id,
displayName: storedUser.displayName,
email: storedUser.email,
});
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as LoginWithEmailResult;
expect(presented.sessionToken).toBe('token-123');
expect(presented.userId).toBe(storedUser.id);
expect(presented.displayName).toBe(storedUser.displayName);
expect(presented.email).toBe(storedUser.email);
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
});
expect(result.isOk()).toBe(true);
const loginResult = result.unwrap();
expect(loginResult.sessionToken).toBe('token-123');
expect(loginResult.userId).toBe('user-1');
expect(loginResult.displayName).toBe('John Smith');
expect(loginResult.email).toBe('test@example.com');
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
expect(sessionPort.createSession).toHaveBeenCalled();
});
it('returns INVALID_INPUT when email or password is missing', async () => {
const result1 = await useCase.execute({ email: '', password: 'x' });
const result2 = await useCase.execute({ email: 'a@example.com', password: '' });
const result = await useCase.execute({
email: '',
password: 'Password123',
});
expect(result1.isErr()).toBe(true);
expect(result1.unwrapErr().code).toBe('INVALID_INPUT');
expect(result2.isErr()).toBe(true);
expect(result2.unwrapErr().code).toBe('INVALID_INPUT');
expect(output.present).not.toHaveBeenCalled();
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
});
it('returns INVALID_CREDENTIALS when user does not exist', async () => {
const input: LoginWithEmailInput = {
email: 'missing@example.com',
password: 'password',
};
userRepository.findByEmail.mockResolvedValue(null);
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
const result = await useCase.execute({
email: 'nonexistent@example.com',
password: 'Password123',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details.message).toBe('Invalid email or password');
expect(output.present).not.toHaveBeenCalled();
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
it('returns INVALID_CREDENTIALS when password is invalid', async () => {
const input: LoginWithEmailInput = {
email: 'test@example.com',
password: 'wrong',
};
// Create a hash for a different password
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const passwordHash = await PasswordHash.create('correct-password');
const storedUser: StoredUser = {
const storedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
passwordHash: passwordHash.value,
displayName: 'John Smith',
passwordHash: 'wrong-hash', // Different hash to simulate wrong password
createdAt: new Date(),
};
userRepository.findByEmail.mockResolvedValue(storedUser);
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
const result = await useCase.execute({
email: 'test@example.com',
password: 'WrongPassword',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details.message).toBe('Invalid email or password');
expect(output.present).not.toHaveBeenCalled();
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const input: LoginWithEmailInput = {
userRepository.findByEmail.mockRejectedValue(new Error('Database connection failed'));
const result = await useCase.execute({
email: 'test@example.com',
password: 'password123',
};
userRepository.findByEmail.mockRejectedValue(new Error('DB failure'));
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
password: 'Password123',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('DB failure');
expect(output.present).not.toHaveBeenCalled();
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});
});

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