resolve todos in website
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get, Post, Body, HttpCode, HttpStatus, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { RaceService } from './RaceService';
|
||||
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
|
||||
import { RaceStatsDTO } from './dtos/RaceStatsDTO';
|
||||
@@ -137,6 +137,15 @@ export class RaceController {
|
||||
return this.raceService.completeRace({ raceId });
|
||||
}
|
||||
|
||||
@Post(':raceId/reopen')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Re-open race' })
|
||||
@ApiParam({ name: 'raceId', description: 'Race ID' })
|
||||
@ApiResponse({ status: 200, description: 'Successfully re-opened race' })
|
||||
async reopenRace(@Param('raceId') raceId: string): Promise<void> {
|
||||
return this.raceService.reopenRace({ raceId });
|
||||
}
|
||||
|
||||
@Post(':raceId/import-results')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Import race results' })
|
||||
@@ -149,7 +158,6 @@ export class RaceController {
|
||||
return this.raceService.importRaceResults({ raceId, ...body });
|
||||
}
|
||||
|
||||
|
||||
@Post('protests/file')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'File a protest' })
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
import { RaceService } from './RaceService';
|
||||
|
||||
// Import core interfaces
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
import { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
|
||||
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
||||
import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
|
||||
import { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
|
||||
import { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
|
||||
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
|
||||
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
|
||||
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
|
||||
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
|
||||
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
|
||||
import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
|
||||
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
|
||||
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
|
||||
|
||||
// Import concrete in-memory implementations
|
||||
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
|
||||
@@ -50,6 +50,7 @@ import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPen
|
||||
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
|
||||
import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase';
|
||||
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRaceUseCase';
|
||||
|
||||
// Define injection tokens
|
||||
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
|
||||
@@ -129,7 +130,8 @@ export const RaceProviders: Provider[] = [
|
||||
// Use cases
|
||||
{
|
||||
provide: GetAllRacesUseCase,
|
||||
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository, logger: Logger) => new GetAllRacesUseCase(raceRepo, leagueRepo, logger),
|
||||
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository, logger: Logger) =>
|
||||
new GetAllRacesUseCase(raceRepo, leagueRepo, logger),
|
||||
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
@@ -146,14 +148,15 @@ export const RaceProviders: Provider[] = [
|
||||
raceRegRepo: IRaceRegistrationRepository,
|
||||
resultRepo: IResultRepository,
|
||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||
) => new GetRaceDetailUseCase(
|
||||
raceRepo,
|
||||
leagueRepo,
|
||||
driverRepo,
|
||||
raceRegRepo,
|
||||
resultRepo,
|
||||
leagueMembershipRepo,
|
||||
),
|
||||
) =>
|
||||
new GetRaceDetailUseCase(
|
||||
raceRepo,
|
||||
leagueRepo,
|
||||
driverRepo,
|
||||
raceRegRepo,
|
||||
resultRepo,
|
||||
leagueMembershipRepo,
|
||||
),
|
||||
inject: [
|
||||
RACE_REPOSITORY_TOKEN,
|
||||
LEAGUE_REPOSITORY_TOKEN,
|
||||
@@ -165,12 +168,14 @@ export const RaceProviders: Provider[] = [
|
||||
},
|
||||
{
|
||||
provide: GetRacesPageDataUseCase,
|
||||
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) => new GetRacesPageDataUseCase(raceRepo, leagueRepo),
|
||||
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) =>
|
||||
new GetRacesPageDataUseCase(raceRepo, leagueRepo),
|
||||
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: GetAllRacesPageDataUseCase,
|
||||
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) => new GetAllRacesPageDataUseCase(raceRepo, leagueRepo),
|
||||
useFactory: (raceRepo: IRaceRepository, leagueRepo: ILeagueRepository) =>
|
||||
new GetAllRacesPageDataUseCase(raceRepo, leagueRepo),
|
||||
inject: [RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN],
|
||||
},
|
||||
{
|
||||
@@ -207,18 +212,23 @@ export const RaceProviders: Provider[] = [
|
||||
},
|
||||
{
|
||||
provide: GetRaceProtestsUseCase,
|
||||
useFactory: (protestRepo: IProtestRepository, driverRepo: IDriverRepository) => new GetRaceProtestsUseCase(protestRepo, driverRepo),
|
||||
useFactory: (protestRepo: IProtestRepository, driverRepo: IDriverRepository) =>
|
||||
new GetRaceProtestsUseCase(protestRepo, driverRepo),
|
||||
inject: [PROTEST_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: GetRacePenaltiesUseCase,
|
||||
useFactory: (penaltyRepo: IPenaltyRepository, driverRepo: IDriverRepository) => new GetRacePenaltiesUseCase(penaltyRepo, driverRepo),
|
||||
useFactory: (penaltyRepo: IPenaltyRepository, driverRepo: IDriverRepository) =>
|
||||
new GetRacePenaltiesUseCase(penaltyRepo, driverRepo),
|
||||
inject: [PENALTY_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: RegisterForRaceUseCase,
|
||||
useFactory: (raceRegRepo: IRaceRegistrationRepository, leagueMembershipRepo: ILeagueMembershipRepository, logger: Logger) =>
|
||||
new RegisterForRaceUseCase(raceRegRepo, leagueMembershipRepo, logger),
|
||||
useFactory: (
|
||||
raceRegRepo: IRaceRegistrationRepository,
|
||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||
logger: Logger,
|
||||
) => new RegisterForRaceUseCase(raceRegRepo, leagueMembershipRepo, logger),
|
||||
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
@@ -248,6 +258,11 @@ export const RaceProviders: Provider[] = [
|
||||
DRIVER_RATING_PROVIDER_TOKEN,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: ReopenRaceUseCase,
|
||||
useFactory: (raceRepo: IRaceRepository, logger: Logger) => new ReopenRaceUseCase(raceRepo, logger),
|
||||
inject: [RACE_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: ImportRaceResultsApiUseCase,
|
||||
useFactory: (
|
||||
@@ -257,14 +272,7 @@ export const RaceProviders: Provider[] = [
|
||||
driverRepo: IDriverRepository,
|
||||
standingRepo: IStandingRepository,
|
||||
logger: Logger,
|
||||
) => new ImportRaceResultsApiUseCase(
|
||||
raceRepo,
|
||||
leagueRepo,
|
||||
resultRepo,
|
||||
driverRepo,
|
||||
standingRepo,
|
||||
logger,
|
||||
),
|
||||
) => new ImportRaceResultsApiUseCase(raceRepo, leagueRepo, resultRepo, driverRepo, standingRepo, logger),
|
||||
inject: [
|
||||
RACE_REPOSITORY_TOKEN,
|
||||
LEAGUE_REPOSITORY_TOKEN,
|
||||
@@ -283,14 +291,7 @@ export const RaceProviders: Provider[] = [
|
||||
driverRepo: IDriverRepository,
|
||||
standingRepo: IStandingRepository,
|
||||
logger: Logger,
|
||||
) => new ImportRaceResultsUseCase(
|
||||
raceRepo,
|
||||
leagueRepo,
|
||||
resultRepo,
|
||||
driverRepo,
|
||||
standingRepo,
|
||||
logger,
|
||||
),
|
||||
) => new ImportRaceResultsUseCase(raceRepo, leagueRepo, resultRepo, driverRepo, standingRepo, logger),
|
||||
inject: [
|
||||
RACE_REPOSITORY_TOKEN,
|
||||
LEAGUE_REPOSITORY_TOKEN,
|
||||
@@ -302,32 +303,61 @@ export const RaceProviders: Provider[] = [
|
||||
},
|
||||
{
|
||||
provide: FileProtestUseCase,
|
||||
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) =>
|
||||
new FileProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo),
|
||||
useFactory: (
|
||||
protestRepo: IProtestRepository,
|
||||
raceRepo: IRaceRepository,
|
||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||
) => new FileProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo),
|
||||
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: QuickPenaltyUseCase,
|
||||
useFactory: (penaltyRepo: IPenaltyRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository, logger: Logger) =>
|
||||
new QuickPenaltyUseCase(penaltyRepo, raceRepo, leagueMembershipRepo, logger),
|
||||
inject: [PENALTY_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
||||
useFactory: (
|
||||
penaltyRepo: IPenaltyRepository,
|
||||
raceRepo: IRaceRepository,
|
||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||
logger: Logger,
|
||||
) => new QuickPenaltyUseCase(penaltyRepo, raceRepo, leagueMembershipRepo, logger),
|
||||
inject: [
|
||||
PENALTY_REPOSITORY_TOKEN,
|
||||
RACE_REPOSITORY_TOKEN,
|
||||
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: ApplyPenaltyUseCase,
|
||||
useFactory: (penaltyRepo: IPenaltyRepository, protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository, logger: Logger) =>
|
||||
new ApplyPenaltyUseCase(penaltyRepo, protestRepo, raceRepo, leagueMembershipRepo, logger),
|
||||
inject: [PENALTY_REPOSITORY_TOKEN, PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
||||
useFactory: (
|
||||
penaltyRepo: IPenaltyRepository,
|
||||
protestRepo: IProtestRepository,
|
||||
raceRepo: IRaceRepository,
|
||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||
logger: Logger,
|
||||
) => new ApplyPenaltyUseCase(penaltyRepo, protestRepo, raceRepo, leagueMembershipRepo, logger),
|
||||
inject: [
|
||||
PENALTY_REPOSITORY_TOKEN,
|
||||
PROTEST_REPOSITORY_TOKEN,
|
||||
RACE_REPOSITORY_TOKEN,
|
||||
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
LOGGER_TOKEN,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: RequestProtestDefenseUseCase,
|
||||
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) =>
|
||||
new RequestProtestDefenseUseCase(protestRepo, raceRepo, leagueMembershipRepo),
|
||||
useFactory: (
|
||||
protestRepo: IProtestRepository,
|
||||
raceRepo: IRaceRepository,
|
||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||
) => new RequestProtestDefenseUseCase(protestRepo, raceRepo, leagueMembershipRepo),
|
||||
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: ReviewProtestUseCase,
|
||||
useFactory: (protestRepo: IProtestRepository, raceRepo: IRaceRepository, leagueMembershipRepo: ILeagueMembershipRepository) =>
|
||||
new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo),
|
||||
useFactory: (
|
||||
protestRepo: IProtestRepository,
|
||||
raceRepo: IRaceRepository,
|
||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||
) => new ReviewProtestUseCase(protestRepo, raceRepo, leagueMembershipRepo),
|
||||
inject: [PROTEST_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { ConflictException, Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import type { AllRacesPageViewModel } from '@core/racing/application/presenters/IGetAllRacesPresenter';
|
||||
import type { RaceDetailOutputPort } from '@core/racing/application/ports/output/RaceDetailOutputPort';
|
||||
import type { RacesPageOutputPort } from '@core/racing/application/ports/output/RacesPageOutputPort';
|
||||
@@ -47,6 +47,7 @@ import { QuickPenaltyUseCase } from '@core/racing/application/use-cases/QuickPen
|
||||
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
|
||||
import { RequestProtestDefenseUseCase } from '@core/racing/application/use-cases/RequestProtestDefenseUseCase';
|
||||
import { ReviewProtestUseCase } from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
import { ReopenRaceUseCase } from '@core/racing/application/use-cases/ReopenRaceUseCase';
|
||||
|
||||
// Presenters
|
||||
import { GetAllRacesPresenter } from './presenters/GetAllRacesPresenter';
|
||||
@@ -61,7 +62,7 @@ import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCom
|
||||
import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO';
|
||||
|
||||
// Tokens
|
||||
import { LOGGER_TOKEN, DRIVER_RATING_PROVIDER_TOKEN, IMAGE_SERVICE_TOKEN, LEAGUE_REPOSITORY_TOKEN } from './RaceProviders';
|
||||
import { DRIVER_RATING_PROVIDER_TOKEN, IMAGE_SERVICE_TOKEN, LEAGUE_REPOSITORY_TOKEN, LOGGER_TOKEN } from './RaceProviders';
|
||||
|
||||
@Injectable()
|
||||
export class RaceService {
|
||||
@@ -85,6 +86,7 @@ export class RaceService {
|
||||
private readonly applyPenaltyUseCase: ApplyPenaltyUseCase,
|
||||
private readonly requestProtestDefenseUseCase: RequestProtestDefenseUseCase,
|
||||
private readonly reviewProtestUseCase: ReviewProtestUseCase,
|
||||
private readonly reopenRaceUseCase: ReopenRaceUseCase,
|
||||
@Inject(LEAGUE_REPOSITORY_TOKEN) private readonly leagueRepository: ILeagueRepository,
|
||||
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
|
||||
@Inject(DRIVER_RATING_PROVIDER_TOKEN) private readonly driverRatingProvider: DriverRatingProvider,
|
||||
@@ -130,60 +132,68 @@ export class RaceService {
|
||||
throw new Error('Failed to get race detail');
|
||||
}
|
||||
|
||||
const outputPort = result.value;
|
||||
const outputPort = result.value as RaceDetailOutputPort;
|
||||
|
||||
// Map to DTO
|
||||
const raceDTO = outputPort.race ? {
|
||||
id: outputPort.race.id,
|
||||
leagueId: outputPort.race.leagueId,
|
||||
track: outputPort.race.track,
|
||||
car: outputPort.race.car,
|
||||
scheduledAt: outputPort.race.scheduledAt.toISOString(),
|
||||
sessionType: outputPort.race.sessionType,
|
||||
status: outputPort.race.status,
|
||||
strengthOfField: outputPort.race.strengthOfField ?? null,
|
||||
registeredCount: outputPort.race.registeredCount ?? undefined,
|
||||
maxParticipants: outputPort.race.maxParticipants ?? undefined,
|
||||
} : null;
|
||||
const raceDTO = outputPort.race
|
||||
? {
|
||||
id: outputPort.race.id,
|
||||
leagueId: outputPort.race.leagueId,
|
||||
track: outputPort.race.track,
|
||||
car: outputPort.race.car,
|
||||
scheduledAt: outputPort.race.scheduledAt.toISOString(),
|
||||
sessionType: outputPort.race.sessionType,
|
||||
status: outputPort.race.status,
|
||||
strengthOfField: outputPort.race.strengthOfField ?? null,
|
||||
registeredCount: outputPort.race.registeredCount ?? undefined,
|
||||
maxParticipants: outputPort.race.maxParticipants ?? undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const leagueDTO = outputPort.league ? {
|
||||
id: outputPort.league.id.toString(),
|
||||
name: outputPort.league.name.toString(),
|
||||
description: outputPort.league.description.toString(),
|
||||
settings: {
|
||||
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
|
||||
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
|
||||
},
|
||||
} : null;
|
||||
const leagueDTO = outputPort.league
|
||||
? {
|
||||
id: outputPort.league.id.toString(),
|
||||
name: outputPort.league.name.toString(),
|
||||
description: outputPort.league.description.toString(),
|
||||
settings: {
|
||||
maxDrivers: outputPort.league.settings.maxDrivers ?? undefined,
|
||||
qualifyingFormat: outputPort.league.settings.qualifyingFormat ?? undefined,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const entryListDTO = await Promise.all(outputPort.drivers.map(async driver => {
|
||||
const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
|
||||
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
||||
return {
|
||||
id: driver.id,
|
||||
name: driver.name.toString(),
|
||||
country: driver.country.toString(),
|
||||
avatarUrl: avatarResult.avatarUrl,
|
||||
rating: ratingResult.rating,
|
||||
isCurrentUser: driver.id === params.driverId,
|
||||
};
|
||||
}));
|
||||
const entryListDTO = await Promise.all(
|
||||
outputPort.drivers.map(async driver => {
|
||||
const ratingResult = await this.driverRatingProvider.getDriverRating({ driverId: driver.id });
|
||||
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
||||
return {
|
||||
id: driver.id,
|
||||
name: driver.name.toString(),
|
||||
country: driver.country.toString(),
|
||||
avatarUrl: avatarResult.avatarUrl,
|
||||
rating: ratingResult.rating,
|
||||
isCurrentUser: driver.id === params.driverId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const registrationDTO = {
|
||||
isUserRegistered: outputPort.isUserRegistered,
|
||||
canRegister: outputPort.canRegister,
|
||||
};
|
||||
|
||||
const userResultDTO = outputPort.userResult ? {
|
||||
position: outputPort.userResult.position.toNumber(),
|
||||
startPosition: outputPort.userResult.startPosition.toNumber(),
|
||||
incidents: outputPort.userResult.incidents.toNumber(),
|
||||
fastestLap: outputPort.userResult.fastestLap.toNumber(),
|
||||
positionChange: outputPort.userResult.getPositionChange(),
|
||||
isPodium: outputPort.userResult.isPodium(),
|
||||
isClean: outputPort.userResult.isClean(),
|
||||
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
|
||||
} : null;
|
||||
const userResultDTO = outputPort.userResult
|
||||
? {
|
||||
position: outputPort.userResult.position.toNumber(),
|
||||
startPosition: outputPort.userResult.startPosition.toNumber(),
|
||||
incidents: outputPort.userResult.incidents.toNumber(),
|
||||
fastestLap: outputPort.userResult.fastestLap.toNumber(),
|
||||
positionChange: outputPort.userResult.getPositionChange(),
|
||||
isPodium: outputPort.userResult.isPodium(),
|
||||
isClean: outputPort.userResult.isClean(),
|
||||
ratingChange: this.calculateRatingChange(outputPort.userResult.position.toNumber()),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
race: raceDTO,
|
||||
@@ -203,7 +213,7 @@ export class RaceService {
|
||||
throw new Error('Failed to get races page data');
|
||||
}
|
||||
|
||||
const outputPort = result.value;
|
||||
const outputPort = result.value as RacesPageOutputPort;
|
||||
|
||||
// Fetch leagues for league names
|
||||
const allLeagues = await this.leagueRepository.findAll();
|
||||
@@ -250,32 +260,34 @@ export class RaceService {
|
||||
throw new Error('Failed to get race results detail');
|
||||
}
|
||||
|
||||
const outputPort = result.value;
|
||||
const outputPort = result.value as RaceResultsDetailOutputPort;
|
||||
|
||||
// Create a map of driverId to driver for easy lookup
|
||||
const driverMap = new Map(outputPort.drivers.map(driver => [driver.id, driver]));
|
||||
|
||||
const resultsDTO = await Promise.all(outputPort.results.map(async (result) => {
|
||||
const driver = driverMap.get(result.driverId.toString());
|
||||
if (!driver) {
|
||||
throw new Error(`Driver not found for result: ${result.driverId}`);
|
||||
}
|
||||
const resultsDTO = await Promise.all(
|
||||
outputPort.results.map(async singleResult => {
|
||||
const driver = driverMap.get(singleResult.driverId.toString());
|
||||
if (!driver) {
|
||||
throw new Error(`Driver not found for result: ${singleResult.driverId}`);
|
||||
}
|
||||
|
||||
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
||||
const avatarResult = await this.imageService.getDriverAvatar({ driverId: driver.id });
|
||||
|
||||
return {
|
||||
driverId: result.driverId.toString(),
|
||||
driverName: driver.name.toString(),
|
||||
avatarUrl: avatarResult.avatarUrl,
|
||||
position: result.position.toNumber(),
|
||||
startPosition: result.startPosition.toNumber(),
|
||||
incidents: result.incidents.toNumber(),
|
||||
fastestLap: result.fastestLap.toNumber(),
|
||||
positionChange: result.getPositionChange(),
|
||||
isPodium: result.isPodium(),
|
||||
isClean: result.isClean(),
|
||||
};
|
||||
}));
|
||||
return {
|
||||
driverId: singleResult.driverId.toString(),
|
||||
driverName: driver.name.toString(),
|
||||
avatarUrl: avatarResult.avatarUrl,
|
||||
position: singleResult.position.toNumber(),
|
||||
startPosition: singleResult.startPosition.toNumber(),
|
||||
incidents: singleResult.incidents.toNumber(),
|
||||
fastestLap: singleResult.fastestLap.toNumber(),
|
||||
positionChange: singleResult.getPositionChange(),
|
||||
isPodium: singleResult.isPodium(),
|
||||
isClean: singleResult.isClean(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
raceId: outputPort.race.id,
|
||||
@@ -293,7 +305,7 @@ export class RaceService {
|
||||
throw new Error('Failed to get race with SOF');
|
||||
}
|
||||
|
||||
const outputPort = result.value;
|
||||
const outputPort = result.value as RaceWithSOFOutputPort;
|
||||
|
||||
// Map to DTO
|
||||
return {
|
||||
@@ -312,7 +324,7 @@ export class RaceService {
|
||||
throw new Error('Failed to get race protests');
|
||||
}
|
||||
|
||||
const outputPort = result.value;
|
||||
const outputPort = result.value as RaceProtestsOutputPort;
|
||||
|
||||
const protestsDTO = outputPort.protests.map(protest => ({
|
||||
id: protest.id,
|
||||
@@ -346,7 +358,7 @@ export class RaceService {
|
||||
throw new Error('Failed to get race penalties');
|
||||
}
|
||||
|
||||
const outputPort = result.value;
|
||||
const outputPort = result.value as RacePenaltiesOutputPort;
|
||||
|
||||
const penaltiesDTO = outputPort.penalties.map(penalty => ({
|
||||
id: penalty.id,
|
||||
@@ -410,7 +422,30 @@ export class RaceService {
|
||||
}
|
||||
}
|
||||
|
||||
async reopenRace(params: RaceActionParamsDTO): Promise<void> {
|
||||
this.logger.debug('[RaceService] Re-opening race:', params);
|
||||
|
||||
const result = await this.reopenRaceUseCase.execute({ raceId: params.raceId });
|
||||
|
||||
if (result.isErr()) {
|
||||
const errorCode = result.unwrapErr().code;
|
||||
|
||||
if (errorCode === 'RACE_NOT_FOUND') {
|
||||
throw new NotFoundException('Race not found');
|
||||
}
|
||||
|
||||
if (errorCode === 'CANNOT_REOPEN_RUNNING_RACE') {
|
||||
throw new ConflictException('Cannot re-open a running race');
|
||||
}
|
||||
|
||||
if (errorCode === 'RACE_ALREADY_SCHEDULED') {
|
||||
this.logger.debug('[RaceService] Race is already scheduled, treating reopen as success.');
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(errorCode ?? 'UNEXPECTED_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
async fileProtest(command: FileProtestCommandDTO): Promise<void> {
|
||||
this.logger.debug('[RaceService] Filing protest:', command);
|
||||
|
||||
@@ -2,45 +2,44 @@ import { Provider } from '@nestjs/common';
|
||||
import { SponsorService } from './SponsorService';
|
||||
|
||||
// Import core interfaces
|
||||
import { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository';
|
||||
import { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
|
||||
import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import { NotificationService } from '@core/notifications/application/ports/NotificationService';
|
||||
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
|
||||
import { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
|
||||
import { IPaymentGateway } from '@core/racing/application/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';
|
||||
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||
import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||
import { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
|
||||
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 { INotificationService } from '@core/notifications/application/ports/INotificationService';
|
||||
import { IPaymentGateway } from '@core/racing/application/ports/IPaymentGateway';
|
||||
import { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
|
||||
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
|
||||
import { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
// Import use cases / application services
|
||||
import { GetSponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetSponsorshipPricingUseCase';
|
||||
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';
|
||||
import { GetSponsorSponsorshipsUseCase } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
||||
import { GetEntitySponsorshipPricingUseCase } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
import { GetSponsorUseCase } from '@core/racing/application/use-cases/GetSponsorUseCase';
|
||||
import { GetPendingSponsorshipRequestsUseCase } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
import { SponsorBillingService } from '@core/payments/application/services/SponsorBillingService';
|
||||
import { AcceptSponsorshipRequestUseCase } from '@core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||
import { CreateSponsorUseCase } from '@core/racing/application/use-cases/CreateSponsorUseCase';
|
||||
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';
|
||||
import { RejectSponsorshipRequestUseCase } from '@core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
|
||||
// Import concrete in-memory implementations
|
||||
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository';
|
||||
import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository';
|
||||
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository';
|
||||
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
||||
import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
|
||||
import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository';
|
||||
import { InMemorySeasonRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonRepository';
|
||||
import { InMemorySeasonSponsorshipRepository } from '@adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository';
|
||||
import { InMemorySponsorRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorRepository';
|
||||
import { InMemorySponsorshipPricingRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipPricingRepository';
|
||||
import { InMemorySponsorshipRequestRepository } from '@adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository';
|
||||
import { InMemoryPaymentRepository } from '@adapters/payments/persistence/inmemory/InMemoryPaymentRepository';
|
||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
||||
|
||||
// Define injection tokens
|
||||
export const SPONSOR_REPOSITORY_TOKEN = 'ISponsorRepository';
|
||||
@@ -179,7 +178,7 @@ export const SponsorProviders: Provider[] = [
|
||||
},
|
||||
{
|
||||
provide: ACCEPT_SPONSORSHIP_REQUEST_USE_CASE_TOKEN,
|
||||
useFactory: (sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, notificationService: INotificationService, paymentGateway: IPaymentGateway, walletRepository: IWalletRepository, leagueWalletRepository: ILeagueWalletRepository, logger: Logger) =>
|
||||
useFactory: (sponsorshipRequestRepo: ISponsorshipRequestRepository, seasonSponsorshipRepo: ISeasonSponsorshipRepository, seasonRepo: ISeasonRepository, notificationService: NotificationService, paymentGateway: IPaymentGateway, walletRepository: IWalletRepository, leagueWalletRepository: ILeagueWalletRepository, logger: Logger) =>
|
||||
new AcceptSponsorshipRequestUseCase(sponsorshipRequestRepo, seasonSponsorshipRepo, seasonRepo, notificationService, paymentGateway, walletRepository, leagueWalletRepository, logger),
|
||||
inject: [SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SEASON_REPOSITORY_TOKEN, 'INotificationService', 'IPaymentGateway', 'IWalletRepository', 'ILeagueWalletRepository', LOGGER_TOKEN],
|
||||
},
|
||||
|
||||
@@ -45,14 +45,14 @@ import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel
|
||||
|
||||
type ProfileTab = 'overview' | 'stats';
|
||||
|
||||
interface TeamLeagueSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
leagues: unknown[]; // TODO: define proper type
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface SocialHandle {
|
||||
@@ -317,11 +317,6 @@ export default function DriverDetailPage() {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: '', // Not available in summary
|
||||
description: '', // Not available in summary
|
||||
ownerId: '', // Not available in summary
|
||||
leagues: [], // TODO: populate if needed
|
||||
createdAt: new Date(), // TODO: add to API
|
||||
} as Team,
|
||||
role: membership.role,
|
||||
joinedAt: new Date(membership.joinedAt),
|
||||
|
||||
@@ -180,11 +180,8 @@ export default function ProtestReviewPage() {
|
||||
type: 'protest_filed',
|
||||
timestamp: new Date(protest.submittedAt),
|
||||
actor: protestingDriver,
|
||||
content: protest.description, // TODO: Add incident description when available
|
||||
metadata: {
|
||||
// lap: protest.incident?.lap,
|
||||
// comment: protest.comment
|
||||
}
|
||||
content: protest.description,
|
||||
metadata: {}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -242,7 +239,8 @@ export default function ProtestReviewPage() {
|
||||
currentDriverId,
|
||||
protest.id
|
||||
);
|
||||
penaltyCommand.reason = 'Protest upheld'; // TODO: Make this configurable
|
||||
|
||||
penaltyCommand.reason = stewardNotes || 'Protest dismissed';
|
||||
|
||||
await protestService.applyPenalty(penaltyCommand);
|
||||
}
|
||||
@@ -406,16 +404,16 @@ export default function ProtestReviewPage() {
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.formattedDate}</span>
|
||||
</div>
|
||||
{/* TODO: Add lap info when available */}
|
||||
{/* <div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div> */}
|
||||
{protest.incident?.lap && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* TODO: Add evidence when available */}
|
||||
{/* {protest.proofVideoUrl && (
|
||||
{protest.proofVideoUrl && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
|
||||
<a
|
||||
@@ -429,7 +427,7 @@ export default function ProtestReviewPage() {
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Card>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-4">
|
||||
@@ -479,13 +477,12 @@ export default function ProtestReviewPage() {
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
|
||||
|
||||
{/* TODO: Add comment when available */}
|
||||
{/* {protest.comment && (
|
||||
{protest.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||
</div>
|
||||
)} */}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
169
apps/website/app/races/[id]/page.test.tsx
Normal file
169
apps/website/app/races/[id]/page.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
import RaceDetailPage from './page';
|
||||
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
|
||||
|
||||
// Mocks for Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
const mockBack = vi.fn();
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
back: mockBack,
|
||||
}),
|
||||
useParams: () => ({ id: 'race-123' }),
|
||||
}));
|
||||
|
||||
// Mock effective driver id hook
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => ({
|
||||
useEffectiveDriverId: () => 'driver-1',
|
||||
}));
|
||||
|
||||
// Mock sponsor mode hook to avoid rendering heavy sponsor card
|
||||
vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="sponsor-insights-mock" />,
|
||||
MetricBuilders: {
|
||||
views: vi.fn(() => ({ label: 'Views', value: '100' })),
|
||||
engagement: vi.fn(() => ({ label: 'Engagement', value: '50%' })),
|
||||
reach: vi.fn(() => ({ label: 'Reach', value: '1000' })),
|
||||
},
|
||||
SlotTemplates: {
|
||||
race: vi.fn(() => []),
|
||||
},
|
||||
useSponsorMode: () => false,
|
||||
}));
|
||||
|
||||
// Mock services hook to provide raceService and leagueMembershipService
|
||||
const mockGetRaceDetail = vi.fn();
|
||||
const mockReopenRace = vi.fn();
|
||||
const mockFetchLeagueMemberships = vi.fn();
|
||||
const mockGetMembership = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => ({
|
||||
useServices: () => ({
|
||||
raceService: {
|
||||
getRaceDetail: mockGetRaceDetail,
|
||||
reopenRace: mockReopenRace,
|
||||
// other methods are not used in this test
|
||||
},
|
||||
leagueMembershipService: {
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
getMembership: mockGetMembership,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock league membership utility to control admin vs non-admin behavior
|
||||
const mockIsOwnerOrAdmin = vi.fn();
|
||||
|
||||
vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
|
||||
LeagueMembershipUtility: {
|
||||
isOwnerOrAdmin: (...args: unknown[]) => mockIsOwnerOrAdmin(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const createViewModel = (status: string) => {
|
||||
return new RaceDetailViewModel({
|
||||
race: {
|
||||
id: 'race-123',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: '2023-12-31T20:00:00Z',
|
||||
status,
|
||||
sessionType: 'race',
|
||||
strengthOfField: null,
|
||||
registeredCount: 0,
|
||||
maxParticipants: 32,
|
||||
} as any,
|
||||
league: {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test league description',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'open',
|
||||
},
|
||||
} as any,
|
||||
entryList: [],
|
||||
registration: {
|
||||
isRegistered: false,
|
||||
canRegister: false,
|
||||
} as any,
|
||||
userResult: null,
|
||||
});
|
||||
};
|
||||
|
||||
describe('RaceDetailPage - Re-open Race behavior', () => {
|
||||
beforeEach(() => {
|
||||
mockGetRaceDetail.mockReset();
|
||||
mockReopenRace.mockReset();
|
||||
mockFetchLeagueMemberships.mockReset();
|
||||
mockGetMembership.mockReset();
|
||||
mockIsOwnerOrAdmin.mockReset();
|
||||
|
||||
mockFetchLeagueMemberships.mockResolvedValue(undefined);
|
||||
mockGetMembership.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('shows Re-open Race button for admin when race is completed and calls reopen + reload on confirm', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(true);
|
||||
const viewModel = createViewModel('completed');
|
||||
|
||||
// First call: initial load, second call: after re-open
|
||||
mockGetRaceDetail.mockResolvedValue(viewModel);
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RaceDetailPage />);
|
||||
|
||||
const reopenButton = await screen.findByText('Re-open Race');
|
||||
expect(reopenButton).toBeInTheDocument();
|
||||
|
||||
mockReopenRace.mockResolvedValue(undefined);
|
||||
|
||||
fireEvent.click(reopenButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReopenRace).toHaveBeenCalledWith('race-123');
|
||||
});
|
||||
|
||||
// loadRaceData should be called again after reopening
|
||||
await waitFor(() => {
|
||||
expect(mockGetRaceDetail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not render Re-open Race button for non-admin viewer', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(false);
|
||||
const viewModel = createViewModel('completed');
|
||||
mockGetRaceDetail.mockResolvedValue(viewModel);
|
||||
|
||||
render(<RaceDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRaceDetail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Re-open Race')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
|
||||
mockIsOwnerOrAdmin.mockReturnValue(true);
|
||||
const viewModel = createViewModel('scheduled');
|
||||
mockGetRaceDetail.mockResolvedValue(viewModel);
|
||||
|
||||
render(<RaceDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRaceDetail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Re-open Race')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export default function RaceDetailPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [reopening, setReopening] = useState(false);
|
||||
const [ratingChange, setRatingChange] = useState<number | null>(null);
|
||||
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
||||
const [showProtestModal, setShowProtestModal] = useState(false);
|
||||
@@ -174,6 +175,27 @@ export default function RaceDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReopenRace = async () => {
|
||||
const race = viewModel?.race;
|
||||
if (!race || !viewModel?.canReopenRace) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Re-open this race? This will allow re-registration and re-running. Results will be archived.',
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setReopening(true);
|
||||
try {
|
||||
await raceService.reopenRace(race.id);
|
||||
await loadRaceData();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to re-open race');
|
||||
} finally {
|
||||
setReopening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
@@ -856,6 +878,19 @@ export default function RaceDetailPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewModel.canReopenRace &&
|
||||
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={handleReopenRace}
|
||||
disabled={reopening}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
{reopening ? 'Re-opening...' : 'Re-open Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
@@ -884,29 +919,22 @@ export default function RaceDetailPage() {
|
||||
<Scale className="w-4 h-4" />
|
||||
Stewarding
|
||||
</Button>
|
||||
{LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Re-open this race? This will allow re-registration and re-running. Results will be archived.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
// TODO: Implement re-open race functionality
|
||||
alert('Re-open race functionality not yet implemented');
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
Re-open Race
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewModel.canReopenRace &&
|
||||
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
onClick={handleReopenRace}
|
||||
disabled={reopening}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
{reopening ? 'Re-opening...' : 'Re-open Race'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{race.status === 'running' && LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -8,6 +8,7 @@ import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import type { RaceResultsDetailViewModel } from '@/lib/view-models';
|
||||
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -18,7 +19,7 @@ export default function RaceResultsPage() {
|
||||
const params = useParams();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { raceResultsService } = useServices();
|
||||
const { raceResultsService, leagueMembershipService } = useServices();
|
||||
|
||||
const [raceData, setRaceData] = useState<RaceResultsDetailViewModel | null>(null);
|
||||
const [raceSOF, setRaceSOF] = useState<number | null>(null);
|
||||
@@ -56,14 +57,16 @@ export default function RaceResultsPage() {
|
||||
}, [raceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (raceData?.league?.id && currentDriverId) {
|
||||
const leagueId = raceData?.league?.id;
|
||||
if (leagueId && currentDriverId) {
|
||||
const checkAdmin = async () => {
|
||||
// For now, assume admin check - this might need to be updated based on API
|
||||
setIsAdmin(true); // TODO: Implement proper admin check via API
|
||||
await leagueMembershipService.fetchLeagueMemberships(leagueId);
|
||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
};
|
||||
checkAdmin();
|
||||
}
|
||||
}, [raceData?.league?.id, currentDriverId]);
|
||||
}, [raceData?.league?.id, currentDriverId, leagueMembershipService]);
|
||||
|
||||
const handleImportSuccess = async (importedResults: any[]) => {
|
||||
setImporting(true);
|
||||
|
||||
@@ -6,6 +6,7 @@ import Card from '@/components/ui/Card';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -24,7 +25,7 @@ import { useEffect, useState } from 'react';
|
||||
export default function RaceStewardingPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { raceStewardingService } = useServices();
|
||||
const { raceStewardingService, leagueMembershipService } = useServices();
|
||||
const raceId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
@@ -40,9 +41,11 @@ export default function RaceStewardingPage() {
|
||||
const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId);
|
||||
setStewardingData(data);
|
||||
|
||||
if (data.league) {
|
||||
// TODO: Implement admin check via API
|
||||
setIsAdmin(true);
|
||||
if (data.league?.id) {
|
||||
const membership = await leagueMembershipService.getMembership(data.league.id, currentDriverId);
|
||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
||||
} else {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
@@ -52,7 +55,7 @@ export default function RaceStewardingPage() {
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [raceId, currentDriverId, raceStewardingService]);
|
||||
}, [raceId, currentDriverId, raceStewardingService, leagueMembershipService]);
|
||||
|
||||
const pendingProtests = stewardingData?.pendingProtests ?? [];
|
||||
const resolvedProtests = stewardingData?.resolvedProtests ?? [];
|
||||
|
||||
@@ -157,11 +157,12 @@ export default function TeamDetailPage() {
|
||||
|
||||
const visibleTabs = tabs.filter(tab => tab.visible);
|
||||
|
||||
// Build sponsor insights for team
|
||||
// Build sponsor insights for team using real membership and league data
|
||||
const leagueCount = team.leagues?.length ?? 0;
|
||||
const teamMetrics = [
|
||||
MetricBuilders.members(memberships.length),
|
||||
MetricBuilders.reach(memberships.length * 15),
|
||||
MetricBuilders.races(0), // TODO: Get league count from team data
|
||||
MetricBuilders.races(leagueCount),
|
||||
MetricBuilders.engagement(82),
|
||||
];
|
||||
|
||||
@@ -206,15 +207,27 @@ export default function TeamDetailPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{team.name}</h1>
|
||||
{/* TODO: Add team tag when available */}
|
||||
{team.tag && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
|
||||
[{team.tag}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
|
||||
{/* TODO: Add created date when available */}
|
||||
{/* TODO: Add league count when available */}
|
||||
{team.createdAt && (
|
||||
<span>
|
||||
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
{leagueCount > 0 && (
|
||||
<span>
|
||||
Active in {leagueCount} {leagueCount === 1 ? 'league' : 'leagues'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,8 +272,19 @@ export default function TeamDetailPage() {
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
|
||||
<div className="space-y-3">
|
||||
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
|
||||
<StatItem label="Leagues" value="0" color="text-green-400" /> {/* TODO: Get league count */}
|
||||
<StatItem label="Founded" value="Unknown" color="text-gray-300" /> {/* TODO: Get founded date */}
|
||||
{leagueCount > 0 && (
|
||||
<StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
|
||||
)}
|
||||
{team.createdAt && (
|
||||
<StatItem
|
||||
label="Founded"
|
||||
value={new Date(team.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
color="text-gray-300"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -285,7 +309,7 @@ export default function TeamDetailPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'standings' && (
|
||||
<TeamStandings teamId={teamId} leagues={[]} />
|
||||
<TeamStandings teamId={teamId} leagues={team.leagues} />
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && isAdmin && (
|
||||
|
||||
@@ -451,9 +451,29 @@ export default function TeamsPage() {
|
||||
const { teamService } = useServices();
|
||||
const teams = await teamService.getAllTeams();
|
||||
setRealTeams(teams);
|
||||
// TODO: set groups and top teams from service or compute locally
|
||||
setGroupsBySkillLevel({});
|
||||
setTopTeams([]);
|
||||
|
||||
// Derive groups by skill level from the loaded teams
|
||||
const byLevel: Record<SkillLevel, TeamDisplayData[]> = {
|
||||
beginner: [],
|
||||
intermediate: [],
|
||||
advanced: [],
|
||||
pro: [],
|
||||
};
|
||||
teams.forEach((team) => {
|
||||
const level = (team.performanceLevel as SkillLevel) || 'intermediate';
|
||||
if (byLevel[level]) {
|
||||
byLevel[level].push(team as TeamDisplayData);
|
||||
}
|
||||
});
|
||||
setGroupsBySkillLevel(byLevel);
|
||||
|
||||
// Select top teams by rating for the preview section
|
||||
const sortedByRating = [...teams].sort((a, b) => {
|
||||
const aRating = typeof a.rating === 'number' && Number.isFinite(a.rating) ? a.rating : 0;
|
||||
const bRating = typeof b.rating === 'number' && Number.isFinite(b.rating) ? b.rating : 0;
|
||||
return bRating - aRating;
|
||||
});
|
||||
setTopTeams(sortedByRating.slice(0, 5));
|
||||
} catch (error) {
|
||||
console.error('Failed to load teams:', error);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
|
||||
export interface DriverIdentityProps {
|
||||
driver: DriverDTO;
|
||||
|
||||
41
apps/website/components/leagues/LeagueHeader.test.tsx
Normal file
41
apps/website/components/leagues/LeagueHeader.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import LeagueHeader from './LeagueHeader';
|
||||
|
||||
describe('LeagueHeader', () => {
|
||||
it('renders league name, description and sponsor', () => {
|
||||
render(
|
||||
<LeagueHeader
|
||||
leagueId="league-1"
|
||||
leagueName="Test League"
|
||||
description="A fun test league"
|
||||
ownerId="owner-1"
|
||||
ownerName="Owner Name"
|
||||
mainSponsor={{
|
||||
name: 'Test Sponsor',
|
||||
websiteUrl: 'https://example.com',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test League')).toBeInTheDocument();
|
||||
expect(screen.getByText('A fun test league')).toBeInTheDocument();
|
||||
expect(screen.getByText('by')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Sponsor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders without description or sponsor', () => {
|
||||
render(
|
||||
<LeagueHeader
|
||||
leagueId="league-2"
|
||||
leagueName="League Without Details"
|
||||
ownerId="owner-2"
|
||||
ownerName="Owner 2"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('League Without Details')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
import MembershipStatus from '@/components/leagues/MembershipStatus';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
|
||||
|
||||
// TODO EntityMapper is legacy. Must use ´useServices` hook.
|
||||
|
||||
// Main sponsor info for "by XYZ" display
|
||||
interface MainSponsorInfo {
|
||||
@@ -35,30 +30,6 @@ export default function LeagueHeader({
|
||||
const imageService = getImageService();
|
||||
const logoUrl = imageService.getLeagueLogo(leagueId);
|
||||
|
||||
const [ownerDriver, setOwnerDriver] = useState<DriverDTO | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadOwner() {
|
||||
try {
|
||||
const driverRepo = getDriverRepository();
|
||||
const entity = await driverRepo.findById(ownerId);
|
||||
if (!entity || !isMounted) return;
|
||||
setOwnerDriver(EntityMappers.toDriverDTO(entity));
|
||||
} catch (err) {
|
||||
console.error('Failed to load league owner for header:', err);
|
||||
}
|
||||
}
|
||||
|
||||
loadOwner();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [ownerId]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* League header with logo - no cover image */}
|
||||
|
||||
129
apps/website/components/leagues/LeagueMembers.test.tsx
Normal file
129
apps/website/components/leagues/LeagueMembers.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import LeagueMembers from './LeagueMembers';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
|
||||
// Stub global driver stats helper used by LeagueMembers sorting/rendering
|
||||
(globalThis as any).getDriverStats = (driverId: string) => ({
|
||||
driverId,
|
||||
rating: driverId === 'driver-1' ? 2500 : 2000,
|
||||
overallRank: driverId === 'driver-1' ? 1 : 2,
|
||||
wins: driverId === 'driver-1' ? 10 : 5,
|
||||
});
|
||||
|
||||
// Mock effective driver id so we can assert the "(You)" label
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => {
|
||||
return {
|
||||
useEffectiveDriverId: () => 'driver-1',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock services hook to inject stub leagueMembershipService and driverService
|
||||
const mockFetchLeagueMemberships = vi.fn<[], Promise<any[]>>();
|
||||
const mockGetLeagueMembers = vi.fn<(leagueId: string) => any[]>();
|
||||
const mockFindByIds = vi.fn<(ids: string[]) => Promise<DriverDTO[]>>();
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => {
|
||||
return {
|
||||
useServices: () => ({
|
||||
leagueMembershipService: {
|
||||
fetchLeagueMemberships: mockFetchLeagueMemberships,
|
||||
getLeagueMembers: mockGetLeagueMembers,
|
||||
},
|
||||
driverService: {
|
||||
findByIds: mockFindByIds,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('LeagueMembers', () => {
|
||||
beforeEach(() => {
|
||||
mockFetchLeagueMemberships.mockReset();
|
||||
mockGetLeagueMembers.mockReset();
|
||||
mockFindByIds.mockReset();
|
||||
});
|
||||
|
||||
it('loads memberships via services and renders driver rows', async () => {
|
||||
const leagueId = 'league-1';
|
||||
|
||||
const memberships = [
|
||||
{
|
||||
id: 'm1',
|
||||
leagueId,
|
||||
driverId: 'driver-1',
|
||||
role: 'owner',
|
||||
status: 'active',
|
||||
joinedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
leagueId,
|
||||
driverId: 'driver-2',
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: '2024-01-02T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const drivers: DriverDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
iracingId: 'ir-1',
|
||||
name: 'Driver One',
|
||||
country: 'DE',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
iracingId: 'ir-2',
|
||||
name: 'Driver Two',
|
||||
country: 'US',
|
||||
},
|
||||
];
|
||||
|
||||
mockFetchLeagueMemberships.mockResolvedValue(memberships);
|
||||
mockGetLeagueMembers.mockReturnValue(memberships);
|
||||
mockFindByIds.mockResolvedValue(drivers);
|
||||
|
||||
render(<LeagueMembers leagueId={leagueId} showActions={false} />);
|
||||
|
||||
// Loading state first
|
||||
expect(screen.getByText('Loading members...')).toBeInTheDocument();
|
||||
|
||||
// Wait for data to be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading members...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Services should have been called with expected arguments
|
||||
expect(mockFetchLeagueMemberships).toHaveBeenCalledWith(leagueId);
|
||||
expect(mockGetLeagueMembers).toHaveBeenCalledWith(leagueId);
|
||||
expect(mockFindByIds).toHaveBeenCalledTimes(1);
|
||||
expect(mockFindByIds).toHaveBeenCalledWith(['driver-1', 'driver-2']);
|
||||
|
||||
// Driver rows should be rendered using DTO names
|
||||
expect(screen.getByText('Driver One')).toBeInTheDocument();
|
||||
expect(screen.getByText('Driver Two')).toBeInTheDocument();
|
||||
|
||||
// Current user marker should appear for effective driver id
|
||||
expect(screen.getByText('(You)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty membership list gracefully', async () => {
|
||||
const leagueId = 'league-empty';
|
||||
|
||||
mockFetchLeagueMemberships.mockResolvedValue([]);
|
||||
mockGetLeagueMembers.mockReturnValue([]);
|
||||
mockFindByIds.mockResolvedValue([]);
|
||||
|
||||
render(<LeagueMembers leagueId={leagueId} showActions={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading members...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('No members found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import {
|
||||
getLeagueMembers,
|
||||
type LeagueMembership,
|
||||
type MembershipRole,
|
||||
} from '@/lib/leagueMembership';
|
||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
|
||||
import DriverIdentity from '../drivers/DriverIdentity';
|
||||
import { useEffectiveDriverId } from '../../hooks/useEffectiveDriverId';
|
||||
import { useServices } from '../../lib/services/ServiceProvider';
|
||||
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
||||
import type { MembershipRole } from '@core/racing/domain/entities/MembershipRole';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
// TODO EntityMapper is legacy. Must use ´useServices` hook.
|
||||
// Migrated to useServices-based website services; legacy EntityMapper removed.
|
||||
|
||||
interface LeagueMembersProps {
|
||||
leagueId: string;
|
||||
@@ -31,32 +28,33 @@ export default function LeagueMembers({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating');
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const { leagueMembershipService, driverService } = useServices();
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const membershipData = getLeagueMembers(leagueId);
|
||||
await leagueMembershipService.fetchLeagueMemberships(leagueId);
|
||||
const membershipData = leagueMembershipService.getLeagueMembers(leagueId);
|
||||
setMembers(membershipData);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const driverEntities = await Promise.all(
|
||||
membershipData.map((m) => driverRepo.findById(m.driverId))
|
||||
);
|
||||
const driverDtos = driverEntities
|
||||
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
const uniqueDriverIds = Array.from(new Set(membershipData.map((m) => m.driverId)));
|
||||
if (uniqueDriverIds.length > 0) {
|
||||
const driverDtos = await driverService.findByIds(uniqueDriverIds);
|
||||
|
||||
const byId: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
byId[dto.id] = dto;
|
||||
const byId: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
byId[dto.id] = dto;
|
||||
}
|
||||
setDriversById(byId);
|
||||
} else {
|
||||
setDriversById({});
|
||||
}
|
||||
setDriversById(byId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load members:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [leagueId]);
|
||||
}, [leagueId, leagueMembershipService, driverService]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMembers();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
import DriverRating from '@/components/profile/DriverRatingPill';
|
||||
|
||||
export interface DriverSummaryPillProps {
|
||||
|
||||
120
apps/website/components/profile/UserPill.test.tsx
Normal file
120
apps/website/components/profile/UserPill.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import UserPill from './UserPill';
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
|
||||
// Mock useAuth to control session state
|
||||
vi.mock('@/lib/auth/AuthContext', () => {
|
||||
return {
|
||||
useAuth: () => mockedAuthValue,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock effective driver id hook
|
||||
vi.mock('@/hooks/useEffectiveDriverId', () => {
|
||||
return {
|
||||
useEffectiveDriverId: () => mockedDriverId,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock services hook to inject stub driverService/mediaService
|
||||
const mockFindById = vi.fn<[], Promise<DriverDTO | null>>();
|
||||
const mockGetDriverAvatar = vi.fn<(driverId: string) => string>();
|
||||
|
||||
vi.mock('@/lib/services/ServiceProvider', () => {
|
||||
return {
|
||||
useServices: () => ({
|
||||
driverService: {
|
||||
findById: mockFindById,
|
||||
},
|
||||
mediaService: {
|
||||
getDriverAvatar: mockGetDriverAvatar,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
interface MockSessionUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface MockSession {
|
||||
user: MockSessionUser | null;
|
||||
}
|
||||
|
||||
let mockedAuthValue: { session: MockSession | null } = { session: null };
|
||||
let mockedDriverId: string | null = null;
|
||||
|
||||
// Provide global stats helpers used by UserPill's rating/rank computation
|
||||
// They are UI-level helpers, so a minimal stub is sufficient for these tests.
|
||||
(globalThis as any).getDriverStats = (driverId: string) => ({
|
||||
driverId,
|
||||
rating: 2000,
|
||||
overallRank: 10,
|
||||
wins: 5,
|
||||
});
|
||||
|
||||
(globalThis as any).getAllDriverRankings = () => [
|
||||
{ driverId: 'driver-1', rating: 2100 },
|
||||
{ driverId: 'driver-2', rating: 2000 },
|
||||
];
|
||||
|
||||
describe('UserPill', () => {
|
||||
beforeEach(() => {
|
||||
mockedAuthValue = { session: null };
|
||||
mockedDriverId = null;
|
||||
mockFindById.mockReset();
|
||||
mockGetDriverAvatar.mockReset();
|
||||
});
|
||||
|
||||
it('renders auth links when there is no session', () => {
|
||||
mockedAuthValue = { session: null };
|
||||
|
||||
const { container } = render(<UserPill />);
|
||||
|
||||
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||
expect(screen.getByText('Get Started')).toBeInTheDocument();
|
||||
expect(mockFindById).not.toHaveBeenCalled();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not load driver when there is no primary driver id', async () => {
|
||||
mockedAuthValue = { session: { user: { id: 'user-1' } } };
|
||||
mockedDriverId = null;
|
||||
|
||||
const { container } = render(<UserPill />);
|
||||
|
||||
await waitFor(() => {
|
||||
// component should render nothing in this state
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(mockFindById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads driver via driverService and uses mediaService avatar', async () => {
|
||||
const driver: DriverDTO = {
|
||||
id: 'driver-1',
|
||||
iracingId: 'ir-123',
|
||||
name: 'Test Driver',
|
||||
country: 'DE',
|
||||
};
|
||||
|
||||
mockedAuthValue = { session: { user: { id: 'user-1' } } };
|
||||
mockedDriverId = driver.id;
|
||||
|
||||
mockFindById.mockResolvedValue(driver);
|
||||
mockGetDriverAvatar.mockImplementation((driverId: string) => `/api/media/avatar/${driverId}`);
|
||||
|
||||
render(<UserPill />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Driver')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockFindById).toHaveBeenCalledWith('driver-1');
|
||||
expect(mockGetDriverAvatar).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
|
||||
|
||||
// TODO EntityMapper is legacy. Must use ´useServices` hook.
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
// Hook to detect sponsor mode
|
||||
function useSponsorMode(): boolean {
|
||||
@@ -84,6 +82,7 @@ function SponsorSummaryPill({
|
||||
|
||||
export default function UserPill() {
|
||||
const { session } = useAuth();
|
||||
const { driverService, mediaService } = useServices();
|
||||
const [driver, setDriver] = useState<DriverDTO | null>(null);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const isSponsorMode = useSponsorMode();
|
||||
@@ -103,19 +102,18 @@ export default function UserPill() {
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = getDriverRepository();
|
||||
const entity = await repo.findById(primaryDriverId);
|
||||
const dto = await driverService.findById(primaryDriverId);
|
||||
if (!cancelled) {
|
||||
setDriver(EntityMappers.toDriverDTO(entity));
|
||||
setDriver(dto);
|
||||
}
|
||||
}
|
||||
|
||||
loadDriver();
|
||||
void loadDriver();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [primaryDriverId]);
|
||||
}, [primaryDriverId, driverService]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!session?.user || !primaryDriverId || !driver) {
|
||||
@@ -153,7 +151,7 @@ export default function UserPill() {
|
||||
}
|
||||
}
|
||||
|
||||
const avatarSrc = getImageService().getDriverAvatar(primaryDriverId);
|
||||
const avatarSrc = mediaService.getDriverAvatar(primaryDriverId);
|
||||
|
||||
return {
|
||||
driver,
|
||||
@@ -161,7 +159,7 @@ export default function UserPill() {
|
||||
rating,
|
||||
rank,
|
||||
};
|
||||
}, [session, driver, primaryDriverId]);
|
||||
}, [session, driver, primaryDriverId, mediaService]);
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
|
||||
@@ -85,4 +85,9 @@ export class RacesApiClient extends BaseApiClient {
|
||||
complete(raceId: string): Promise<void> {
|
||||
return this.post<void>(`/races/${raceId}/complete`, {});
|
||||
}
|
||||
|
||||
/** Re-open race */
|
||||
reopen(raceId: string): Promise<void> {
|
||||
return this.post<void>(`/races/${raceId}/reopen`, {});
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,12 @@ describe('RaceService', () => {
|
||||
getDetail: vi.fn(),
|
||||
getPageData: vi.fn(),
|
||||
getTotal: vi.fn(),
|
||||
} as Mocked<RacesApiClient>;
|
||||
register: vi.fn(),
|
||||
withdraw: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
reopen: vi.fn(),
|
||||
} as unknown as Mocked<RacesApiClient>;
|
||||
|
||||
service = new RaceService(mockApiClient);
|
||||
});
|
||||
@@ -131,4 +136,22 @@ describe('RaceService', () => {
|
||||
await expect(service.getRacesTotal()).rejects.toThrow('API call failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reopenRace', () => {
|
||||
it('should call apiClient.reopen with raceId', async () => {
|
||||
const raceId = 'race-123';
|
||||
|
||||
await service.reopenRace(raceId);
|
||||
|
||||
expect(mockApiClient.reopen).toHaveBeenCalledWith(raceId);
|
||||
});
|
||||
|
||||
it('should propagate errors from apiClient.reopen', async () => {
|
||||
const raceId = 'race-123';
|
||||
const error = new Error('API call failed');
|
||||
mockApiClient.reopen.mockRejectedValue(error);
|
||||
|
||||
await expect(service.reopenRace(raceId)).rejects.toThrow('API call failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,12 @@ export class RaceService {
|
||||
await this.apiClient.complete(raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open a race
|
||||
*/
|
||||
async reopenRace(raceId: string): Promise<void> {
|
||||
await this.apiClient.reopen(raceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find races by league ID
|
||||
|
||||
@@ -14,6 +14,9 @@ export class ProtestViewModel {
|
||||
status: string;
|
||||
reviewedAt?: string;
|
||||
decisionNotes?: string;
|
||||
incident?: { lap?: number } | null;
|
||||
proofVideoUrl?: string | null;
|
||||
comment?: string | null;
|
||||
|
||||
constructor(dto: ProtestDTO) {
|
||||
this.id = dto.id;
|
||||
|
||||
@@ -252,6 +252,36 @@ describe('RaceDetailViewModel', () => {
|
||||
expect(viewModel.registrationStatusMessage).toBe('Registration not available');
|
||||
});
|
||||
|
||||
it('should expose canReopenRace for completed and cancelled statuses', () => {
|
||||
const completedVm = new RaceDetailViewModel({
|
||||
race: createMockRace({ status: 'completed' }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
const cancelledVm = new RaceDetailViewModel({
|
||||
race: createMockRace({ status: 'cancelled' as any }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
const upcomingVm = new RaceDetailViewModel({
|
||||
race: createMockRace({ status: 'upcoming' }),
|
||||
league: createMockLeague(),
|
||||
entryList: [],
|
||||
registration: createMockRegistration(),
|
||||
userResult: null,
|
||||
});
|
||||
|
||||
expect(completedVm.canReopenRace).toBe(true);
|
||||
expect(cancelledVm.canReopenRace).toBe(true);
|
||||
expect(upcomingVm.canReopenRace).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error property', () => {
|
||||
const viewModel = new RaceDetailViewModel({
|
||||
race: createMockRace(),
|
||||
|
||||
@@ -70,4 +70,10 @@ export class RaceDetailViewModel {
|
||||
if (this.canRegister) return 'You can register for this race';
|
||||
return 'Registration not available';
|
||||
}
|
||||
|
||||
/** UI-specific: Whether race can be re-opened */
|
||||
get canReopenRace(): boolean {
|
||||
if (!this.race) return false;
|
||||
return this.race.status === 'completed' || this.race.status === 'cancelled';
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,24 @@
|
||||
*/
|
||||
|
||||
// Use Cases
|
||||
export * from './use-cases/SendNotificationUseCase';
|
||||
export * from './use-cases/MarkNotificationReadUseCase';
|
||||
export * from './use-cases/GetUnreadNotificationsUseCase';
|
||||
export * from './use-cases/MarkNotificationReadUseCase';
|
||||
export * from './use-cases/NotificationPreferencesUseCases';
|
||||
export * from './use-cases/SendNotificationUseCase';
|
||||
|
||||
// Ports
|
||||
export * from './ports/INotificationGateway';
|
||||
export * from './ports/NotificationGateway';
|
||||
|
||||
// Re-export domain types for convenience
|
||||
export type {
|
||||
Notification,
|
||||
NotificationProps,
|
||||
NotificationStatus,
|
||||
NotificationData,
|
||||
NotificationUrgency,
|
||||
NotificationAction,
|
||||
Notification, NotificationAction, NotificationData, NotificationProps,
|
||||
NotificationStatus, NotificationUrgency
|
||||
} from '../domain/entities/Notification';
|
||||
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference';
|
||||
export type { NotificationType, NotificationChannel } from '../domain/types/NotificationTypes';
|
||||
export { getNotificationTypeTitle, getNotificationTypePriority, getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/types/NotificationTypes';
|
||||
export type { ChannelPreference, NotificationPreference, NotificationPreferenceProps, TypePreference } from '../domain/entities/NotificationPreference';
|
||||
export { ALL_CHANNELS, DEFAULT_ENABLED_CHANNELS, getChannelDisplayName, getNotificationTypePriority, getNotificationTypeTitle, isExternalChannel } from '../domain/types/NotificationTypes';
|
||||
export type { NotificationChannel, NotificationType } from '../domain/types/NotificationTypes';
|
||||
|
||||
// Re-export repository interfaces
|
||||
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';
|
||||
export type { INotificationRepository } from '../domain/repositories/INotificationRepository';
|
||||
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface NotificationDeliveryResult {
|
||||
attemptedAt: Date;
|
||||
}
|
||||
|
||||
export interface INotificationGateway {
|
||||
export interface NotificationGateway {
|
||||
/**
|
||||
* Send a notification through this gateway's channel
|
||||
*/
|
||||
@@ -45,21 +45,21 @@ export interface INotificationGateway {
|
||||
* Registry for notification gateways
|
||||
* Allows routing notifications to the appropriate gateway based on channel
|
||||
*/
|
||||
export interface INotificationGatewayRegistry {
|
||||
export interface NotificationGatewayRegistry {
|
||||
/**
|
||||
* Register a gateway for a channel
|
||||
*/
|
||||
register(gateway: INotificationGateway): void;
|
||||
register(gateway: NotificationGateway): void;
|
||||
|
||||
/**
|
||||
* Get gateway for a specific channel
|
||||
*/
|
||||
getGateway(channel: NotificationChannel): INotificationGateway | null;
|
||||
getGateway(channel: NotificationChannel): NotificationGateway | null;
|
||||
|
||||
/**
|
||||
* Get all registered gateways
|
||||
*/
|
||||
getAllGateways(): INotificationGateway[];
|
||||
getAllGateways(): NotificationGateway[];
|
||||
|
||||
/**
|
||||
* Send notification through appropriate gateway
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NotificationType } from '../../domain/types/NotificationTypes';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface NotificationData {
|
||||
raceEventId?: string;
|
||||
@@ -36,6 +35,6 @@ export interface SendNotificationCommand {
|
||||
requiresResponse?: boolean;
|
||||
}
|
||||
|
||||
export interface INotificationService {
|
||||
export interface NotificationService {
|
||||
sendNotification(command: SendNotificationCommand): Promise<void>;
|
||||
}
|
||||
@@ -5,15 +5,14 @@
|
||||
* based on their preferences.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase, Logger } from '@core/shared/application';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { AsyncUseCase } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationData } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
|
||||
import type { NotificationDeliveryResult, NotificationGatewayRegistry } from '../ports/NotificationGateway';
|
||||
|
||||
export interface SendNotificationCommand {
|
||||
recipientId: string;
|
||||
@@ -48,7 +47,7 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly gatewayRegistry: INotificationGatewayRegistry,
|
||||
private readonly gatewayRegistry: NotificationGatewayRegistry,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.logger.debug('SendNotificationUseCase initialized.');
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { AcceptSponsorshipRequestUseCase } from './AcceptSponsorshipRequestUseCase';
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { INotificationService } from '@core/notifications/application/ports/INotificationService';
|
||||
import type { NotificationService } from '@/notifications/application/ports/NotificationService';
|
||||
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
|
||||
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
|
||||
import { Season } from '../../domain/entities/Season';
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { LeagueWallet } from '../../domain/entities/LeagueWallet';
|
||||
import { Season } from '../../domain/entities/Season';
|
||||
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
|
||||
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import { Money } from '../../domain/value-objects/Money';
|
||||
import { AcceptSponsorshipRequestUseCase } from './AcceptSponsorshipRequestUseCase';
|
||||
|
||||
describe('AcceptSponsorshipRequestUseCase', () => {
|
||||
let mockSponsorshipRequestRepo: {
|
||||
@@ -78,7 +78,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
|
||||
mockSeasonRepo as unknown as ISeasonRepository,
|
||||
mockNotificationService as unknown as INotificationService,
|
||||
mockNotificationService as unknown as NotificationService,
|
||||
processPayment,
|
||||
mockWalletRepo as unknown as IWalletRepository,
|
||||
mockLeagueWalletRepo as unknown as ILeagueWalletRepository,
|
||||
|
||||
@@ -5,20 +5,19 @@
|
||||
* This creates an active sponsorship and notifies the sponsor.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { INotificationService } from '@core/notifications/application/ports/INotificationService';
|
||||
import type { NotificationService } from '@/notifications/application/ports/NotificationService';
|
||||
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
|
||||
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
|
||||
import type { AsyncUseCase } from '@core/shared/application';
|
||||
import type { AsyncUseCase, Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
|
||||
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||
import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO';
|
||||
import type { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort';
|
||||
import type { ProcessPaymentInputPort } from '../ports/input/ProcessPaymentInputPort';
|
||||
import type { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort';
|
||||
import type { ProcessPaymentOutputPort } from '../ports/output/ProcessPaymentOutputPort';
|
||||
|
||||
export class AcceptSponsorshipRequestUseCase
|
||||
@@ -27,7 +26,7 @@ export class AcceptSponsorshipRequestUseCase
|
||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly notificationService: INotificationService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly paymentProcessor: (input: ProcessPaymentInputPort) => Promise<ProcessPaymentOutputPort>,
|
||||
private readonly walletRepository: IWalletRepository,
|
||||
private readonly leagueWalletRepository: ILeagueWalletRepository,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SendFinalResultsUseCase } from './SendFinalResultsUseCase';
|
||||
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
|
||||
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||
import { SendFinalResultsUseCase } from './SendFinalResultsUseCase';
|
||||
|
||||
describe('SendFinalResultsUseCase', () => {
|
||||
it('sends final results notifications to all participating drivers', async () => {
|
||||
const mockNotificationService = {
|
||||
sendNotification: vi.fn(),
|
||||
} as unknown as INotificationService;
|
||||
} as unknown as NotificationService;
|
||||
|
||||
const mockRaceEvent = {
|
||||
id: 'race-1',
|
||||
@@ -107,7 +107,7 @@ describe('SendFinalResultsUseCase', () => {
|
||||
it('skips sending notifications if race event not found', async () => {
|
||||
const mockNotificationService = {
|
||||
sendNotification: vi.fn(),
|
||||
} as unknown as INotificationService;
|
||||
} as unknown as NotificationService;
|
||||
|
||||
const mockRaceEventRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
@@ -146,7 +146,7 @@ describe('SendFinalResultsUseCase', () => {
|
||||
it('skips sending notifications if no main race session', async () => {
|
||||
const mockNotificationService = {
|
||||
sendNotification: vi.fn(),
|
||||
} as unknown as INotificationService;
|
||||
} as unknown as NotificationService;
|
||||
|
||||
const mockRaceEvent = {
|
||||
id: 'race-1',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||
import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
|
||||
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
|
||||
import type { RaceEvent } from '../../domain/entities/RaceEvent';
|
||||
import type { Result as RaceResult } from '../../domain/entities/Result';
|
||||
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
|
||||
/**
|
||||
* Use Case: SendFinalResultsUseCase
|
||||
@@ -17,7 +17,7 @@ import type { Result as RaceResult } from '../../domain/entities/Result';
|
||||
*/
|
||||
export class SendFinalResultsUseCase {
|
||||
constructor(
|
||||
private readonly notificationService: INotificationService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly raceEventRepository: IRaceEventRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
) {}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase';
|
||||
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
|
||||
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
|
||||
import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase';
|
||||
|
||||
describe('SendPerformanceSummaryUseCase', () => {
|
||||
it('sends performance summary notifications to all participating drivers', async () => {
|
||||
const mockNotificationService = {
|
||||
sendNotification: vi.fn(),
|
||||
} as unknown as INotificationService;
|
||||
} as unknown as NotificationService;
|
||||
|
||||
const mockRaceEvent = {
|
||||
id: 'race-1',
|
||||
@@ -106,7 +106,7 @@ describe('SendPerformanceSummaryUseCase', () => {
|
||||
it('skips sending notifications if race event not found', async () => {
|
||||
const mockNotificationService = {
|
||||
sendNotification: vi.fn(),
|
||||
} as unknown as INotificationService;
|
||||
} as unknown as NotificationService;
|
||||
|
||||
const mockRaceEventRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
|
||||
import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
|
||||
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
|
||||
import type { RaceEvent } from '../../domain/entities/RaceEvent';
|
||||
import type { Result as RaceResult } from '../../domain/entities/Result';
|
||||
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
|
||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
|
||||
/**
|
||||
* Use Case: SendPerformanceSummaryUseCase
|
||||
@@ -16,7 +16,7 @@ import type { Result as RaceResult } from '../../domain/entities/Result';
|
||||
*/
|
||||
export class SendPerformanceSummaryUseCase {
|
||||
constructor(
|
||||
private readonly notificationService: INotificationService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly raceEventRepository: IRaceEventRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
) {}
|
||||
|
||||
244
docs/architecture/CQRS.md
Normal file
244
docs/architecture/CQRS.md
Normal file
@@ -0,0 +1,244 @@
|
||||
CQRS Light with Clean Architecture
|
||||
|
||||
This document defines CQRS Light as a pragmatic, production-ready approach that integrates cleanly with Clean Architecture.
|
||||
|
||||
It is intentionally non-dogmatic, avoids event-sourcing overhead, and focuses on clarity, performance, and maintainability.
|
||||
|
||||
⸻
|
||||
|
||||
1. What CQRS Light Is
|
||||
|
||||
CQRS Light separates how the system writes data from how it reads data — without changing the core architecture.
|
||||
|
||||
Key properties:
|
||||
• Commands and Queries are separated logically, not infrastructurally
|
||||
• Same database is allowed
|
||||
• No event bus required
|
||||
• No eventual consistency by default
|
||||
|
||||
CQRS Light is an optimization, not a foundation.
|
||||
|
||||
⸻
|
||||
|
||||
2. What CQRS Light Is NOT
|
||||
|
||||
CQRS Light explicitly does not include:
|
||||
• Event Sourcing
|
||||
• Message brokers
|
||||
• Projections as a hard requirement
|
||||
• Separate databases
|
||||
• Microservices
|
||||
|
||||
Those can be added later if needed.
|
||||
|
||||
⸻
|
||||
|
||||
3. Why CQRS Light Exists
|
||||
|
||||
Without CQRS:
|
||||
• Reads are forced through domain aggregates
|
||||
• Aggregates grow unnaturally large
|
||||
• Reporting logic pollutes the domain
|
||||
• Performance degrades due to object loading
|
||||
|
||||
CQRS Light solves this by allowing:
|
||||
• Strict domain logic on writes
|
||||
• Flexible, optimized reads
|
||||
|
||||
⸻
|
||||
|
||||
4. Core Architectural Principle
|
||||
|
||||
Writes protect invariants. Reads optimize information access.
|
||||
|
||||
Therefore:
|
||||
• Commands enforce business rules
|
||||
• Queries are allowed to be pragmatic and denormalized
|
||||
|
||||
⸻
|
||||
|
||||
5. Placement in Clean Architecture
|
||||
|
||||
CQRS Light does not introduce new layers.
|
||||
It reorganizes existing ones.
|
||||
|
||||
core/
|
||||
└── <context>/
|
||||
└── application/
|
||||
├── commands/ # Write side (Use Cases)
|
||||
└── queries/ # Read side (Query Use Cases)
|
||||
|
||||
Domain remains unchanged.
|
||||
|
||||
⸻
|
||||
|
||||
6. Command Side (Write Model)
|
||||
|
||||
Purpose
|
||||
• Modify state
|
||||
• Enforce invariants
|
||||
• Emit outcomes
|
||||
|
||||
Characteristics
|
||||
• Uses Domain Entities and Value Objects
|
||||
• Uses Repositories
|
||||
• Uses Output Ports
|
||||
• Transactional
|
||||
|
||||
Example Structure
|
||||
|
||||
core/racing/application/commands/
|
||||
├── CreateLeagueUseCase.ts
|
||||
├── ApplyPenaltyUseCase.ts
|
||||
└── RegisterForRaceUseCase.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
7. Query Side (Read Model)
|
||||
|
||||
Purpose
|
||||
• Read state
|
||||
• Aggregate data
|
||||
• Serve UI efficiently
|
||||
|
||||
Characteristics
|
||||
• No domain entities
|
||||
• No invariants
|
||||
• No side effects
|
||||
• May use SQL/ORM directly
|
||||
|
||||
Example Structure
|
||||
|
||||
core/racing/application/queries/
|
||||
├── GetLeagueStandingsQuery.ts
|
||||
├── GetDashboardOverviewQuery.ts
|
||||
└── GetDriverStatsQuery.ts
|
||||
|
||||
Queries are still Use Cases, just read-only ones.
|
||||
|
||||
⸻
|
||||
|
||||
8. Repositories in CQRS Light
|
||||
|
||||
Write Repositories
|
||||
• Used by command use cases
|
||||
• Work with domain entities
|
||||
• Enforce consistency
|
||||
|
||||
core/racing/domain/repositories/
|
||||
└── LeagueRepositoryPort.ts
|
||||
|
||||
|
||||
⸻
|
||||
|
||||
Read Repositories
|
||||
• Used by query use cases
|
||||
• Return flat, optimized data
|
||||
• Not domain repositories
|
||||
|
||||
core/racing/application/ports/
|
||||
└── LeagueStandingsReadPort.ts
|
||||
|
||||
Implementation lives in adapters.
|
||||
|
||||
⸻
|
||||
|
||||
9. Performance Benefits (Why It Matters)
|
||||
|
||||
Without CQRS Light
|
||||
• Aggregate loading
|
||||
• N+1 queries
|
||||
• Heavy object graphs
|
||||
• CPU and memory overhead
|
||||
|
||||
With CQRS Light
|
||||
• Single optimized queries
|
||||
• Minimal data transfer
|
||||
• Database does aggregation
|
||||
• Lower memory footprint
|
||||
|
||||
This results in:
|
||||
• Faster endpoints
|
||||
• Simpler code
|
||||
• Easier scaling
|
||||
|
||||
⸻
|
||||
|
||||
10. Testing Strategy
|
||||
|
||||
Commands
|
||||
• Unit tests
|
||||
• Mock repositories and ports
|
||||
• Verify output port calls
|
||||
|
||||
Queries
|
||||
• Simple unit tests
|
||||
• Input → Output verification
|
||||
• No mocks beyond data source
|
||||
|
||||
CQRS Light reduces the need for complex integration tests.
|
||||
|
||||
⸻
|
||||
|
||||
11. When CQRS Light Is a Good Fit
|
||||
|
||||
Use CQRS Light when:
|
||||
• Read complexity is high
|
||||
• Write logic must stay strict
|
||||
• Dashboards or analytics exist
|
||||
• Multiple clients consume the system
|
||||
|
||||
Avoid CQRS Light when:
|
||||
• Application is CRUD-only
|
||||
• Data volume is small
|
||||
• Read/write patterns are identical
|
||||
|
||||
⸻
|
||||
|
||||
12. Migration Path
|
||||
|
||||
CQRS Light allows incremental adoption:
|
||||
1. Start with classic Clean Architecture
|
||||
2. Separate commands and queries logically
|
||||
3. Optimize read paths as needed
|
||||
4. Introduce events or projections later (optional)
|
||||
|
||||
No rewrites required.
|
||||
|
||||
⸻
|
||||
|
||||
13. Key Rules (Non-Negotiable)
|
||||
• Commands MUST use the domain
|
||||
• Queries MUST NOT modify state
|
||||
• Queries MUST NOT enforce invariants
|
||||
• Domain MUST NOT depend on queries
|
||||
• Core remains framework-agnostic
|
||||
|
||||
⸻
|
||||
|
||||
14. Mental Model
|
||||
|
||||
Think of CQRS Light as:
|
||||
|
||||
One core truth, two access patterns.
|
||||
|
||||
The domain defines truth.
|
||||
Queries define convenience.
|
||||
|
||||
⸻
|
||||
|
||||
15. Final Summary
|
||||
|
||||
CQRS Light provides:
|
||||
• Cleaner domain models
|
||||
• Faster reads
|
||||
• Reduced complexity
|
||||
• Future scalability
|
||||
|
||||
Without:
|
||||
• Infrastructure overhead
|
||||
• Event sourcing complexity
|
||||
• Premature optimization
|
||||
|
||||
It is the safest way to gain CQRS benefits while staying true to Clean Architecture.
|
||||
Reference in New Issue
Block a user