fix issues in core

This commit is contained in:
2025-12-23 11:25:08 +01:00
parent 1efd971032
commit 2854ae3c5c
113 changed files with 1142 additions and 458 deletions

View File

@@ -0,0 +1,93 @@
export interface LeagueConfigFormModel {
basics?: {
name?: string;
description?: string;
visibility?: string;
gameId?: string;
};
structure?: {
mode?: string;
maxDrivers?: number;
};
championships?: {
enableDriverChampionship?: boolean;
enableTeamChampionship?: boolean;
enableNationsChampionship?: boolean;
enableTrophyChampionship?: boolean;
};
scoring?: {
patternId?: string;
customScoringEnabled?: boolean;
};
dropPolicy?: {
strategy?: string;
n?: number;
};
timings?: {
qualifyingMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
seasonStartDate?: string;
raceStartTime?: string;
timezoneId?: string;
recurrenceStrategy?: string;
weekdays?: string[];
intervalWeeks?: number;
monthlyOrdinal?: number;
monthlyWeekday?: string;
};
stewarding?: {
decisionMode?: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
};
}
export interface LeagueStructureFormDTO {
name: string;
description: string;
ownerId: string;
}
export interface LeagueChampionshipsFormDTO {
pointsSystem: string;
customPoints?: Record<number, number>;
}
export interface LeagueScoringFormDTO {
pointsSystem: string;
customPoints?: Record<number, number>;
}
export interface LeagueDropPolicyFormDTO {
dropWeeks?: number;
bestResults?: number;
}
export interface LeagueStructureMode {
mode: 'simple' | 'advanced';
}
export interface LeagueTimingsFormDTO {
sessionDuration?: number;
qualifyingFormat?: string;
}
export interface LeagueStewardingFormDTO {
decisionMode: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
}

View File

@@ -0,0 +1,30 @@
export interface LeagueDTO {
id: string;
name: string;
description: string;
ownerId: string;
settings: {
pointsSystem: string;
sessionDuration?: number;
qualifyingFormat?: string;
customPoints?: Record<number, number>;
maxDrivers?: number;
stewarding?: {
decisionMode: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
};
};
createdAt: Date;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
}

View File

@@ -0,0 +1,11 @@
export interface LeagueDriverSeasonStatsDTO {
driverId: string;
leagueId: string;
seasonId: string;
totalPoints: number;
averagePoints: number;
bestFinish: number;
podiums: number;
races: number;
wins: number;
}

View File

@@ -0,0 +1,21 @@
export interface LeagueScheduleDTO {
leagueId: string;
seasonId: string;
races: Array<{
id: string;
name: string;
scheduledTime: Date;
trackId: string;
status: string;
}>;
}
export interface LeagueSchedulePreviewDTO {
leagueId: string;
preview: Array<{
id: string;
name: string;
scheduledTime: Date;
trackId: string;
}>;
}

View File

@@ -0,0 +1,9 @@
export interface RaceDTO {
id: string;
leagueId: string;
name: string;
scheduledTime: Date;
trackId: string;
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
results?: string[];
}

View File

@@ -1,3 +0,0 @@
export interface ReopenRaceCommandDTO {
raceId: string;
}

View File

@@ -0,0 +1,9 @@
export interface ResultDTO {
id: string;
raceId: string;
driverId: string;
position: number;
points: number;
time?: string;
incidents?: number;
}

View File

@@ -0,0 +1,10 @@
export interface StandingDTO {
id: string;
leagueId: string;
driverId: string;
position: number;
points: number;
races: number;
wins: number;
podiums: number;
}

View File

@@ -0,0 +1,7 @@
export * from './LeagueConfigFormDTO';
export * from './LeagueDTO';
export * from './LeagueDriverSeasonStatsDTO';
export * from './LeagueScheduleDTO';
export * from './RaceDTO';
export * from './ResultDTO';
export * from './StandingDTO';

View File

@@ -0,0 +1,27 @@
export interface DriverRatingChange {
driverId: string;
oldRating: number;
newRating: number;
change: number;
}
export interface RatingChange {
driverId: string;
oldRating: number;
newRating: number;
change: number;
}
export interface DriverRatingPort {
calculateRatingChange(
driverId: string,
raceId: string,
finalPosition: number,
incidents: number,
baseRating: number,
): Promise<RatingChange>;
getDriverRating(driverId: string): Promise<number>;
updateDriverRating(driverId: string, newRating: number): Promise<void>;
}

View File

@@ -0,0 +1,15 @@
export interface AllRacesPageOutputPort {
races: Array<{
id: string;
name: string;
leagueId: string;
leagueName: string;
scheduledTime: Date;
trackId: string;
status: string;
participants: number;
}>;
total: number;
page: number;
limit: number;
}

View File

@@ -0,0 +1,12 @@
export interface ChampionshipStandingsOutputPort {
leagueId: string;
seasonId: string;
standings: Array<{
driverId: string;
position: number;
points: number;
driverName: string;
teamId?: string;
teamName?: string;
}>;
}

View File

@@ -0,0 +1,8 @@
export interface ChampionshipStandingsRowOutputPort {
driverId: string;
position: number;
points: number;
driverName: string;
teamId?: string;
teamName?: string;
}

View File

@@ -0,0 +1,7 @@
export interface DriverRegistrationStatusOutputPort {
driverId: string;
raceId: string;
leagueId: string;
registered: boolean;
status: 'registered' | 'withdrawn' | 'pending' | 'not_registered';
}

View File

@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
import { Season } from '../../domain/entities/Season';
import { Season } from '../../domain/entities/season/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { Weekday } from '../../domain/types/Weekday';
@@ -32,7 +32,7 @@ export interface SeasonSummaryDTO {
seasonId: string;
leagueId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
status: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
startDate?: Date;
endDate?: Date;
isPrimary: boolean;
@@ -56,7 +56,7 @@ export interface SeasonDetailsDTO {
leagueId: string;
gameId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
status: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
startDate?: Date;
endDate?: Date;
maxDrivers?: number;
@@ -69,11 +69,11 @@ export interface SeasonDetailsDTO {
customScoringEnabled: boolean;
};
dropPolicy?: {
strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy;
strategy: string;
n?: number;
};
stewarding?: {
decisionMode: import('../../domain/entities/League').StewardingDecisionMode;
decisionMode: string;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
@@ -95,7 +95,7 @@ export interface ManageSeasonLifecycleCommand {
export interface ManageSeasonLifecycleResultDTO {
seasonId: string;
status: import('../../domain/entities/Season').SeasonStatus;
status: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
startDate?: Date;
endDate?: Date;
}
@@ -140,7 +140,7 @@ export class SeasonApplicationService {
const season = Season.create({
id: seasonId,
leagueId: league.id,
leagueId: league.id.toString(),
gameId: command.gameId,
name: command.name,
year: new Date().getFullYear(),
@@ -163,7 +163,7 @@ export class SeasonApplicationService {
throw new Error(`League not found: ${query.leagueId}`);
}
const seasons = await this.seasonRepository.listByLeague(league.id);
const seasons = await this.seasonRepository.listByLeague(league.id.toString());
const items: SeasonSummaryDTO[] = seasons.map((s) => ({
seasonId: s.id,
leagueId: s.leagueId,
@@ -184,7 +184,7 @@ export class SeasonApplicationService {
}
const season = await this.seasonRepository.findById(query.seasonId);
if (!season || season.leagueId !== league.id) {
if (!season || season.leagueId !== league.id.toString()) {
throw new Error(`Season ${query.seasonId} does not belong to league ${league.id}`);
}
@@ -248,7 +248,7 @@ export class SeasonApplicationService {
}
const season = await this.seasonRepository.findById(command.seasonId);
if (!season || season.leagueId !== league.id) {
if (!season || season.leagueId !== league.id.toString()) {
throw new Error(`Season ${command.seasonId} does not belong to league ${league.id}`);
}
@@ -288,29 +288,38 @@ export class SeasonApplicationService {
maxDrivers?: number;
} {
const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
});
const dropPolicy = new SeasonDropPolicy({
strategy: config.dropPolicy.strategy,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
});
const stewardingConfig = new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode,
...(config.stewarding.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense,
defenseTimeLimit: config.stewarding.defenseTimeLimit,
voteTimeLimit: config.stewarding.voteTimeLimit,
protestDeadlineHours: config.stewarding.protestDeadlineHours,
stewardingClosesHours: config.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
});
const scoringConfig = config.scoring
? new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
})
: undefined;
const structure = config.structure;
const dropPolicy = config.dropPolicy
? new SeasonDropPolicy({
strategy: config.dropPolicy.strategy as any,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
})
: undefined;
const stewardingConfig = config.stewarding
? new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode as any,
...(config.stewarding.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense ?? false,
defenseTimeLimit: config.stewarding.defenseTimeLimit ?? 48,
voteTimeLimit: config.stewarding.voteTimeLimit ?? 72,
protestDeadlineHours: config.stewarding.protestDeadlineHours ?? 48,
stewardingClosesHours: config.stewarding.stewardingClosesHours ?? 168,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired ?? true,
})
: undefined;
const structure = config.structure ?? {};
const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers
@@ -318,28 +327,28 @@ export class SeasonApplicationService {
return {
...(schedule !== undefined ? { schedule } : {}),
scoringConfig,
dropPolicy,
stewardingConfig,
...(scoringConfig !== undefined ? { scoringConfig } : {}),
...(dropPolicy !== undefined ? { dropPolicy } : {}),
...(stewardingConfig !== undefined ? { stewardingConfig } : {}),
...(maxDrivers !== undefined ? { maxDrivers } : {}),
};
}
private buildScheduleFromTimings(config: LeagueConfigFormModel): SeasonSchedule | undefined {
const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) {
if (!timings || !timings.seasonStartDate || !timings.raceStartTime) {
return undefined;
}
const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId);
const timezone = LeagueTimezone.create(timezoneId);
const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
? timings.roundsPlanned
: timings.sessionCount;
: timings.sessionCount ?? 0;
const recurrence = (() => {
const weekdays: WeekdaySet =
@@ -353,10 +362,10 @@ export class SeasonApplicationService {
weekdays,
);
case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday,
});
const pattern = MonthlyRecurrencePattern.create(
(timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
(timings.monthlyWeekday ?? 'Mon') as Weekday,
);
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
}
case 'weekly':

View File

@@ -1,4 +1,4 @@
import type { NotificationService } from '@/notifications/application/ports/NotificationService';
import type { NotificationService } from '@core/notifications/application/ports/NotificationService';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Logger } from '@core/shared/application';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';

View File

@@ -5,7 +5,7 @@
* This creates an active sponsorship and notifies the sponsor.
*/
import type { NotificationService } from '@/notifications/application/ports/NotificationService';
import type { NotificationService } from '@core/notifications/application/ports/NotificationService';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';

View File

@@ -200,11 +200,16 @@ describe('ApplyForSponsorshipUseCase', () => {
});
it('should return error when offered amount is less than minimum', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
@@ -228,11 +233,16 @@ describe('ApplyForSponsorshipUseCase', () => {
});
it('should create sponsorship request and return result on success', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });

View File

@@ -52,6 +52,7 @@ export class ApplyForSponsorshipUseCase {
| 'NO_SLOTS_AVAILABLE'
| 'PENDING_REQUEST_EXISTS'
| 'OFFERED_AMOUNT_TOO_LOW'
| 'VALIDATION_ERROR'
>
>
> {
@@ -82,10 +83,17 @@ export class ApplyForSponsorshipUseCase {
return Result.err({ code: 'ENTITY_NOT_ACCEPTING_APPLICATIONS' });
}
// Validate tier type
const tier = input.tier as 'main' | 'secondary';
if (tier !== 'main' && tier !== 'secondary') {
this.logger.warn('Invalid tier', { tier: input.tier });
return Result.err({ code: 'VALIDATION_ERROR' });
}
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(input.tier);
const slotAvailable = pricing.isSlotAvailable(tier);
if (!slotAvailable) {
this.logger.warn(`No ${input.tier} sponsorship slots are available for entity ${input.entityId}`);
this.logger.warn(`No ${tier} sponsorship slots are available for entity ${input.entityId}`);
return Result.err({ code: 'NO_SLOTS_AVAILABLE' });
}
@@ -105,24 +113,24 @@ export class ApplyForSponsorshipUseCase {
}
// Validate offered amount meets minimum price
const minPrice = pricing.getPrice(input.tier);
const minPrice = pricing.getPrice(tier);
if (minPrice && input.offeredAmount < minPrice.amount) {
this.logger.warn(
`Offered amount ${input.offeredAmount} is less than minimum ${minPrice.amount} for entity ${input.entityId}, tier ${input.tier}`,
`Offered amount ${input.offeredAmount} is less than minimum ${minPrice.amount} for entity ${input.entityId}, tier ${tier}`,
);
return Result.err({ code: 'OFFERED_AMOUNT_TOO_LOW' });
}
// Create the sponsorship request
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const offeredAmount = Money.create(input.offeredAmount, input.currency ?? 'USD');
const offeredAmount = Money.create(input.offeredAmount, (input.currency as any) || 'USD');
const request = SponsorshipRequest.create({
id: requestId,
sponsorId: input.sponsorId,
entityType: input.entityType,
entityId: input.entityId,
tier: input.tier,
tier,
offeredAmount,
...(input.message !== undefined ? { message: input.message } : {}),
});

View File

@@ -5,7 +5,6 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ApplyPenaltyUseCase', () => {
let mockPenaltyRepo: {
@@ -49,12 +48,17 @@ describe('ApplyPenaltyUseCase', () => {
});
it('should return error when race does not exist', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger,
output as any,
);
mockRaceRepo.findById.mockResolvedValue(null);
@@ -73,12 +77,17 @@ describe('ApplyPenaltyUseCase', () => {
});
it('should return error when steward does not have authority', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger,
output as any,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -100,12 +109,17 @@ describe('ApplyPenaltyUseCase', () => {
});
it('should return error when protest does not exist', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger,
output as any,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -129,12 +143,17 @@ describe('ApplyPenaltyUseCase', () => {
});
it('should return error when protest is not upheld', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger,
output as any,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -158,12 +177,17 @@ describe('ApplyPenaltyUseCase', () => {
});
it('should return error when protest is not for this race', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger,
output as any,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -187,12 +211,17 @@ describe('ApplyPenaltyUseCase', () => {
});
it('should create penalty and return result on success', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger,
output as any,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });

View File

@@ -68,10 +68,10 @@ export class ApplyPenaltyUseCase {
// Validate steward has authority (owner or admin of the league)
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const stewardMembership = memberships.find(
m => m.driverId === command.stewardId && m.status === 'active'
m => m.driverId.toString() === command.stewardId && m.status.toString() === 'active'
);
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
if (!stewardMembership || (stewardMembership.role.toString() !== 'owner' && stewardMembership.role.toString() !== 'admin')) {
this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`);
return Result.err({ code: 'INSUFFICIENT_AUTHORITY' });
}
@@ -84,7 +84,7 @@ export class ApplyPenaltyUseCase {
this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`);
return Result.err({ code: 'PROTEST_NOT_FOUND' });
}
if (protest.status !== 'upheld') {
if (protest.status.toString() !== 'upheld') {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`);
return Result.err({ code: 'PROTEST_NOT_UPHELD' });
}

View File

@@ -25,7 +25,6 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output as unknown as UseCaseOutputPort<any>,
);
const leagueId = 'league-1';
@@ -34,22 +33,24 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue(joinRequests);
const result = await useCase.execute({ leagueId, requestId });
const result = await useCase.execute({ leagueId, requestId }, output as unknown as UseCaseOutputPort<any>);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith({
id: expect.any(String),
leagueId,
driverId: 'driver-1',
role: 'member',
status: 'active',
joinedAt: expect.any(Date),
});
expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.any(String),
leagueId: expect.objectContaining({ toString: expect.any(Function) }),
driverId: expect.objectContaining({ toString: expect.any(Function) }),
role: expect.objectContaining({ toString: expect.any(Function) }),
status: expect.objectContaining({ toString: expect.any(Function) }),
joinedAt: expect.any(Date),
})
);
expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request approved.' });
});
it('should return error if request not found', async () => {
const output = {
present: vi.fn(),
@@ -57,12 +58,11 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output as unknown as UseCaseOutputPort<any>,
);
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]);
const result = await useCase.execute({ leagueId: 'league-1', requestId: 'req-1' });
const result = await useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }, output as unknown as UseCaseOutputPort<any>);
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('JOIN_REQUEST_NOT_FOUND');

View File

@@ -4,6 +4,10 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
import { randomUUID } from 'crypto';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { JoinedAt } from '../../domain/value-objects/JoinedAt';
import { LeagueId } from '../../domain/entities/LeagueId';
import { DriverId } from '../../domain/entities/DriverId';
import { MembershipRole } from '../../domain/entities/MembershipRole';
import { MembershipStatus } from '../../domain/entities/MembershipStatus';
export interface ApproveLeagueJoinRequestInput {
leagueId: string;
@@ -33,10 +37,10 @@ export class ApproveLeagueJoinRequestUseCase {
await this.leagueMembershipRepository.removeJoinRequest(input.requestId);
await this.leagueMembershipRepository.saveMembership({
id: randomUUID(),
leagueId: input.leagueId,
driverId: request.driverId,
role: 'member',
status: 'active',
leagueId: LeagueId.create(input.leagueId),
driverId: DriverId.create(request.driverId.toString()),
role: MembershipRole.create('member'),
status: MembershipStatus.create('active'),
joinedAt: JoinedAt.create(new Date()),
});

View File

@@ -92,7 +92,7 @@ describe('CancelRaceUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED');
expect(result.unwrapErr().details?.message).toContain('already cancelled');
expect((result.unwrapErr() as any).details.message).toContain('already cancelled');
expect(output.present).not.toHaveBeenCalled();
});
@@ -114,7 +114,7 @@ describe('CancelRaceUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED');
expect(result.unwrapErr().details?.message).toContain('completed race');
expect((result.unwrapErr() as any).details.message).toContain('completed race');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -42,7 +42,10 @@ export class CancelRaceUseCase {
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
return Result.err({ code: 'RACE_NOT_FOUND' });
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' }
});
}
const cancelledRace = race.cancel();

View File

@@ -3,7 +3,7 @@ import { CloseRaceEventStewardingUseCase, type CloseRaceEventStewardingResult }
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { DomainEventPublisher } from '@/shared/domain/DomainEvent';
import type { DomainEventPublisher } from '@core/shared/domain/DomainEvent';
import type { Logger } from '@core/shared/application';
import { RaceEvent } from '../../domain/entities/RaceEvent';
import { Session } from '../../domain/entities/Session';
@@ -118,7 +118,7 @@ describe('CloseRaceEventStewardingUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details?.message).toContain('DB error');
expect((result.unwrapErr() as any).details.message).toContain('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@ import type { Logger } from '@core/shared/application';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { DomainEventPublisher } from '@/shared/domain/DomainEvent';
import type { DomainEventPublisher } from '@core/shared/domain/DomainEvent';
import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';

View File

@@ -3,13 +3,11 @@ import {
CompleteDriverOnboardingUseCase,
type CompleteDriverOnboardingInput,
type CompleteDriverOnboardingResult,
type CompleteDriverOnboardingApplicationError,
} from './CompleteDriverOnboardingUseCase';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Logger } from '@core/shared/application/Logger';
import type { Result } from '@core/shared/application/Result';
describe('CompleteDriverOnboardingUseCase', () => {
let useCase: CompleteDriverOnboardingUseCase;
@@ -35,7 +33,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
useCase = new CompleteDriverOnboardingUseCase(
driverRepository as unknown as IDriverRepository,
logger,
output,
);
});
@@ -62,9 +59,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
expect(result.unwrap()).toEqual({ driver: createdDriver });
expect(driverRepository.findById).toHaveBeenCalledWith('user-1');
expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
@@ -99,7 +94,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('DRIVER_ALREADY_EXISTS');
expect(driverRepository.create).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository create throws', async () => {
@@ -120,7 +114,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
const error = result.unwrapErr() as { code: 'REPOSITORY_ERROR'; details?: { message: string } };
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
it('should handle bio being undefined', async () => {
@@ -144,7 +137,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(result.unwrap()).toEqual({ driver: createdDriver });
expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user-1',
@@ -154,7 +147,5 @@ describe('CompleteDriverOnboardingUseCase', () => {
bio: undefined,
})
);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
});
});

View File

@@ -2,7 +2,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import { Driver } from '../../domain/entities/Driver';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCase, UseCaseOutputPort } from '@core/shared/application';
import type { UseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application/Logger';
export interface CompleteDriverOnboardingInput {

View File

@@ -57,13 +57,19 @@ export class CompleteRaceUseCase {
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND' });
return Result.err<void, ApplicationErrorCode<CompleteRaceErrorCode, { message: string }>>({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' },
});
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
return Result.err({ code: 'NO_REGISTERED_DRIVERS' });
return Result.err<void, ApplicationErrorCode<CompleteRaceErrorCode, { message: string }>>({
code: 'NO_REGISTERED_DRIVERS',
details: { message: 'No registered drivers for this race' },
});
}
// Get driver ratings using injected provider
@@ -175,9 +181,10 @@ export class CompleteRaceUseCase {
// Group results by driver
const resultsByDriver = new Map<string, RaceResult[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
const driverIdStr = result.driverId.toString();
const existing = resultsByDriver.get(driverIdStr) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
resultsByDriver.set(driverIdStr, existing);
}
// Update or create standings for each driver
@@ -193,7 +200,7 @@ export class CompleteRaceUseCase {
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
standing = standing.addRaceResult(result.position.toNumber(), {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});
}

View File

@@ -52,16 +52,25 @@ export class CompleteRaceUseCaseWithRatings {
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND' });
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' }
});
}
if (race.status === 'completed') {
return Result.err({ code: 'ALREADY_COMPLETED' });
return Result.err({
code: 'ALREADY_COMPLETED',
details: { message: 'Race already completed' }
});
}
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
return Result.err({ code: 'NO_REGISTERED_DRIVERS' });
return Result.err({
code: 'NO_REGISTERED_DRIVERS',
details: { message: 'No registered drivers' }
});
}
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
@@ -107,23 +116,24 @@ export class CompleteRaceUseCaseWithRatings {
private async updateStandings(leagueId: string, results: RaceResult[]): Promise<void> {
const resultsByDriver = new Map<string, RaceResult[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
const driverIdStr = result.driverId.toString();
const existing = resultsByDriver.get(driverIdStr) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
resultsByDriver.set(driverIdStr, existing);
}
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
for (const [driverIdStr, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverIdStr, leagueId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId,
driverId: driverIdStr,
});
}
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
standing = standing.addRaceResult(result.position.toNumber(), {
1: 25,
2: 18,
3: 15,
@@ -143,11 +153,11 @@ export class CompleteRaceUseCaseWithRatings {
private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise<void> {
const driverResults = results.map((result) => ({
driverId: result.driverId,
position: result.position,
driverId: result.driverId.toString(),
position: result.position.toNumber(),
totalDrivers,
incidents: result.incidents,
startPosition: result.startPosition,
incidents: result.incidents.toNumber(),
startPosition: result.startPosition.toNumber(),
}));
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);

View File

@@ -89,10 +89,10 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateLeagueWithSeasonAndScoringResult;
expect(presented.league.id.toString()).toBeDefined();
expect(presented.season.id).toBeDefined();
expect(presented.scoringConfig.seasonId.toString()).toBe(presented.season.id);
const presented = (output.present as Mock).mock.calls[0]?.[0] as unknown as CreateLeagueWithSeasonAndScoringResult;
expect(presented?.league.id.toString()).toBeDefined();
expect(presented?.season.id).toBeDefined();
expect(presented?.scoringConfig.seasonId.toString()).toBe(presented?.season.id);
expect(leagueRepository.create).toHaveBeenCalledTimes(1);
expect(seasonRepository.create).toHaveBeenCalledTimes(1);
expect(leagueScoringConfigRepository.save).toHaveBeenCalledTimes(1);
@@ -114,8 +114,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('League name is required');
const err = result.unwrapErr();
expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('League name is required');
}
expect(output.present).not.toHaveBeenCalled();
});
@@ -135,8 +138,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('League ownerId is required');
const err = result.unwrapErr();
expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('League ownerId is required');
}
expect(output.present).not.toHaveBeenCalled();
});
@@ -157,7 +163,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('gameId is required');
expect((result.unwrapErr() as any).details.message).toBe('gameId is required');
expect(output.present).not.toHaveBeenCalled();
});
@@ -175,8 +181,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('visibility is required');
const err = result.unwrapErr();
expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('visibility is required');
}
expect(output.present).not.toHaveBeenCalled();
});
@@ -197,8 +206,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('maxDrivers must be greater than 0 when provided');
const err = result.unwrapErr();
expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('maxDrivers must be greater than 0 when provided');
}
expect(output.present).not.toHaveBeenCalled();
});
@@ -219,8 +231,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toContain('Ranked leagues require at least 10 drivers');
const err = result.unwrapErr();
expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toContain('Ranked leagues require at least 10 drivers');
}
expect(output.present).not.toHaveBeenCalled();
});
@@ -243,8 +258,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('UNKNOWN_PRESET');
expect(result.unwrapErr().details?.message).toBe('Unknown scoring preset: unknown-preset');
const err = result.unwrapErr();
expect(err.code).toBe('UNKNOWN_PRESET');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('Unknown scoring preset: unknown-preset');
}
expect(output.present).not.toHaveBeenCalled();
});
@@ -272,8 +290,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details?.message).toBe('DB error');
const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('DB error');
}
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -117,12 +117,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase {
const scoringConfig = LeagueScoringConfig.create({
seasonId,
scoringPresetId: preset.id,
championships: {
driver: command.enableDriverChampionship,
team: command.enableTeamChampionship,
nations: command.enableNationsChampionship,
trophy: command.enableTrophyChampionship,
},
championships: [], // Empty array - will be populated by preset
});
this.logger.debug(`Scoring configuration created from preset ${preset.id}.`);

View File

@@ -70,7 +70,9 @@ function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>)
};
}
type CreateSeasonErrorCode = ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'>;
type CreateSeasonErrorCode = ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'> & {
details?: { message: string };
};
describe('CreateSeasonForLeagueUseCase', () => {
const mockLeagueFindById = vi.fn();
@@ -146,9 +148,9 @@ describe('CreateSeasonForLeagueUseCase', () => {
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult;
expect(presented.season).toBeInstanceOf(Season);
expect(presented.league.id).toBe('league-1');
const presented = (output.present as Mock).mock.calls[0]?.[0] as CreateSeasonForLeagueResult;
expect(presented?.season).toBeInstanceOf(Season);
expect(presented?.league.id).toBe('league-1');
});
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
@@ -179,8 +181,8 @@ describe('CreateSeasonForLeagueUseCase', () => {
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult;
expect(presented.season.maxDrivers).toBe(40);
const presented = (output.present as Mock).mock.calls[0]?.[0] as CreateSeasonForLeagueResult;
expect(presented?.season.maxDrivers).toBe(40);
});
it('returns error when league not found and does not call output', async () => {

View File

@@ -1,8 +1,8 @@
import { Season } from '../../domain/entities/Season';
import { Season } from '../../domain/entities/season/Season';
import { League } from '../../domain/entities/League';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig';
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
@@ -93,7 +93,7 @@ export class CreateSeasonForLeagueUseCase {
const season = Season.create({
id: seasonId,
leagueId: league.id,
leagueId: league.id.toString(),
gameId: input.gameId,
name: input.name,
year: new Date().getFullYear(),
@@ -129,28 +129,28 @@ export class CreateSeasonForLeagueUseCase {
} {
const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
scoringPresetId: config.scoring?.patternId ?? 'custom',
customScoringEnabled: config.scoring?.customScoringEnabled ?? false,
});
const dropPolicy = new SeasonDropPolicy({
strategy: config.dropPolicy.strategy,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
strategy: (config.dropPolicy?.strategy as any) ?? 'none',
...(config.dropPolicy?.n !== undefined ? { n: config.dropPolicy.n } : {}),
});
const stewardingConfig = new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode,
...(config.stewarding.requiredVotes !== undefined
decisionMode: (config.stewarding?.decisionMode as any) ?? 'auto',
...(config.stewarding?.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense,
defenseTimeLimit: config.stewarding.defenseTimeLimit,
voteTimeLimit: config.stewarding.voteTimeLimit,
protestDeadlineHours: config.stewarding.protestDeadlineHours,
stewardingClosesHours: config.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
requireDefense: config.stewarding?.requireDefense ?? false,
defenseTimeLimit: config.stewarding?.defenseTimeLimit ?? 0,
voteTimeLimit: config.stewarding?.voteTimeLimit ?? 0,
protestDeadlineHours: config.stewarding?.protestDeadlineHours ?? 0,
stewardingClosesHours: config.stewarding?.stewardingClosesHours ?? 0,
notifyAccusedOnProtest: config.stewarding?.notifyAccusedOnProtest ?? false,
notifyOnVoteRequired: config.stewarding?.notifyOnVoteRequired ?? false,
});
const structure = config.structure;
const structure = config.structure ?? {};
const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers
@@ -169,14 +169,14 @@ export class CreateSeasonForLeagueUseCase {
config: LeagueConfigFormModel,
): SeasonSchedule | undefined {
const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) {
if (!timings || !timings.seasonStartDate || !timings.raceStartTime) {
return undefined;
}
const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId);
const timezone = LeagueTimezone.create(timezoneId);
const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
@@ -197,10 +197,10 @@ export class CreateSeasonForLeagueUseCase {
weekdays,
);
case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday,
});
const pattern = MonthlyRecurrencePattern.create(
(timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
(timings.monthlyWeekday ?? 'Mon') as Weekday,
);
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
}
case 'weekly':
@@ -214,7 +214,7 @@ export class CreateSeasonForLeagueUseCase {
timeOfDay,
timezone,
recurrence,
plannedRounds,
plannedRounds: plannedRounds ?? 0,
});
}
}

View File

@@ -54,13 +54,13 @@ describe('CreateSponsorUseCase', () => {
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0];
expect(presented.sponsor.id).toBeDefined();
expect(presented.sponsor.name).toBe('Test Sponsor');
expect(presented.sponsor.contactEmail).toBe('test@example.com');
expect(presented.sponsor.websiteUrl).toBe('https://example.com');
expect(presented.sponsor.logoUrl).toBe('https://example.com/logo.png');
expect(presented.sponsor.createdAt).toBeInstanceOf(Date);
const presented = (output.present as Mock).mock.calls[0]?.[0];
expect(presented?.sponsor.id).toBeDefined();
expect(presented?.sponsor.name).toBe('Test Sponsor');
expect(presented?.sponsor.contactEmail).toBe('test@example.com');
expect(presented?.sponsor.websiteUrl).toBe('https://example.com');
expect(presented?.sponsor.logoUrl).toBe('https://example.com/logo.png');
expect(presented?.sponsor.createdAt).toBeInstanceOf(Date);
expect(sponsorRepository.create).toHaveBeenCalledTimes(1);
});
@@ -77,7 +77,7 @@ describe('CreateSponsorUseCase', () => {
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0];
const presented = (output.present as Mock).mock.calls[0]?.[0];
expect(presented.sponsor.websiteUrl).toBeUndefined();
expect(presented.sponsor.logoUrl).toBeUndefined();
});

View File

@@ -61,27 +61,27 @@ export class CreateSponsorUseCase {
}
}
private validate(command: CreateSponsorCommand): Result<void, ApplicationErrorCode<'VALIDATION_ERROR', { message: string }>> {
this.logger.debug('Validating CreateSponsorCommand', { command });
if (!command.name || command.name.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor name is required', { command });
private validate(input: CreateSponsorInput): Result<void, ApplicationErrorCode<'VALIDATION_ERROR', { message: string }>> {
this.logger.debug('Validating CreateSponsorInput', { input });
if (!input.name || input.name.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor name is required', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor name is required' } });
}
if (!command.contactEmail || command.contactEmail.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor contact email is required', { command });
if (!input.contactEmail || input.contactEmail.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor contact email is required', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor contact email is required' } });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(command.contactEmail)) {
this.logger.warn('Validation failed: Invalid sponsor contact email format', { command });
if (!emailRegex.test(input.contactEmail)) {
this.logger.warn('Validation failed: Invalid sponsor contact email format', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor contact email format' } });
}
if (command.websiteUrl && command.websiteUrl.trim().length > 0) {
if (input.websiteUrl && input.websiteUrl.trim().length > 0) {
try {
new URL(command.websiteUrl);
new URL(input.websiteUrl);
} catch {
this.logger.warn('Validation failed: Invalid sponsor website URL', { command });
this.logger.warn('Validation failed: Invalid sponsor website URL', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor website URL' } });
}
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
DashboardOverviewUseCase,
@@ -10,12 +10,11 @@ import { Race } from '@core/racing/domain/entities/Race';
import { League } from '@core/racing/domain/entities/League';
import { Standing } from '@core/racing/domain/entities/Standing';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { Result as RaceResult } from '@core/racing/domain/entities/Result';
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { Result as UseCaseResult } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('DashboardOverviewUseCase', () => {
it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => {

View File

@@ -9,7 +9,6 @@ import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepo
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { League } from '../../domain/entities/League';
import { Race } from '../../domain/entities/Race';
import { Result as RaceResult } from '../../domain/entities/Result';

View File

@@ -49,12 +49,12 @@ export class GetAllTeamsUseCase {
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
leagues: [...team.leagues],
createdAt: team.createdAt,
name: team.name.props,
tag: team.tag.props,
description: team.description.props,
ownerId: team.ownerId.toString(),
leagues: team.leagues.map(l => l.toString()),
createdAt: team.createdAt.toDate(),
memberCount,
};
}),

View File

@@ -13,8 +13,9 @@ import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
export type SponsorshipEntityType = 'season' | 'league' | 'team';
export type SponsorshipEntityType = SponsorableEntityType;
export type GetEntitySponsorshipPricingInput = {
entityType: SponsorshipEntityType;
@@ -23,11 +24,10 @@ export type GetEntitySponsorshipPricingInput = {
export type SponsorshipPricingTier = {
name: string;
price: SponsorshipPricing['mainSlot'] extends SponsorshipSlotConfig
? SponsorshipSlotConfig['price']
: SponsorshipPricing['secondarySlots'] extends SponsorshipSlotConfig
? SponsorshipSlotConfig['price']
: never;
price: {
amount: number;
currency: string;
};
benefits: string[];
};

View File

@@ -14,8 +14,8 @@ export type DriverSeasonStats = {
driverId: string;
position: number;
driverName: string;
teamId?: string;
teamName?: string;
teamId: string | undefined;
teamName: string | undefined;
totalPoints: number;
basePoints: number;
penaltyPoints: number;
@@ -102,8 +102,8 @@ export class GetLeagueDriverSeasonStatsUseCase {
const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>();
for (const standing of standings) {
const driverId = String(standing.driverId);
const ratingInfo = this.driverRatingPort.getRating(driverId);
driverRatings.set(driverId, ratingInfo);
const rating = await this.driverRatingPort.getDriverRating(driverId);
driverRatings.set(driverId, { rating, ratingChange: null });
}
const driverResults = new Map<string, Array<{ position: number }>>();

View File

@@ -2,7 +2,7 @@ import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season';
import type { Season } from '../../domain/entities/season/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';

View File

@@ -7,7 +7,7 @@ import {
} from './GetSeasonDetailsUseCase';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Season } from '../../domain/entities/Season';
import { Season } from '../../domain/entities/season/Season';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';

View File

@@ -3,7 +3,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { Season } from '../../domain/entities/Season';
import type { Season } from '../../domain/entities/season/Season';
export type GetSeasonDetailsInput = {
leagueId: string;

View File

@@ -12,8 +12,8 @@ import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { Sponsor } from '../../domain/entities/Sponsor';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import { Season } from '../../domain/entities/Season';
import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship';
import { Season } from '../../domain/entities/season/Season';
import { League } from '../../domain/entities/League';
import { Money } from '../../domain/value-objects/Money';
import type { UseCaseOutputPort } from '@core/shared/application';

View File

@@ -142,8 +142,8 @@ export class GetSponsorDashboardUseCase {
);
sponsoredLeagues.push({
leagueId: league.id,
leagueName: league.name,
leagueId: league.id.toString(),
leagueName: league.name.toString(),
tier: sponsorship.tier,
metrics: {
drivers: driverCount,

View File

@@ -3,7 +3,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result as RaceResult } from '../../domain/entities/Result';
import { Result as RaceResult } from '../../domain/entities/result/Result';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';

View File

@@ -56,7 +56,7 @@ export class RegisterForRaceUseCase {
const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
if (alreadyRegistered) {
this.logger.warn(`RegisterForRaceUseCase: driver ${driverId} already registered for race ${raceId}`);
return Result.err({
return Result.err<void, ApplicationErrorCode<RegisterForRaceErrorCode, { message: string }>>({
code: 'ALREADY_REGISTERED',
details: { message: 'Already registered for this race' },
});
@@ -65,7 +65,7 @@ export class RegisterForRaceUseCase {
const membership = await this.membershipRepository.getMembership(leagueId, driverId);
if (!membership || membership.status !== 'active') {
this.logger.error(`RegisterForRaceUseCase: driver ${driverId} not an active member of league ${leagueId}`);
return Result.err({
return Result.err<void, ApplicationErrorCode<RegisterForRaceErrorCode, { message: string }>>({
code: 'NOT_ACTIVE_MEMBER',
details: { message: 'Must be an active league member to register for races' },
});
@@ -94,14 +94,13 @@ export class RegisterForRaceUseCase {
? error.message
: 'Failed to register for race';
this.logger.error('RegisterForRaceUseCase: unexpected error during registration', {
this.logger.error('RegisterForRaceUseCase: unexpected error during registration', error instanceof Error ? error : undefined, {
raceId,
leagueId,
driverId,
error,
});
return Result.err({
return Result.err<void, ApplicationErrorCode<RegisterForRaceErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR',
details: { message },
});

View File

@@ -1,4 +1,4 @@
import { Season } from '../../domain/entities/Season';
import { Season } from '../../domain/entities/season/Season';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';