wip league admin tools

This commit is contained in:
2025-12-28 12:04:12 +01:00
parent 5dc8c2399c
commit 6edf12fda8
401 changed files with 15365 additions and 6047 deletions

View File

@@ -3,7 +3,9 @@ import {
ApproveLeagueJoinRequestUseCase,
type ApproveLeagueJoinRequestResult,
} from './ApproveLeagueJoinRequestUseCase';
import { League } from '../../domain/entities/League';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ApproveLeagueJoinRequestUseCase', () => {
@@ -11,6 +13,11 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
getJoinRequests: Mock;
removeJoinRequest: Mock;
saveMembership: Mock;
getLeagueMembers: Mock;
};
let mockLeagueRepo: {
findById: Mock;
};
beforeEach(() => {
@@ -18,33 +25,52 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
getJoinRequests: vi.fn(),
removeJoinRequest: vi.fn(),
saveMembership: vi.fn(),
getLeagueMembers: vi.fn(),
};
mockLeagueRepo = {
findById: vi.fn(),
};
});
it('should approve join request and save membership', async () => {
it('approve removes request and adds member', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLeagueRepo as unknown as ILeagueRepository,
);
const leagueId = 'league-1';
const requestId = 'req-1';
const joinRequests = [{ id: requestId, leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }];
const joinRequestId = 'req-1';
const joinRequests = [{ id: joinRequestId, leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }];
mockLeagueRepo.findById.mockResolvedValue(
League.create({
id: leagueId,
name: 'L',
description: 'D',
ownerId: 'owner-1',
settings: { maxDrivers: 32, visibility: 'unranked' },
participantCount: 0,
}),
);
mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([]);
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue(joinRequests);
const result = await useCase.execute(
{ leagueId, requestId },
{ leagueId, joinRequestId },
output as unknown as UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(joinRequestId);
expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledTimes(1);
const savedMembership = (mockLeagueMembershipRepo.saveMembership as Mock).mock.calls[0]?.[0] as unknown as {
id: string;
leagueId: { toString(): string };
@@ -60,26 +86,101 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
expect(savedMembership.role.toString()).toBe('member');
expect(savedMembership.status.toString()).toBe('active');
expect(savedMembership.joinedAt.toDate()).toBeInstanceOf(Date);
expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request approved.' });
});
it('should return error if request not found', async () => {
it('approve returns error when request missing', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLeagueRepo as unknown as ILeagueRepository,
);
mockLeagueRepo.findById.mockResolvedValue(
League.create({
id: 'league-1',
name: 'L',
description: 'D',
ownerId: 'owner-1',
settings: { maxDrivers: 32, visibility: 'unranked' },
participantCount: 0,
}),
);
mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([]);
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]);
const result = await useCase.execute(
{ leagueId: 'league-1', requestId: 'req-1' },
{ leagueId: 'league-1', joinRequestId: 'req-1' },
output as unknown as UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
);
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('JOIN_REQUEST_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
expect(mockLeagueMembershipRepo.removeJoinRequest).not.toHaveBeenCalled();
expect(mockLeagueMembershipRepo.saveMembership).not.toHaveBeenCalled();
});
it('rejects approval when league is at capacity and does not mutate state', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLeagueRepo as unknown as ILeagueRepository,
);
const leagueId = 'league-1';
const joinRequestId = 'req-1';
const joinRequests = [{ id: joinRequestId, leagueId, driverId: 'driver-2', requestedAt: new Date(), message: 'msg' }];
mockLeagueRepo.findById.mockResolvedValue(
League.create({
id: leagueId,
name: 'L',
description: 'D',
ownerId: 'owner-1',
settings: { maxDrivers: 2, visibility: 'unranked' },
participantCount: 2,
}),
);
mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([
{
id: `${leagueId}:owner-1`,
leagueId: { toString: () => leagueId },
driverId: { toString: () => 'owner-1' },
role: { toString: () => 'owner' },
status: { toString: () => 'active' },
joinedAt: { toDate: () => new Date() },
},
{
id: `${leagueId}:driver-1`,
leagueId: { toString: () => leagueId },
driverId: { toString: () => 'driver-1' },
role: { toString: () => 'member' },
status: { toString: () => 'active' },
joinedAt: { toDate: () => new Date() },
},
]);
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue(joinRequests);
const result = await useCase.execute(
{ leagueId, joinRequestId },
output as unknown as UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('LEAGUE_AT_CAPACITY');
expect(output.present).not.toHaveBeenCalled();
expect(mockLeagueMembershipRepo.removeJoinRequest).not.toHaveBeenCalled();
expect(mockLeagueMembershipRepo.saveMembership).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { randomUUID } from 'crypto';
@@ -11,7 +12,7 @@ import { MembershipStatus } from '../../domain/entities/MembershipStatus';
export interface ApproveLeagueJoinRequestInput {
leagueId: string;
requestId: string;
joinRequestId: string;
}
export interface ApproveLeagueJoinRequestResult {
@@ -22,19 +23,40 @@ export interface ApproveLeagueJoinRequestResult {
export class ApproveLeagueJoinRequestUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly leagueRepository: ILeagueRepository,
) {}
async execute(
input: ApproveLeagueJoinRequestInput,
output: UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
): Promise<Result<void, ApplicationErrorCode<'JOIN_REQUEST_NOT_FOUND'>>> {
): Promise<
Result<
void,
ApplicationErrorCode<
'JOIN_REQUEST_NOT_FOUND' | 'LEAGUE_NOT_FOUND' | 'LEAGUE_AT_CAPACITY',
{ message: string }
>
>
> {
const requests = await this.leagueMembershipRepository.getJoinRequests(input.leagueId);
const request = requests.find(r => r.id === input.requestId);
const request = requests.find(r => r.id === input.joinRequestId);
if (!request) {
return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' });
return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND', details: { message: 'Join request not found' } });
}
await this.leagueMembershipRepository.removeJoinRequest(input.requestId);
const league = await this.leagueRepository.findById(input.leagueId);
if (!league) {
return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } });
}
const members = await this.leagueMembershipRepository.getLeagueMembers(input.leagueId);
const maxDrivers = league.settings.maxDrivers ?? 32;
if (members.length >= maxDrivers) {
return Result.err({ code: 'LEAGUE_AT_CAPACITY', details: { message: 'League is at capacity' } });
}
await this.leagueMembershipRepository.removeJoinRequest(input.joinRequestId);
await this.leagueMembershipRepository.saveMembership({
id: randomUUID(),
leagueId: LeagueId.create(input.leagueId),

View File

@@ -58,9 +58,15 @@ describe('CancelRaceUseCase', () => {
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(raceRepository.findById).toHaveBeenCalledWith(raceId);
expect(raceRepository.update).toHaveBeenCalledWith(expect.objectContaining({ id: raceId, status: 'cancelled' }));
expect(raceRepository.update).toHaveBeenCalledTimes(1);
const updatedRace = (raceRepository.update as Mock).mock.calls[0]?.[0] as Race;
expect(updatedRace.id).toBe(raceId);
expect(updatedRace.status.toString()).toBe('cancelled');
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ race: expect.objectContaining({ id: raceId, status: 'cancelled' }) });
const presented = (output.present as Mock).mock.calls[0]?.[0] as CancelRaceResult;
expect(presented.race.id).toBe(raceId);
expect(presented.race.status.toString()).toBe('cancelled');
});
it('should return error if race not found', async () => {

View File

@@ -58,7 +58,17 @@ export class CompleteRaceUseCaseWithRatings {
});
}
if (race.status === 'completed') {
const raceStatus = (race as unknown as { status?: unknown }).status;
const isCompleted =
typeof raceStatus === 'string'
? raceStatus === 'completed'
: typeof (raceStatus as { isCompleted?: unknown })?.isCompleted === 'function'
? (raceStatus as { isCompleted: () => boolean }).isCompleted()
: typeof (raceStatus as { toString?: unknown })?.toString === 'function'
? (raceStatus as { toString: () => string }).toString() === 'completed'
: false;
if (isCompleted) {
return Result.err({
code: 'ALREADY_COMPLETED',
details: { message: 'Race already completed' }

View File

@@ -0,0 +1,140 @@
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Race } from '../../domain/entities/Race';
import type { Season } from '../../domain/entities/season/Season';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
export type CreateLeagueSeasonScheduleRaceInput = {
leagueId: string;
seasonId: string;
track: string;
car: string;
scheduledAt: Date;
};
export type CreateLeagueSeasonScheduleRaceResult = {
raceId: string;
};
export type CreateLeagueSeasonScheduleRaceErrorCode =
| 'SEASON_NOT_FOUND'
| 'RACE_OUTSIDE_SEASON_WINDOW'
| 'INVALID_INPUT'
| 'REPOSITORY_ERROR';
export class CreateLeagueSeasonScheduleRaceUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<CreateLeagueSeasonScheduleRaceResult>,
private readonly deps: { generateRaceId: () => string },
) {}
async execute(
input: CreateLeagueSeasonScheduleRaceInput,
): Promise<
Result<
void,
ApplicationErrorCode<CreateLeagueSeasonScheduleRaceErrorCode, { message: string }>
>
> {
this.logger.debug('Creating league season schedule race', {
leagueId: input.leagueId,
seasonId: input.seasonId,
});
try {
const season = await this.seasonRepository.findById(input.seasonId);
if (!season || season.leagueId !== input.leagueId) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season not found for league' },
});
}
if (!this.isWithinSeasonWindow(season, input.scheduledAt)) {
return Result.err({
code: 'RACE_OUTSIDE_SEASON_WINDOW',
details: { message: 'Race scheduledAt is outside the season schedule window' },
});
}
const id = this.deps.generateRaceId();
let race: Race;
try {
race = Race.create({
id,
leagueId: input.leagueId,
track: input.track,
car: input.car,
scheduledAt: input.scheduledAt,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid race input';
return Result.err({ code: 'INVALID_INPUT', details: { message } });
}
await this.raceRepository.create(race);
const result: CreateLeagueSeasonScheduleRaceResult = { raceId: race.id };
this.output.present(result);
return Result.ok(undefined);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
this.logger.error('Failed to create league season schedule race', error, {
leagueId: input.leagueId,
seasonId: input.seasonId,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error.message },
});
}
}
private isWithinSeasonWindow(season: Season, scheduledAt: Date): boolean {
const { start, endInclusive } = this.getSeasonDateWindow(season);
if (!start && !endInclusive) return true;
const t = scheduledAt.getTime();
if (start && t < start.getTime()) return false;
if (endInclusive && t > endInclusive.getTime()) return false;
return true;
}
private getSeasonDateWindow(season: Season): { start?: Date; endInclusive?: Date } {
const start = season.startDate ?? season.schedule?.startDate;
const window: { start?: Date; endInclusive?: Date } = {};
if (start) {
window.start = start;
}
if (season.endDate) {
window.endInclusive = season.endDate;
return window;
}
if (season.schedule) {
const slots = SeasonScheduleGenerator.generateSlotsUpTo(
season.schedule,
season.schedule.plannedRounds,
);
const last = slots.at(-1);
if (last?.scheduledAt) {
window.endInclusive = last.scheduledAt;
}
return window;
}
return window;
}
}

View File

@@ -148,7 +148,7 @@ export class DashboardOverviewUseCase {
const now = new Date();
const upcomingRaces = allRaces
.filter(race => race.status === 'scheduled' && race.scheduledAt > now)
.filter(race => race.status.isScheduled() && race.scheduledAt > now)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
const upcomingRacesInDriverLeagues = upcomingRaces.filter(race =>

View File

@@ -0,0 +1,82 @@
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
export type DeleteLeagueSeasonScheduleRaceInput = {
leagueId: string;
seasonId: string;
raceId: string;
};
export type DeleteLeagueSeasonScheduleRaceResult = {
success: true;
};
export type DeleteLeagueSeasonScheduleRaceErrorCode =
| 'SEASON_NOT_FOUND'
| 'RACE_NOT_FOUND'
| 'REPOSITORY_ERROR';
export class DeleteLeagueSeasonScheduleRaceUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<DeleteLeagueSeasonScheduleRaceResult>,
) {}
async execute(
input: DeleteLeagueSeasonScheduleRaceInput,
): Promise<
Result<
void,
ApplicationErrorCode<DeleteLeagueSeasonScheduleRaceErrorCode, { message: string }>
>
> {
this.logger.debug('Deleting league season schedule race', {
leagueId: input.leagueId,
seasonId: input.seasonId,
raceId: input.raceId,
});
try {
const season = await this.seasonRepository.findById(input.seasonId);
if (!season || season.leagueId !== input.leagueId) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season not found for league' },
});
}
const existing = await this.raceRepository.findById(input.raceId);
if (!existing || existing.leagueId !== input.leagueId) {
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found for league' },
});
}
await this.raceRepository.delete(input.raceId);
const result: DeleteLeagueSeasonScheduleRaceResult = { success: true };
this.output.present(result);
return Result.ok(undefined);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
this.logger.error('Failed to delete league season schedule race', error, {
leagueId: input.leagueId,
seasonId: input.seasonId,
raceId: input.raceId,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error.message },
});
}
}
}

View File

@@ -41,10 +41,10 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => {
const league = { id: 'league1', name: 'Test League', settings: { maxDrivers: 30 } };
const members = [
{ status: 'active', role: 'member' },
{ status: 'active', role: 'owner' },
{ status: { toString: () => 'active' }, role: { toString: () => 'member' } },
{ status: { toString: () => 'active' }, role: { toString: () => 'owner' } },
];
const season = { id: 'season1', status: 'active', gameId: 'game1' };
const season = { id: 'season1', status: { isActive: () => true }, gameId: 'game1' };
const scoringConfig = { scoringPresetId: 'preset1' };
const game = { id: 'game1', name: 'iRacing' };

View File

@@ -77,7 +77,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase {
const seasons = await this.seasonRepository.findByLeagueId(league.id.toString());
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
? seasons.find((s) => s.status.isActive()) ?? seasons[0]
: undefined;
let scoringConfig: LeagueScoringConfig | undefined;

View File

@@ -4,7 +4,7 @@ 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 { RaceStatus } from '../../domain/entities/Race';
import type { RaceStatusValue } from '../../domain/entities/Race';
export type GetAllRacesPageDataInput = {};
@@ -13,14 +13,14 @@ export interface GetAllRacesPageRaceItem {
track: string;
car: string;
scheduledAt: string;
status: RaceStatus;
status: RaceStatusValue;
leagueId: string;
leagueName: string;
strengthOfField: number | null;
}
export interface GetAllRacesPageDataFilters {
statuses: { value: 'all' | RaceStatus; label: string }[];
statuses: { value: 'all' | RaceStatusValue; label: string }[];
leagues: { id: string; name: string }[];
}
@@ -61,10 +61,10 @@ export class GetAllRacesPageDataUseCase {
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
status: race.status.toString(),
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
strengthOfField: race.strengthOfField ?? null,
strengthOfField: race.strengthOfField?.toNumber() ?? null,
}));
const uniqueLeagues = new Map<string, { id: string; name: string }>();

View File

@@ -74,7 +74,7 @@ describe('GetLeagueFullConfigUseCase', () => {
},
},
};
const mockSeasons = [{ id: 'season-1', status: 'active', gameId: 'game-1' }];
const mockSeasons = [{ id: 'season-1', status: { isActive: () => true }, gameId: 'game-1' }];
const mockScoringConfig = { id: 'config-1' };
const mockGame = { id: 'game-1' };

View File

@@ -53,7 +53,7 @@ export class GetLeagueFullConfigUseCase {
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
? seasons.find((s) => s.status.isActive()) ?? seasons[0]
: undefined;
const scoringConfig = await (async () => {

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueRosterJoinRequestsUseCase,
type GetLeagueRosterJoinRequestsInput,
type GetLeagueRosterJoinRequestsResult,
type GetLeagueRosterJoinRequestsErrorCode,
} from './GetLeagueRosterJoinRequestsUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Driver } from '../../domain/entities/Driver';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
describe('GetLeagueRosterJoinRequestsUseCase', () => {
let useCase: GetLeagueRosterJoinRequestsUseCase;
let leagueMembershipRepository: {
getJoinRequests: Mock;
};
let driverRepository: {
findById: Mock;
};
let leagueRepository: {
exists: Mock;
};
let output: UseCaseOutputPort<GetLeagueRosterJoinRequestsResult> & { present: Mock };
beforeEach(() => {
leagueMembershipRepository = {
getJoinRequests: vi.fn(),
};
driverRepository = {
findById: vi.fn(),
};
leagueRepository = {
exists: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetLeagueRosterJoinRequestsUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
driverRepository as unknown as IDriverRepository,
leagueRepository as unknown as ILeagueRepository,
output,
);
});
it('presents only join requests with resolvable drivers', async () => {
const leagueId = 'league-1';
const requestedAt = new Date('2025-01-02T03:04:05.000Z');
const joinRequests = [
{
id: 'req-1',
leagueId: { toString: () => leagueId },
driverId: { toString: () => 'driver-1' },
requestedAt: { toDate: () => requestedAt },
message: 'hello',
},
{
id: 'req-2',
leagueId: { toString: () => leagueId },
driverId: { toString: () => 'driver-missing' },
requestedAt: { toDate: () => new Date() },
},
];
const driver1 = Driver.create({
id: 'driver-1',
iracingId: '123',
name: 'Driver 1',
country: 'US',
});
leagueRepository.exists.mockResolvedValue(true);
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
driverRepository.findById.mockImplementation((id: string) => {
if (id === 'driver-1') return Promise.resolve(driver1);
return Promise.resolve(null);
});
const result = await useCase.execute({ leagueId } as GetLeagueRosterJoinRequestsInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]?.[0] as GetLeagueRosterJoinRequestsResult;
expect(presented.joinRequests).toHaveLength(1);
expect(presented.joinRequests[0]).toMatchObject({
id: 'req-1',
leagueId,
driverId: 'driver-1',
requestedAt,
message: 'hello',
driver: driver1,
});
});
it('returns LEAGUE_NOT_FOUND when league does not exist', async () => {
leagueRepository.exists.mockResolvedValue(false);
const result = await useCase.execute({ leagueId: 'missing' } as GetLeagueRosterJoinRequestsInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueRosterJoinRequestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('returns REPOSITORY_ERROR when repository throws', async () => {
leagueRepository.exists.mockRejectedValue(new Error('Repository failure'));
const result = await useCase.execute({ leagueId: 'league-1' } as GetLeagueRosterJoinRequestsInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueRosterJoinRequestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Driver } from '../../domain/entities/Driver';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
export interface GetLeagueRosterJoinRequestsInput {
leagueId: string;
}
export type GetLeagueRosterJoinRequestsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export interface LeagueRosterJoinRequestWithDriver {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
driver: Driver;
}
export interface GetLeagueRosterJoinRequestsResult {
joinRequests: LeagueRosterJoinRequestWithDriver[];
}
export class GetLeagueRosterJoinRequestsUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly output: UseCaseOutputPort<GetLeagueRosterJoinRequestsResult>,
) {}
async execute(
input: GetLeagueRosterJoinRequestsInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueRosterJoinRequestsErrorCode, { message: string }>>> {
try {
const leagueExists = await this.leagueRepository.exists(input.leagueId);
if (!leagueExists) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const joinRequests = await this.leagueMembershipRepository.getJoinRequests(input.leagueId);
const driverIds = [...new Set(joinRequests.map(request => request.driverId.toString()))];
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driverMap = new Map(
drivers.filter((driver): driver is Driver => driver !== null).map(driver => [driver.id, driver]),
);
const enrichedJoinRequests: LeagueRosterJoinRequestWithDriver[] = joinRequests
.filter(request => driverMap.has(request.driverId.toString()))
.map(request => ({
id: request.id,
leagueId: request.leagueId.toString(),
driverId: request.driverId.toString(),
requestedAt: request.requestedAt.toDate(),
...(request.message !== undefined && { message: request.message }),
driver: driverMap.get(request.driverId.toString())!,
}));
this.output.present({ joinRequests: enrichedJoinRequests });
return Result.ok(undefined);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to load league roster join requests';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueRosterMembersUseCase,
type GetLeagueRosterMembersInput,
type GetLeagueRosterMembersResult,
type GetLeagueRosterMembersErrorCode,
} from './GetLeagueRosterMembersUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { LeagueMembership } from '../../domain/entities/LeagueMembership';
import { Driver } from '../../domain/entities/Driver';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
describe('GetLeagueRosterMembersUseCase', () => {
let useCase: GetLeagueRosterMembersUseCase;
let leagueMembershipRepository: {
getLeagueMembers: Mock;
};
let driverRepository: {
findById: Mock;
};
let leagueRepository: {
exists: Mock;
};
let output: UseCaseOutputPort<GetLeagueRosterMembersResult> & { present: Mock };
beforeEach(() => {
leagueMembershipRepository = {
getLeagueMembers: vi.fn(),
};
driverRepository = {
findById: vi.fn(),
};
leagueRepository = {
exists: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetLeagueRosterMembersUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
driverRepository as unknown as IDriverRepository,
leagueRepository as unknown as ILeagueRepository,
output,
);
});
it('presents only members with resolvable drivers', async () => {
const leagueId = 'league-1';
const memberships = [
LeagueMembership.create({
id: 'membership-1',
leagueId,
driverId: 'driver-1',
role: 'member',
joinedAt: new Date(),
}),
LeagueMembership.create({
id: 'membership-2',
leagueId,
driverId: 'driver-missing',
role: 'admin',
joinedAt: new Date(),
}),
];
const driver1 = Driver.create({
id: 'driver-1',
iracingId: '123',
name: 'Driver 1',
country: 'US',
});
leagueRepository.exists.mockResolvedValue(true);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
driverRepository.findById.mockImplementation((id: string) => {
if (id === 'driver-1') return Promise.resolve(driver1);
return Promise.resolve(null);
});
const result = await useCase.execute({ leagueId } as GetLeagueRosterMembersInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]?.[0] as GetLeagueRosterMembersResult;
expect(presented.members).toHaveLength(1);
expect(presented.members[0]?.membership).toEqual(memberships[0]);
expect(presented.members[0]?.driver).toEqual(driver1);
});
it('returns LEAGUE_NOT_FOUND when league does not exist', async () => {
leagueRepository.exists.mockResolvedValue(false);
const result = await useCase.execute({ leagueId: 'missing' } as GetLeagueRosterMembersInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueRosterMembersErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('returns REPOSITORY_ERROR when repository throws', async () => {
leagueRepository.exists.mockRejectedValue(new Error('Repository failure'));
const result = await useCase.execute({ leagueId: 'league-1' } as GetLeagueRosterMembersInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueRosterMembersErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,74 @@
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Driver } from '../../domain/entities/Driver';
import type { LeagueMembership } from '../../domain/entities/LeagueMembership';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
export interface GetLeagueRosterMembersInput {
leagueId: string;
}
export type GetLeagueRosterMembersErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export interface LeagueRosterMember {
membership: LeagueMembership;
driver: Driver;
}
export interface GetLeagueRosterMembersResult {
members: LeagueRosterMember[];
}
export class GetLeagueRosterMembersUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly output: UseCaseOutputPort<GetLeagueRosterMembersResult>,
) {}
async execute(
input: GetLeagueRosterMembersInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueRosterMembersErrorCode, { message: string }>>> {
try {
const leagueExists = await this.leagueRepository.exists(input.leagueId);
if (!leagueExists) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const memberships = await this.leagueMembershipRepository.getLeagueMembers(input.leagueId);
const driverIds = [...new Set(memberships.map(membership => membership.driverId.toString()))];
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driverMap = new Map<string, Driver>(
drivers.filter((driver): driver is Driver => driver !== null).map(driver => [driver.id, driver]),
);
const members: LeagueRosterMember[] = memberships
.filter(membership => driverMap.has(membership.driverId.toString()))
.map(membership => ({
membership,
driver: driverMap.get(membership.driverId.toString())!,
}));
this.output.present({ members });
return Result.ok(undefined);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to load league roster members';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}

View File

@@ -17,6 +17,10 @@ describe('GetLeagueScheduleUseCase', () => {
let leagueRepository: {
findById: Mock;
};
let seasonRepository: {
findById: Mock;
findByLeagueId: Mock;
};
let raceRepository: {
findByLeagueId: Mock;
};
@@ -27,6 +31,10 @@ describe('GetLeagueScheduleUseCase', () => {
leagueRepository = {
findById: vi.fn(),
};
seasonRepository = {
findById: vi.fn(),
findByLeagueId: vi.fn(),
};
raceRepository = {
findByLeagueId: vi.fn(),
};
@@ -42,6 +50,7 @@ describe('GetLeagueScheduleUseCase', () => {
useCase = new GetLeagueScheduleUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as any,
raceRepository as unknown as IRaceRepository,
logger,
output,
@@ -60,6 +69,9 @@ describe('GetLeagueScheduleUseCase', () => {
});
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findByLeagueId.mockResolvedValue([
{ id: 'season-1', leagueId, status: { isActive: () => true } },
]);
raceRepository.findByLeagueId.mockResolvedValue([race]);
const input: GetLeagueScheduleInput = { leagueId };
@@ -69,19 +81,74 @@ describe('GetLeagueScheduleUseCase', () => {
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented =
output.present.mock.calls[0]?.[0] as GetLeagueScheduleResult;
const presented = output.present.mock.calls[0]?.[0] as GetLeagueScheduleResult;
expect(presented.league).toBe(league);
expect(presented.seasonId).toBe('season-1');
expect(presented.published).toBe(false);
expect(presented.races).toHaveLength(1);
expect(presented.races[0]?.race).toBe(race);
});
it('should scope schedule by seasonId (no season bleed)', async () => {
const leagueId = 'league-1';
const league = { id: leagueId } as unknown as League;
const janRace = Race.create({
id: 'race-jan',
leagueId,
scheduledAt: new Date('2025-01-10T20:00:00Z'),
track: 'Track Jan',
car: 'Car Jan',
});
const febRace = Race.create({
id: 'race-feb',
leagueId,
scheduledAt: new Date('2025-02-10T20:00:00Z'),
track: 'Track Feb',
car: 'Car Feb',
});
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockImplementation(async (id: string) => {
if (id === 'season-jan') {
return { id, leagueId, startDate: new Date('2025-01-01T00:00:00Z'), endDate: new Date('2025-01-31T23:59:59Z') };
}
if (id === 'season-feb') {
return { id, leagueId, startDate: new Date('2025-02-01T00:00:00Z'), endDate: new Date('2025-02-28T23:59:59Z') };
}
return null;
});
raceRepository.findByLeagueId.mockResolvedValue([janRace, febRace]);
// Season 1 covers January
const resultSeason1 = await useCase.execute({ leagueId, seasonId: 'season-jan' } as any);
expect(resultSeason1.isOk()).toBe(true);
const presented1 = output.present.mock.calls.at(-1)?.[0] as any;
expect(presented1.seasonId).toBe('season-jan');
expect(presented1.published).toBe(false);
expect((presented1.races as GetLeagueScheduleResult['races']).map(r => r.race.id)).toEqual(['race-jan']);
// Season 2 covers February
const resultSeason2 = await useCase.execute({ leagueId, seasonId: 'season-feb' } as any);
expect(resultSeason2.isOk()).toBe(true);
const presented2 = output.present.mock.calls.at(-1)?.[0] as any;
expect(presented2.seasonId).toBe('season-feb');
expect(presented2.published).toBe(false);
expect((presented2.races as GetLeagueScheduleResult['races']).map(r => r.race.id)).toEqual(['race-feb']);
});
it('should present empty schedule when no races exist', async () => {
const leagueId = 'league-1';
const league = { id: leagueId } as unknown as League;
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findByLeagueId.mockResolvedValue([
{ id: 'season-1', leagueId, status: { isActive: () => true } },
]);
raceRepository.findByLeagueId.mockResolvedValue([]);
const input: GetLeagueScheduleInput = { leagueId };
@@ -95,6 +162,7 @@ describe('GetLeagueScheduleUseCase', () => {
output.present.mock.calls[0]?.[0] as GetLeagueScheduleResult;
expect(presented.league).toBe(league);
expect(presented.published).toBe(false);
expect(presented.races).toHaveLength(0);
});
@@ -123,6 +191,9 @@ describe('GetLeagueScheduleUseCase', () => {
const repositoryError = new Error('DB down');
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findByLeagueId.mockResolvedValue([
{ id: 'season-1', leagueId, status: { isActive: () => true } },
]);
raceRepository.findByLeagueId.mockRejectedValue(repositoryError);
const input: GetLeagueScheduleInput = { leagueId };

View File

@@ -3,15 +3,20 @@ import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { League } from '../../domain/entities/League';
import type { Race } from '../../domain/entities/Race';
import type { Season } from '../../domain/entities/season/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
export type GetLeagueScheduleErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'SEASON_NOT_FOUND'
| 'REPOSITORY_ERROR';
export interface GetLeagueScheduleInput {
leagueId: string;
seasonId?: string;
}
export interface LeagueScheduledRace {
@@ -20,17 +25,88 @@ export interface LeagueScheduledRace {
export interface GetLeagueScheduleResult {
league: League;
seasonId: string;
published: boolean;
races: LeagueScheduledRace[];
}
export class GetLeagueScheduleUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetLeagueScheduleResult>,
) {}
private async resolveSeasonForSchedule(params: {
leagueId: string;
requestedSeasonId?: string;
}): Promise<Result<Season, ApplicationErrorCode<GetLeagueScheduleErrorCode, { message: string }>>> {
if (params.requestedSeasonId) {
const season = await this.seasonRepository.findById(params.requestedSeasonId);
if (!season || season.leagueId !== params.leagueId) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season not found for league' },
});
}
return Result.ok(season);
}
const seasons = await this.seasonRepository.findByLeagueId(params.leagueId);
const activeSeason = seasons.find(s => s.status.isActive()) ?? seasons[0];
if (!activeSeason) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: 'No seasons found for league' },
});
}
return Result.ok(activeSeason);
}
private getSeasonDateWindow(season: Season): { start?: Date; endInclusive?: Date } {
const start = season.startDate ?? season.schedule?.startDate;
const window: { start?: Date; endInclusive?: Date } = {};
if (start) {
window.start = start;
}
if (season.endDate) {
window.endInclusive = season.endDate;
return window;
}
if (season.schedule) {
const slots = SeasonScheduleGenerator.generateSlotsUpTo(
season.schedule,
season.schedule.plannedRounds,
);
const last = slots.at(-1);
if (last?.scheduledAt) {
window.endInclusive = last.scheduledAt;
}
return window;
}
return window;
}
private filterRacesBySeasonWindow(season: Season, races: Race[]): Race[] {
const { start, endInclusive } = this.getSeasonDateWindow(season);
if (!start && !endInclusive) return races;
return races.filter(race => {
const t = race.scheduledAt.getTime();
if (start && t < start.getTime()) return false;
if (endInclusive && t > endInclusive.getTime()) return false;
return true;
});
}
async execute(
input: GetLeagueScheduleInput,
): Promise<
@@ -49,14 +125,26 @@ export class GetLeagueScheduleUseCase {
});
}
const races = await this.raceRepository.findByLeagueId(leagueId);
const seasonResult = await this.resolveSeasonForSchedule({
leagueId,
...(input.seasonId ? { requestedSeasonId: input.seasonId } : {}),
});
if (seasonResult.isErr()) {
return Result.err(seasonResult.unwrapErr());
}
const season = seasonResult.unwrap();
const scheduledRaces: LeagueScheduledRace[] = races.map(race => ({
const races = await this.raceRepository.findByLeagueId(leagueId);
const seasonRaces = this.filterRacesBySeasonWindow(season, races);
const scheduledRaces: LeagueScheduledRace[] = seasonRaces.map(race => ({
race,
}));
const result: GetLeagueScheduleResult = {
league,
seasonId: season.id,
published: season.schedulePublished ?? false,
races: scheduledRaces,
};

View File

@@ -71,7 +71,17 @@ export class GetLeagueScoringConfigUseCase {
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
seasons.find((s) => {
const seasonStatus = (s as unknown as { status?: unknown }).status;
if (typeof seasonStatus === 'string') return seasonStatus === 'active';
if (seasonStatus && typeof (seasonStatus as { isActive?: unknown }).isActive === 'function') {
return (seasonStatus as { isActive: () => boolean }).isActive();
}
if (seasonStatus && typeof (seasonStatus as { toString?: unknown }).toString === 'function') {
return (seasonStatus as { toString: () => string }).toString() === 'active';
}
return false;
}) ?? seasons[0];
if (!activeSeason) {
return Result.err({

View File

@@ -47,15 +47,14 @@ export class GetLeagueSeasonsUseCase {
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeCount = seasons.filter(season => season.status === 'active').length;
const activeCount = seasons.filter(season => season.status.isActive()).length;
const result: GetLeagueSeasonsResult = {
league,
seasons: seasons.map(season => ({
season,
isPrimary: false,
isParallelActive:
season.status === 'active' && activeCount > 1,
isParallelActive: season.status.isActive() && activeCount > 1,
})),
};

View File

@@ -13,6 +13,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Race } from '../../domain/entities/Race';
describe('GetRaceDetailUseCase', () => {
let useCase: GetRaceDetailUseCase;
@@ -47,18 +48,17 @@ describe('GetRaceDetailUseCase', () => {
it('should present race detail when race exists', async () => {
const raceId = 'race-1';
const driverId = 'driver-1';
const race = {
const race = Race.create({
id: raceId,
leagueId: 'league-1',
track: 'Track 1',
car: 'Car 1',
scheduledAt: new Date('2099-01-01T10:00:00Z'),
sessionType: 'race' as const,
status: 'scheduled' as const,
status: 'scheduled',
strengthOfField: 1500,
registeredCount: 10,
maxParticipants: 20,
};
});
const league = {
id: 'league-1',
name: 'League 1',
@@ -69,7 +69,7 @@ describe('GetRaceDetailUseCase', () => {
{ driverId: { toString: () => 'driver-1' } },
{ driverId: { toString: () => 'driver-2' } },
];
const membership = { status: 'active' as const };
const membership = { status: { toString: () => 'active' } };
const drivers = [
{ id: 'driver-1', name: 'Driver 1', country: 'US' },
{ id: 'driver-2', name: 'Driver 2', country: 'UK' },
@@ -117,15 +117,14 @@ describe('GetRaceDetailUseCase', () => {
it('should include user result when race is completed', async () => {
const raceId = 'race-1';
const driverId = 'driver-1';
const race = {
const race = Race.create({
id: raceId,
leagueId: 'league-1',
track: 'Track 1',
car: 'Car 1',
scheduledAt: new Date('2023-01-01T10:00:00Z'),
sessionType: 'race' as const,
status: 'completed' as const,
};
status: 'completed',
});
const registrations: Array<{ driverId: { toString: () => string } }> = [];
const userDomainResult = {
driverId: { toString: () => driverId },

View File

@@ -74,7 +74,7 @@ export class GetRaceDetailUseCase {
? registrations.some(reg => reg.driverId.toString() === driverId)
: false;
const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date();
const isUpcoming = race.status.isScheduled() && race.scheduledAt > new Date();
const canRegister =
typeof driverId === 'string' && driverId.length > 0
? !!membership && membership.status.toString() === 'active' && isUpcoming
@@ -82,7 +82,7 @@ export class GetRaceDetailUseCase {
let userResult: RaceResult | null = null;
if (race.status === 'completed' && typeof driverId === 'string' && driverId.length > 0) {
if (race.status.isCompleted() && typeof driverId === 'string' && driverId.length > 0) {
const results = await this.resultRepository.findByRaceId(race.id);
userResult = results.find(r => r.driverId.toString() === driverId) ?? null;
}

View File

@@ -64,7 +64,7 @@ export class GetRaceWithSOFUseCase {
// Get participant IDs based on race status
let participantIds: string[] = [];
if (race.status === 'completed') {
if (race.status.isCompleted()) {
// For completed races, use results
const results = await this.resultRepository.findByRaceId(raceId);
participantIds = results.map(r => r.driverId.toString());
@@ -74,7 +74,7 @@ export class GetRaceWithSOFUseCase {
}
// Use stored SOF if available, otherwise calculate
let strengthOfField = race.strengthOfField ?? null;
let strengthOfField = race.strengthOfField?.toNumber() ?? null;
if (strengthOfField === null && participantIds.length > 0) {
// Get ratings for all participants using clean ports
@@ -100,8 +100,8 @@ export class GetRaceWithSOFUseCase {
const result: GetRaceWithSOFResult = {
race,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants ?? participantIds.length,
registeredCount: race.registeredCount?.toNumber() ?? participantIds.length,
maxParticipants: race.maxParticipants?.toNumber() ?? participantIds.length,
participantCount: participantIds.length,
};

View File

@@ -75,7 +75,7 @@ describe('GetSeasonDetailsUseCase', () => {
expect(presented?.season.leagueId).toBe('league-1');
expect(presented?.season.gameId).toBe('iracing');
expect(presented?.season.name).toBe('Detailed Season');
expect(presented?.season.status).toBe('planned');
expect(presented?.season.status.toString()).toBe('planned');
expect(presented?.season.maxDrivers).toBe(24);
});

View File

@@ -153,9 +153,9 @@ describe('GetSeasonSponsorshipsUseCase', () => {
]);
raceRepository.findByLeagueId.mockResolvedValue([
{ id: 'race-1', status: 'completed' },
{ id: 'race-2', status: 'completed' },
{ id: 'race-3', status: 'scheduled' },
{ id: 'race-1', status: { isCompleted: () => true } },
{ id: 'race-2', status: { isCompleted: () => true } },
{ id: 'race-3', status: { isCompleted: () => false } },
]);
const result = await useCase.execute(input);

View File

@@ -95,7 +95,7 @@ export class GetSeasonSponsorshipsUseCase {
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const raceCount = races.length;
const completedRaces = races.filter(r => r.status === 'completed').length;
const completedRaces = races.filter(r => r.status.isCompleted()).length;
const impressions = completedRaces * driverCount * 100;
const sponsorshipDetails: SeasonSponsorshipDetail[] = sponsorships.map((sponsorship) => {

View File

@@ -103,7 +103,7 @@ describe('GetSponsorDashboardUseCase', () => {
ownerId: 'owner-1',
});
const memberships = [{ driverId: 'driver-1' }];
const races = [{ id: 'race-1', status: 'completed' }];
const races = [{ id: 'race-1', status: { isCompleted: () => true } }];
sponsorRepository.findById.mockResolvedValue(sponsor);
seasonSponsorshipRepository.findBySponsorId.mockResolvedValue([sponsorship]);

View File

@@ -123,7 +123,7 @@ export class GetSponsorDashboardUseCase {
totalRaces += raceCount;
// Calculate impressions based on completed races and drivers
const completedRaces = races.filter(r => r.status === 'completed').length;
const completedRaces = races.filter(r => r.status.isCompleted()).length;
const leagueImpressions = completedRaces * driverCount * 100; // Simplified: 100 views per driver per race
totalImpressions += leagueImpressions;
@@ -154,7 +154,7 @@ export class GetSponsorDashboardUseCase {
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const activeSponsorships = sponsorships.filter((s) => s.status.toString() === 'active').length;
const costPerThousandViews = totalImpressions > 0
? totalInvestmentMoney.amount / (totalImpressions / 1000)
: 0;

View File

@@ -103,7 +103,7 @@ describe('GetSponsorSponsorshipsUseCase', () => {
ownerId: 'owner-1',
});
const memberships = [{ driverId: 'driver-1' }];
const races = [{ id: 'race-1', status: 'completed' }];
const races = [{ id: 'race-1', status: { isCompleted: () => true } }];
sponsorRepository.findById.mockResolvedValue(sponsor);
seasonSponsorshipRepository.findBySponsorId.mockResolvedValue([sponsorship]);

View File

@@ -103,7 +103,7 @@ export class GetSponsorSponsorshipsUseCase {
const driverCount = memberships.length;
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const completedRaces = races.filter(r => r.status === 'completed').length;
const completedRaces = races.filter(r => r.status.isCompleted()).length;
const impressions = completedRaces * driverCount * 100;

View File

@@ -0,0 +1,546 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Race } from '../../domain/entities/Race';
import { Season } from '../../domain/entities/season/Season';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import {
CreateLeagueSeasonScheduleRaceUseCase,
type CreateLeagueSeasonScheduleRaceErrorCode,
type CreateLeagueSeasonScheduleRaceResult,
} from './CreateLeagueSeasonScheduleRaceUseCase';
import {
UpdateLeagueSeasonScheduleRaceUseCase,
type UpdateLeagueSeasonScheduleRaceErrorCode,
type UpdateLeagueSeasonScheduleRaceResult,
} from './UpdateLeagueSeasonScheduleRaceUseCase';
import {
DeleteLeagueSeasonScheduleRaceUseCase,
type DeleteLeagueSeasonScheduleRaceErrorCode,
type DeleteLeagueSeasonScheduleRaceResult,
} from './DeleteLeagueSeasonScheduleRaceUseCase';
import {
PublishLeagueSeasonScheduleUseCase,
type PublishLeagueSeasonScheduleErrorCode,
type PublishLeagueSeasonScheduleResult,
} from './PublishLeagueSeasonScheduleUseCase';
import {
UnpublishLeagueSeasonScheduleUseCase,
type UnpublishLeagueSeasonScheduleErrorCode,
type UnpublishLeagueSeasonScheduleResult,
} from './UnpublishLeagueSeasonScheduleUseCase';
function createLogger(): Logger {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
}
function createSeasonWithinWindow(overrides?: Partial<{ leagueId: string }>): Season {
return Season.create({
id: 'season-1',
leagueId: overrides?.leagueId ?? 'league-1',
gameId: 'iracing',
name: 'Schedule Season',
status: 'planned',
startDate: new Date('2025-01-01T00:00:00Z'),
endDate: new Date('2025-01-31T00:00:00Z'),
});
}
describe('CreateLeagueSeasonScheduleRaceUseCase', () => {
let seasonRepository: { findById: Mock };
let raceRepository: { create: Mock };
let output: UseCaseOutputPort<CreateLeagueSeasonScheduleRaceResult> & { present: Mock };
let logger: Logger;
beforeEach(() => {
seasonRepository = { findById: vi.fn() };
raceRepository = { create: vi.fn() };
output = { present: vi.fn() };
logger = createLogger();
});
it('creates a race when season belongs to league and scheduledAt is within season window', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
raceRepository.create.mockImplementation(async (race: Race) => race);
const useCase = new CreateLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
{ generateRaceId: () => 'race-123' },
);
const scheduledAt = new Date('2025-01-10T20:00:00Z');
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
track: 'Road Atlanta',
car: 'MX-5',
scheduledAt,
});
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ raceId: 'race-123' });
expect(raceRepository.create).toHaveBeenCalledTimes(1);
const createdRace = raceRepository.create.mock.calls[0]?.[0] as Race;
expect(createdRace.id).toBe('race-123');
expect(createdRace.leagueId).toBe('league-1');
expect(createdRace.track).toBe('Road Atlanta');
expect(createdRace.car).toBe('MX-5');
expect(createdRace.scheduledAt.getTime()).toBe(scheduledAt.getTime());
});
it('returns SEASON_NOT_FOUND when season does not belong to league and does not create', async () => {
const season = createSeasonWithinWindow({ leagueId: 'other-league' });
seasonRepository.findById.mockResolvedValue(season);
const useCase = new CreateLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
{ generateRaceId: () => 'race-123' },
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
track: 'Road Atlanta',
car: 'MX-5',
scheduledAt: new Date('2025-01-10T20:00:00Z'),
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
CreateLeagueSeasonScheduleRaceErrorCode,
{ message: string }
>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
expect(raceRepository.create).not.toHaveBeenCalled();
});
it('returns RACE_OUTSIDE_SEASON_WINDOW when scheduledAt is before season start and does not create', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
const useCase = new CreateLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
{ generateRaceId: () => 'race-123' },
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
track: 'Road Atlanta',
car: 'MX-5',
scheduledAt: new Date('2024-12-31T23:59:59Z'),
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
CreateLeagueSeasonScheduleRaceErrorCode,
{ message: string }
>;
expect(error.code).toBe('RACE_OUTSIDE_SEASON_WINDOW');
expect(output.present).not.toHaveBeenCalled();
expect(raceRepository.create).not.toHaveBeenCalled();
});
});
describe('UpdateLeagueSeasonScheduleRaceUseCase', () => {
let seasonRepository: { findById: Mock };
let raceRepository: { findById: Mock; update: Mock };
let output: UseCaseOutputPort<UpdateLeagueSeasonScheduleRaceResult> & { present: Mock };
let logger: Logger;
beforeEach(() => {
seasonRepository = { findById: vi.fn() };
raceRepository = { findById: vi.fn(), update: vi.fn() };
output = { present: vi.fn() };
logger = createLogger();
});
it('updates race when season belongs to league and updated scheduledAt stays within window', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
const existing = Race.create({
id: 'race-1',
leagueId: 'league-1',
track: 'Old Track',
car: 'Old Car',
scheduledAt: new Date('2025-01-05T20:00:00Z'),
});
raceRepository.findById.mockResolvedValue(existing);
raceRepository.update.mockImplementation(async (race: Race) => race);
const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
const newScheduledAt = new Date('2025-01-20T20:00:00Z');
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
raceId: 'race-1',
track: 'New Track',
car: 'New Car',
scheduledAt: newScheduledAt,
});
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ success: true });
expect(raceRepository.update).toHaveBeenCalledTimes(1);
const updated = raceRepository.update.mock.calls[0]?.[0] as Race;
expect(updated.id).toBe('race-1');
expect(updated.leagueId).toBe('league-1');
expect(updated.track).toBe('New Track');
expect(updated.car).toBe('New Car');
expect(updated.scheduledAt.getTime()).toBe(newScheduledAt.getTime());
});
it('returns SEASON_NOT_FOUND when season does not belong to league and does not read/update race', async () => {
const season = createSeasonWithinWindow({ leagueId: 'other-league' });
seasonRepository.findById.mockResolvedValue(season);
const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
raceId: 'race-1',
track: 'New Track',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
UpdateLeagueSeasonScheduleRaceErrorCode,
{ message: string }
>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(raceRepository.findById).not.toHaveBeenCalled();
expect(raceRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('returns RACE_OUTSIDE_SEASON_WINDOW when updated scheduledAt is outside window and does not update', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
const existing = Race.create({
id: 'race-1',
leagueId: 'league-1',
track: 'Old Track',
car: 'Old Car',
scheduledAt: new Date('2025-01-05T20:00:00Z'),
});
raceRepository.findById.mockResolvedValue(existing);
const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
raceId: 'race-1',
scheduledAt: new Date('2025-02-01T00:00:01Z'),
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
UpdateLeagueSeasonScheduleRaceErrorCode,
{ message: string }
>;
expect(error.code).toBe('RACE_OUTSIDE_SEASON_WINDOW');
expect(raceRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('returns RACE_NOT_FOUND when race does not exist for league and does not update', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
raceRepository.findById.mockResolvedValue(null);
const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
raceId: 'race-404',
track: 'New Track',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
UpdateLeagueSeasonScheduleRaceErrorCode,
{ message: string }
>;
expect(error.code).toBe('RACE_NOT_FOUND');
expect(raceRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
});
describe('DeleteLeagueSeasonScheduleRaceUseCase', () => {
let seasonRepository: { findById: Mock };
let raceRepository: { findById: Mock; delete: Mock };
let output: UseCaseOutputPort<DeleteLeagueSeasonScheduleRaceResult> & { present: Mock };
let logger: Logger;
beforeEach(() => {
seasonRepository = { findById: vi.fn() };
raceRepository = { findById: vi.fn(), delete: vi.fn() };
output = { present: vi.fn() };
logger = createLogger();
});
it('deletes race when season belongs to league and race belongs to league', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
const existing = Race.create({
id: 'race-1',
leagueId: 'league-1',
track: 'Track',
car: 'Car',
scheduledAt: new Date('2025-01-05T20:00:00Z'),
});
raceRepository.findById.mockResolvedValue(existing);
raceRepository.delete.mockResolvedValue(undefined);
const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
raceId: 'race-1',
});
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ success: true });
expect(raceRepository.delete).toHaveBeenCalledTimes(1);
expect(raceRepository.delete).toHaveBeenCalledWith('race-1');
});
it('returns SEASON_NOT_FOUND when season does not belong to league and does not read/delete race', async () => {
const season = createSeasonWithinWindow({ leagueId: 'other-league' });
seasonRepository.findById.mockResolvedValue(season);
const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
raceId: 'race-1',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
DeleteLeagueSeasonScheduleRaceErrorCode,
{ message: string }
>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(raceRepository.findById).not.toHaveBeenCalled();
expect(raceRepository.delete).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('returns RACE_NOT_FOUND when race does not exist for league and does not delete', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
raceRepository.findById.mockResolvedValue(null);
const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(
seasonRepository as unknown as ISeasonRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
raceId: 'race-404',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
DeleteLeagueSeasonScheduleRaceErrorCode,
{ message: string }
>;
expect(error.code).toBe('RACE_NOT_FOUND');
expect(raceRepository.delete).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
});
describe('PublishLeagueSeasonScheduleUseCase', () => {
let seasonRepository: { findById: Mock; update: Mock };
let output: UseCaseOutputPort<PublishLeagueSeasonScheduleResult> & { present: Mock };
let logger: Logger;
beforeEach(() => {
seasonRepository = { findById: vi.fn(), update: vi.fn() };
output = { present: vi.fn() };
logger = createLogger();
});
it('publishes schedule deterministically (schedulePublished=true) and persists', async () => {
const season = createSeasonWithinWindow();
seasonRepository.findById.mockResolvedValue(season);
seasonRepository.update.mockResolvedValue(undefined);
const useCase = new PublishLeagueSeasonScheduleUseCase(
seasonRepository as unknown as ISeasonRepository,
logger,
output,
);
const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' });
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({
success: true,
seasonId: 'season-1',
published: true,
});
expect(seasonRepository.update).toHaveBeenCalledTimes(1);
const updatedSeason = seasonRepository.update.mock.calls[0]?.[0] as Season;
expect(updatedSeason.id).toBe('season-1');
expect(updatedSeason.leagueId).toBe('league-1');
expect(updatedSeason.schedulePublished).toBe(true);
});
it('returns SEASON_NOT_FOUND when season does not belong to league and does not update', async () => {
const season = createSeasonWithinWindow({ leagueId: 'other-league' });
seasonRepository.findById.mockResolvedValue(season);
const useCase = new PublishLeagueSeasonScheduleUseCase(
seasonRepository as unknown as ISeasonRepository,
logger,
output,
);
const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' });
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
PublishLeagueSeasonScheduleErrorCode,
{ message: string }
>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(seasonRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
});
describe('UnpublishLeagueSeasonScheduleUseCase', () => {
let seasonRepository: { findById: Mock; update: Mock };
let output: UseCaseOutputPort<UnpublishLeagueSeasonScheduleResult> & { present: Mock };
let logger: Logger;
beforeEach(() => {
seasonRepository = { findById: vi.fn(), update: vi.fn() };
output = { present: vi.fn() };
logger = createLogger();
});
it('unpublishes schedule deterministically (schedulePublished=false) and persists', async () => {
const season = createSeasonWithinWindow().withSchedulePublished(true);
seasonRepository.findById.mockResolvedValue(season);
seasonRepository.update.mockResolvedValue(undefined);
const useCase = new UnpublishLeagueSeasonScheduleUseCase(
seasonRepository as unknown as ISeasonRepository,
logger,
output,
);
const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' });
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({
success: true,
seasonId: 'season-1',
published: false,
});
expect(seasonRepository.update).toHaveBeenCalledTimes(1);
const updatedSeason = seasonRepository.update.mock.calls[0]?.[0] as Season;
expect(updatedSeason.id).toBe('season-1');
expect(updatedSeason.leagueId).toBe('league-1');
expect(updatedSeason.schedulePublished).toBe(false);
});
it('returns SEASON_NOT_FOUND when season does not belong to league and does not update', async () => {
const season = createSeasonWithinWindow({ leagueId: 'other-league' });
seasonRepository.findById.mockResolvedValue(season);
const useCase = new UnpublishLeagueSeasonScheduleUseCase(
seasonRepository as unknown as ISeasonRepository,
logger,
output,
);
const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' });
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
UnpublishLeagueSeasonScheduleErrorCode,
{ message: string }
>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(seasonRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -82,9 +82,9 @@ describe('ListSeasonsForLeagueUseCase', () => {
const secondSeason = presented.seasons.find((s) => s.id === 'season-2');
expect(firstSeason?.name).toBe('Season One');
expect(firstSeason?.status).toBe('planned');
expect(firstSeason?.status.toString()).toBe('planned');
expect(secondSeason?.name).toBe('Season Two');
expect(secondSeason?.status).toBe('active');
expect(secondSeason?.status.toString()).toBe('active');
});
it('returns error when league not found and does not call output', async () => {

View File

@@ -1,6 +1,6 @@
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { SeasonStatus } from '../../domain/entities/season/Season';
import type { SeasonStatus } from '../../domain/value-objects/SeasonStatus';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';

View File

@@ -74,7 +74,7 @@ describe('ManageSeasonLifecycleUseCase', () => {
const [firstCall] = output.present.mock.calls;
const [firstArg] = firstCall as [ManageSeasonLifecycleResult];
let presented = firstArg;
expect(presented.season.status).toBe('active');
expect(presented.season.status.toString()).toBe('active');
(output.present as Mock).mockClear();
@@ -92,7 +92,7 @@ describe('ManageSeasonLifecycleUseCase', () => {
const [[arg]] = output.present.mock.calls as [[ManageSeasonLifecycleResult]];
presented = arg;
}
expect(presented.season.status).toBe('completed');
expect(presented.season.status.toString()).toBe('completed');
(output.present as Mock).mockClear();
@@ -111,7 +111,7 @@ describe('ManageSeasonLifecycleUseCase', () => {
expect(presentedRaw).toBeDefined();
presented = presentedRaw as ManageSeasonLifecycleResult;
}
expect(presented.season.status).toBe('archived');
expect(presented.season.status.toString()).toBe('archived');
});
it('propagates domain invariant errors for invalid transitions', async () => {

View File

@@ -1,6 +1,6 @@
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { SeasonStatus } from '../../domain/entities/season/Season';
import type { SeasonStatus } from '../../domain/value-objects/SeasonStatus';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application';

View File

@@ -0,0 +1,74 @@
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
export type PublishLeagueSeasonScheduleInput = {
leagueId: string;
seasonId: string;
};
export type PublishLeagueSeasonScheduleResult = {
success: true;
seasonId: string;
published: true;
};
export type PublishLeagueSeasonScheduleErrorCode =
| 'SEASON_NOT_FOUND'
| 'REPOSITORY_ERROR';
export class PublishLeagueSeasonScheduleUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<PublishLeagueSeasonScheduleResult>,
) {}
async execute(
input: PublishLeagueSeasonScheduleInput,
): Promise<
Result<
void,
ApplicationErrorCode<PublishLeagueSeasonScheduleErrorCode, { message: string }>
>
> {
this.logger.debug('Publishing league season schedule', {
leagueId: input.leagueId,
seasonId: input.seasonId,
});
try {
const season = await this.seasonRepository.findById(input.seasonId);
if (!season || season.leagueId !== input.leagueId) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season not found for league' },
});
}
await this.seasonRepository.update(season.withSchedulePublished(true));
const result: PublishLeagueSeasonScheduleResult = {
success: true,
seasonId: season.id,
published: true,
};
this.output.present(result);
return Result.ok(undefined);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
this.logger.error('Failed to publish league season schedule', error, {
leagueId: input.leagueId,
seasonId: input.seasonId,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error.message },
});
}
}
}

View File

@@ -1,225 +1,67 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import {
RejectLeagueJoinRequestUseCase,
type RejectLeagueJoinRequestInput,
type RejectLeagueJoinRequestResult,
type RejectLeagueJoinRequestErrorCode,
} from './RejectLeagueJoinRequestUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
interface LeagueRepositoryMock {
findById: Mock;
}
interface LeagueMembershipRepositoryMock {
getMembership: Mock;
getJoinRequests: Mock;
removeJoinRequest: Mock;
}
describe('RejectLeagueJoinRequestUseCase', () => {
let leagueRepository: LeagueRepositoryMock;
let leagueMembershipRepository: LeagueMembershipRepositoryMock;
let logger: Logger & { info: Mock; warn: Mock; error: Mock; debug: Mock };
let output: UseCaseOutputPort<RejectLeagueJoinRequestResult> & { present: Mock };
let useCase: RejectLeagueJoinRequestUseCase;
let leagueMembershipRepository: {
getJoinRequests: Mock;
removeJoinRequest: Mock;
};
beforeEach(() => {
leagueRepository = {
findById: vi.fn(),
};
leagueMembershipRepository = {
getMembership: vi.fn(),
getJoinRequests: vi.fn(),
removeJoinRequest: vi.fn(),
};
});
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger & { info: Mock; warn: Mock; error: Mock; debug: Mock };
output = {
it('reject removes request only', async () => {
const output: UseCaseOutputPort<RejectLeagueJoinRequestResult> & { present: Mock } = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<RejectLeagueJoinRequestResult> & { present: Mock };
} as any;
useCase = new RejectLeagueJoinRequestUseCase(
leagueRepository as unknown as ILeagueRepository,
const useCase = new RejectLeagueJoinRequestUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
logger,
);
leagueMembershipRepository.getJoinRequests.mockResolvedValue([
{ id: 'jr-1', leagueId: 'league-1', driverId: 'driver-1' },
]);
const result = await useCase.execute(
{ leagueId: 'league-1', joinRequestId: 'jr-1' },
output,
);
});
it('rejects a pending join request successfully and presents result', async () => {
const input: RejectLeagueJoinRequestInput = {
leagueId: 'league-1',
adminId: 'admin-1',
requestId: 'req-1',
};
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
leagueMembershipRepository.getMembership.mockResolvedValue({
leagueId: 'league-1',
driverId: 'admin-1',
role: { toString: () => 'owner' },
status: { toString: () => 'active' },
});
leagueMembershipRepository.getJoinRequests.mockResolvedValue([
{
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
status: 'pending',
},
]);
leagueMembershipRepository.removeJoinRequest.mockResolvedValue(undefined);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presentedRaw = output.present.mock.calls[0]?.[0];
expect(presentedRaw).toBeDefined();
const presented = presentedRaw as RejectLeagueJoinRequestResult;
expect(presented.leagueId).toBe('league-1');
expect(presented.requestId).toBe('req-1');
expect(presented.status).toBe('rejected');
expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith('req-1');
expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith('jr-1');
expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request rejected.' });
});
it('returns LEAGUE_NOT_FOUND when league does not exist', async () => {
const input: RejectLeagueJoinRequestInput = {
leagueId: 'missing-league',
adminId: 'admin-1',
requestId: 'req-1',
};
it('reject returns error when request missing', async () => {
const output: UseCaseOutputPort<RejectLeagueJoinRequestResult> & { present: Mock } = {
present: vi.fn(),
} as any;
leagueRepository.findById.mockResolvedValue(null);
const useCase = new RejectLeagueJoinRequestUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
RejectLeagueJoinRequestErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled();
});
it('returns UNAUTHORIZED when admin is not authorized', async () => {
const input: RejectLeagueJoinRequestInput = {
leagueId: 'league-1',
adminId: 'user-1',
requestId: 'req-1',
};
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
leagueMembershipRepository.getMembership.mockResolvedValue(null);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
RejectLeagueJoinRequestErrorCode,
{ message: string }
>;
expect(err.code).toBe('UNAUTHORIZED');
expect(output.present).not.toHaveBeenCalled();
expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled();
});
it('returns REQUEST_NOT_FOUND when join request does not exist', async () => {
const input: RejectLeagueJoinRequestInput = {
leagueId: 'league-1',
adminId: 'admin-1',
requestId: 'missing-req',
};
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
leagueMembershipRepository.getMembership.mockResolvedValue({
leagueId: 'league-1',
driverId: 'admin-1',
role: { toString: () => 'owner' },
status: { toString: () => 'active' },
});
leagueMembershipRepository.getJoinRequests.mockResolvedValue([]);
const result = await useCase.execute(input);
const result = await useCase.execute(
{ leagueId: 'league-1', joinRequestId: 'jr-404' },
output,
);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
RejectLeagueJoinRequestErrorCode,
{ message: string }
>;
expect(err.code).toBe('REQUEST_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled();
});
it('returns INVALID_REQUEST_STATE when join request is not pending', async () => {
const input: RejectLeagueJoinRequestInput = {
leagueId: 'league-1',
adminId: 'admin-1',
requestId: 'req-1',
};
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
leagueMembershipRepository.getMembership.mockResolvedValue({
leagueId: 'league-1',
driverId: 'admin-1',
role: { toString: () => 'owner' },
status: { toString: () => 'active' },
});
leagueMembershipRepository.getJoinRequests.mockResolvedValue([
{
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
status: 'approved',
},
]);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
RejectLeagueJoinRequestErrorCode,
{ message: string }
>;
expect(err.code).toBe('INVALID_REQUEST_STATE');
expect(output.present).not.toHaveBeenCalled();
expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled();
});
it('returns REPOSITORY_ERROR when repository throws', async () => {
const input: RejectLeagueJoinRequestInput = {
leagueId: 'league-1',
adminId: 'admin-1',
requestId: 'req-1',
};
const repoError = new Error('Repository failure');
leagueRepository.findById.mockRejectedValue(repoError);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
RejectLeagueJoinRequestErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository failure');
const err = result.unwrapErr() as ApplicationErrorCode<'JOIN_REQUEST_NOT_FOUND'>;
expect(err.code).toBe('JOIN_REQUEST_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled();
});

View File

@@ -1,130 +1,36 @@
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
export type RejectLeagueJoinRequestInput = {
leagueId: string;
adminId: string;
requestId: string;
reason?: string;
joinRequestId: string;
};
export type RejectLeagueJoinRequestResult = {
leagueId: string;
requestId: string;
status: 'rejected';
success: boolean;
message: string;
};
export type RejectLeagueJoinRequestErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'REQUEST_NOT_FOUND'
| 'UNAUTHORIZED'
| 'INVALID_REQUEST_STATE'
| 'REPOSITORY_ERROR';
export class RejectLeagueJoinRequestUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<RejectLeagueJoinRequestResult>,
) {}
async execute(
input: RejectLeagueJoinRequestInput,
): Promise<Result<void, ApplicationErrorCode<RejectLeagueJoinRequestErrorCode, { message: string }>>> {
const { leagueId, adminId, requestId, reason } = input;
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
this.logger.warn('League not found when rejecting join request', { leagueId, adminId, requestId });
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const adminMembership = await this.leagueMembershipRepository.getMembership(leagueId, adminId);
if (
!adminMembership ||
adminMembership.status.toString() !== 'active' ||
(adminMembership.role.toString() !== 'owner' && adminMembership.role.toString() !== 'admin')
) {
this.logger.warn('User is not authorized to reject league join requests', {
leagueId,
adminId,
requestId,
});
return Result.err({
code: 'UNAUTHORIZED',
details: { message: 'User is not authorized to reject league join requests' },
});
}
const joinRequests = await this.leagueMembershipRepository.getJoinRequests(leagueId);
const joinRequest = joinRequests.find(r => r.id === requestId);
if (!joinRequest) {
this.logger.warn('Join request not found when rejecting', { leagueId, adminId, requestId });
return Result.err({
code: 'REQUEST_NOT_FOUND',
details: { message: 'Join request not found' },
});
}
const currentStatus = (() => {
const rawStatus = (joinRequest as unknown as { status?: unknown }).status;
return rawStatus === 'pending' || rawStatus === 'approved' || rawStatus === 'rejected'
? rawStatus
: 'pending';
})();
if (currentStatus !== 'pending') {
this.logger.warn('Join request is in invalid state for rejection', {
leagueId,
adminId,
requestId,
currentStatus,
});
return Result.err({
code: 'INVALID_REQUEST_STATE',
details: { message: 'Join request is not in a pending state' },
});
}
await this.leagueMembershipRepository.removeJoinRequest(requestId);
const result: RejectLeagueJoinRequestResult = {
leagueId,
requestId,
status: 'rejected',
};
this.output.present(result);
this.logger.info('League join request rejected successfully', {
leagueId,
adminId,
requestId,
reason,
});
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
this.logger.error('Failed to reject league join request', err, {
leagueId,
adminId,
requestId,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: err.message ?? 'Failed to reject league join request',
},
});
output: UseCaseOutputPort<RejectLeagueJoinRequestResult>,
): Promise<Result<void, ApplicationErrorCode<'JOIN_REQUEST_NOT_FOUND'>>> {
const requests = await this.leagueMembershipRepository.getJoinRequests(input.leagueId);
const request = requests.find((r) => r.id === input.joinRequestId);
if (!request) {
return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' });
}
await this.leagueMembershipRepository.removeJoinRequest(input.joinRequestId);
output.present({ success: true, message: 'Join request rejected.' });
return Result.ok(undefined);
}
}

View File

@@ -11,12 +11,13 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
describe('RemoveLeagueMemberUseCase', () => {
let useCase: RemoveLeagueMemberUseCase;
let leagueMembershipRepository: { getMembership: Mock; saveMembership: Mock };
let leagueMembershipRepository: { getMembership: Mock; getLeagueMembers: Mock; saveMembership: Mock };
let output: UseCaseOutputPort<RemoveLeagueMemberResult> & { present: Mock };
beforeEach(() => {
leagueMembershipRepository = {
getMembership: vi.fn(),
getLeagueMembers: vi.fn(),
saveMembership: vi.fn(),
};
output = {
@@ -104,4 +105,33 @@ describe('RemoveLeagueMemberUseCase', () => {
expect(output.present).not.toHaveBeenCalled();
});
it('prevents removing the last owner', async () => {
const leagueId = 'league-1';
const targetDriverId = 'owner-1';
const membership = {
id: `${leagueId}:${targetDriverId}`,
leagueId: { toString: () => leagueId },
driverId: { toString: () => targetDriverId },
role: { toString: () => 'owner' },
status: { toString: () => 'active' },
joinedAt: { toDate: () => new Date() },
};
leagueMembershipRepository.getMembership.mockResolvedValue(membership);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue([membership]);
const result = await useCase.execute({ leagueId, targetDriverId });
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
RemoveLeagueMemberErrorCode,
{ message: string }
>;
expect(error.code).toBe('CANNOT_REMOVE_LAST_OWNER');
expect(output.present).not.toHaveBeenCalled();
expect(leagueMembershipRepository.saveMembership).not.toHaveBeenCalled();
});
});

View File

@@ -15,7 +15,10 @@ export interface RemoveLeagueMemberResult {
removedRole: string;
}
export type RemoveLeagueMemberErrorCode = 'MEMBERSHIP_NOT_FOUND' | 'REPOSITORY_ERROR';
export type RemoveLeagueMemberErrorCode =
| 'MEMBERSHIP_NOT_FOUND'
| 'CANNOT_REMOVE_LAST_OWNER'
| 'REPOSITORY_ERROR';
export class RemoveLeagueMemberUseCase {
constructor(
@@ -39,6 +42,18 @@ export class RemoveLeagueMemberUseCase {
});
}
if (membership.role.toString() === 'owner') {
const members = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const ownerCount = members.filter(m => m.role.toString() === 'owner').length;
if (ownerCount <= 1) {
return Result.err({
code: 'CANNOT_REMOVE_LAST_OWNER',
details: { message: 'Cannot remove the last owner' },
});
}
}
const updatedMembership = LeagueMembership.create({
id: membership.id,
leagueId: membership.leagueId.toString(),

View File

@@ -87,19 +87,15 @@ describe('ReopenRaceUseCase', () => {
expect(result.isOk()).toBe(true);
expect(raceRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'race-1',
status: 'scheduled',
}),
);
expect(raceRepository.update).toHaveBeenCalledTimes(1);
const updatedRace = (raceRepository.update as Mock).mock.calls[0]?.[0] as Race;
expect(updatedRace.id).toBe('race-1');
expect(updatedRace.status.toString()).toBe('scheduled');
expect(output.present).toHaveBeenCalledWith({
race: expect.objectContaining({
id: 'race-1',
status: 'scheduled',
}),
});
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as Mock).mock.calls[0]?.[0] as ReopenRaceResult;
expect(presented.race.id).toBe('race-1');
expect(presented.race.status.toString()).toBe('scheduled');
expect(logger.info).toHaveBeenCalled();
});

View File

@@ -193,7 +193,7 @@ describe('CreateSeasonForLeagueUseCase', () => {
expect(season.leagueId).toBe('league-1');
expect(season.gameId).toBe('iracing');
expect(season.name).toBe('Season from Config');
expect(season.status).toBe('planned');
expect(season.status.toString()).toBe('planned');
// Schedule is optional when timings lack seasonStartDate / raceStartTime.
expect(season.schedule).toBeUndefined();
@@ -244,7 +244,7 @@ describe('CreateSeasonForLeagueUseCase', () => {
expect(season.id).not.toBe(sourceSeason.id);
expect(season.leagueId).toBe(sourceSeason.leagueId);
expect(season.gameId).toBe(sourceSeason.gameId);
expect(season.status).toBe('planned');
expect(season.status.toString()).toBe('planned');
expect(season.maxDrivers).toBe(sourceSeason.maxDrivers);
expect(season.schedule).toBe(sourceSeason.schedule);
expect(season.scoringConfig).toBe(sourceSeason.scoringConfig);
@@ -419,7 +419,7 @@ describe('GetSeasonDetailsUseCase', () => {
expect(payload.season.leagueId).toBe('league-1');
expect(payload.season.gameId).toBe('iracing');
expect(payload.season.name).toBe('Detailed Season');
expect(payload.season.status).toBe('planned');
expect(payload.season.status.toString()).toBe('planned');
expect(payload.season.maxDrivers).toBe(24);
});
@@ -512,7 +512,7 @@ describe('ManageSeasonLifecycleUseCase', () => {
const activatePayloadRaw = (output.present as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
expect(activatePayloadRaw).toBeDefined();
const activatePayload = activatePayloadRaw as ManageSeasonLifecycleResult;
expect(activatePayload.season.status).toBe('active');
expect(activatePayload.season.status.toString()).toBe('active');
const completeCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
@@ -526,7 +526,7 @@ describe('ManageSeasonLifecycleUseCase', () => {
const completePayloadRaw = (output.present as ReturnType<typeof vi.fn>).mock.calls[1]?.[0];
expect(completePayloadRaw).toBeDefined();
const completePayload = completePayloadRaw as ManageSeasonLifecycleResult;
expect(completePayload.season.status).toBe('completed');
expect(completePayload.season.status.toString()).toBe('completed');
const archiveCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
@@ -540,9 +540,9 @@ describe('ManageSeasonLifecycleUseCase', () => {
const archivePayloadRaw = (output.present as ReturnType<typeof vi.fn>).mock.calls[2]?.[0];
expect(archivePayloadRaw).toBeDefined();
const archivePayload = archivePayloadRaw as ManageSeasonLifecycleResult;
expect(archivePayload.season.status).toBe('archived');
expect(archivePayload.season.status.toString()).toBe('archived');
expect(currentSeason.status).toBe('archived');
expect(currentSeason.status.toString()).toBe('archived');
});
it('returns INVALID_LIFECYCLE_TRANSITION for invalid transitions and does not call output', async () => {

View File

@@ -0,0 +1,74 @@
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
export type UnpublishLeagueSeasonScheduleInput = {
leagueId: string;
seasonId: string;
};
export type UnpublishLeagueSeasonScheduleResult = {
success: true;
seasonId: string;
published: false;
};
export type UnpublishLeagueSeasonScheduleErrorCode =
| 'SEASON_NOT_FOUND'
| 'REPOSITORY_ERROR';
export class UnpublishLeagueSeasonScheduleUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<UnpublishLeagueSeasonScheduleResult>,
) {}
async execute(
input: UnpublishLeagueSeasonScheduleInput,
): Promise<
Result<
void,
ApplicationErrorCode<UnpublishLeagueSeasonScheduleErrorCode, { message: string }>
>
> {
this.logger.debug('Unpublishing league season schedule', {
leagueId: input.leagueId,
seasonId: input.seasonId,
});
try {
const season = await this.seasonRepository.findById(input.seasonId);
if (!season || season.leagueId !== input.leagueId) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season not found for league' },
});
}
await this.seasonRepository.update(season.withSchedulePublished(false));
const result: UnpublishLeagueSeasonScheduleResult = {
success: true,
seasonId: season.id,
published: false,
};
this.output.present(result);
return Result.ok(undefined);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
this.logger.error('Failed to unpublish league season schedule', error, {
leagueId: input.leagueId,
seasonId: input.seasonId,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error.message },
});
}
}
}

View File

@@ -115,4 +115,80 @@ describe('UpdateLeagueMemberRoleUseCase', () => {
expect(error.details.message).toBe('Database connection failed');
expect(output.present).not.toHaveBeenCalled();
});
it('rejects invalid roles', async () => {
const mockMembership = {
id: 'league-1:driver-1',
leagueId: { toString: () => 'league-1' },
driverId: { toString: () => 'driver-1' },
role: { toString: () => 'member' },
status: { toString: () => 'active' },
joinedAt: { toDate: () => new Date() },
};
const mockLeagueMembershipRepository = {
getLeagueMembers: vi.fn().mockResolvedValue([mockMembership]),
saveMembership: vi.fn().mockResolvedValue(undefined),
} as unknown as ILeagueMembershipRepository;
const output: UseCaseOutputPort<UpdateLeagueMemberRoleResult> & { present: Mock } = {
present: vi.fn(),
};
const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository, output);
const result = await useCase.execute({
leagueId: 'league-1',
targetDriverId: 'driver-1',
newRole: 'manager',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
UpdateLeagueMemberRoleErrorCode,
{ message: string }
>;
expect(error.code).toBe('INVALID_ROLE');
expect(output.present).not.toHaveBeenCalled();
expect(mockLeagueMembershipRepository.saveMembership).not.toHaveBeenCalled();
});
it('prevents downgrading the last owner', async () => {
const mockOwnerMembership = {
id: 'league-1:owner-1',
leagueId: { toString: () => 'league-1' },
driverId: { toString: () => 'owner-1' },
role: { toString: () => 'owner' },
status: { toString: () => 'active' },
joinedAt: { toDate: () => new Date() },
};
const mockLeagueMembershipRepository = {
getLeagueMembers: vi.fn().mockResolvedValue([mockOwnerMembership]),
saveMembership: vi.fn().mockResolvedValue(undefined),
} as unknown as ILeagueMembershipRepository;
const output: UseCaseOutputPort<UpdateLeagueMemberRoleResult> & { present: Mock } = {
present: vi.fn(),
};
const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository, output);
const result = await useCase.execute({
leagueId: 'league-1',
targetDriverId: 'owner-1',
newRole: 'admin',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
UpdateLeagueMemberRoleErrorCode,
{ message: string }
>;
expect(error.code).toBe('CANNOT_DOWNGRADE_LAST_OWNER');
expect(output.present).not.toHaveBeenCalled();
expect(mockLeagueMembershipRepository.saveMembership).not.toHaveBeenCalled();
});
});

View File

@@ -3,6 +3,7 @@ import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { LeagueMembership } from '../../domain/entities/LeagueMembership';
import type { MembershipRoleValue } from '../../domain/entities/MembershipRole';
export type UpdateLeagueMemberRoleInput = {
leagueId: string;
@@ -16,6 +17,8 @@ export type UpdateLeagueMemberRoleResult = {
export type UpdateLeagueMemberRoleErrorCode =
| 'MEMBERSHIP_NOT_FOUND'
| 'INVALID_ROLE'
| 'CANNOT_DOWNGRADE_LAST_OWNER'
| 'REPOSITORY_ERROR';
export class UpdateLeagueMemberRoleUseCase {
@@ -40,11 +43,31 @@ export class UpdateLeagueMemberRoleUseCase {
});
}
const allowedRoles: MembershipRoleValue[] = ['owner', 'admin', 'steward', 'member'];
const requestedRole = input.newRole as MembershipRoleValue;
if (!allowedRoles.includes(requestedRole)) {
return Result.err({
code: 'INVALID_ROLE',
details: { message: 'Invalid membership role' },
});
}
const ownerCount = memberships.filter(m => m.role.toString() === 'owner').length;
const isTargetOwner = membership.role.toString() === 'owner';
if (isTargetOwner && requestedRole !== 'owner' && ownerCount <= 1) {
return Result.err({
code: 'CANNOT_DOWNGRADE_LAST_OWNER',
details: { message: 'Cannot downgrade the last owner' },
});
}
const updatedMembership = LeagueMembership.create({
id: membership.id,
leagueId: membership.leagueId.toString(),
driverId: membership.driverId.toString(),
role: input.newRole,
role: requestedRole,
status: membership.status.toString(),
joinedAt: membership.joinedAt.toDate(),
});

View File

@@ -0,0 +1,160 @@
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Race } from '../../domain/entities/Race';
import type { Season } from '../../domain/entities/season/Season';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
export type UpdateLeagueSeasonScheduleRaceInput = {
leagueId: string;
seasonId: string;
raceId: string;
track?: string;
car?: string;
scheduledAt?: Date;
};
export type UpdateLeagueSeasonScheduleRaceResult = {
success: true;
};
export type UpdateLeagueSeasonScheduleRaceErrorCode =
| 'SEASON_NOT_FOUND'
| 'RACE_NOT_FOUND'
| 'RACE_OUTSIDE_SEASON_WINDOW'
| 'INVALID_INPUT'
| 'REPOSITORY_ERROR';
export class UpdateLeagueSeasonScheduleRaceUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<UpdateLeagueSeasonScheduleRaceResult>,
) {}
async execute(
input: UpdateLeagueSeasonScheduleRaceInput,
): Promise<
Result<
void,
ApplicationErrorCode<UpdateLeagueSeasonScheduleRaceErrorCode, { message: string }>
>
> {
this.logger.debug('Updating league season schedule race', {
leagueId: input.leagueId,
seasonId: input.seasonId,
raceId: input.raceId,
});
try {
const season = await this.seasonRepository.findById(input.seasonId);
if (!season || season.leagueId !== input.leagueId) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season not found for league' },
});
}
const existing = await this.raceRepository.findById(input.raceId);
if (!existing || existing.leagueId !== input.leagueId) {
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found for league' },
});
}
const nextTrack = input.track ?? existing.track;
const nextCar = input.car ?? existing.car;
const nextScheduledAt = input.scheduledAt ?? existing.scheduledAt;
if (!this.isWithinSeasonWindow(season, nextScheduledAt)) {
return Result.err({
code: 'RACE_OUTSIDE_SEASON_WINDOW',
details: { message: 'Race scheduledAt is outside the season schedule window' },
});
}
let updated: Race;
try {
updated = Race.create({
id: existing.id,
leagueId: existing.leagueId,
track: nextTrack,
car: nextCar,
scheduledAt: nextScheduledAt,
...(existing.trackId !== undefined ? { trackId: existing.trackId } : {}),
...(existing.carId !== undefined ? { carId: existing.carId } : {}),
sessionType: existing.sessionType,
status: existing.status.toString(),
...(existing.strengthOfFieldNumber !== undefined ? { strengthOfField: existing.strengthOfFieldNumber } : {}),
...(existing.registeredCountNumber !== undefined ? { registeredCount: existing.registeredCountNumber } : {}),
...(existing.maxParticipantsNumber !== undefined ? { maxParticipants: existing.maxParticipantsNumber } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Invalid race update';
return Result.err({ code: 'INVALID_INPUT', details: { message } });
}
await this.raceRepository.update(updated);
const result: UpdateLeagueSeasonScheduleRaceResult = { success: true };
this.output.present(result);
return Result.ok(undefined);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
this.logger.error('Failed to update league season schedule race', error, {
leagueId: input.leagueId,
seasonId: input.seasonId,
raceId: input.raceId,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error.message },
});
}
}
private isWithinSeasonWindow(season: Season, scheduledAt: Date): boolean {
const { start, endInclusive } = this.getSeasonDateWindow(season);
if (!start && !endInclusive) return true;
const t = scheduledAt.getTime();
if (start && t < start.getTime()) return false;
if (endInclusive && t > endInclusive.getTime()) return false;
return true;
}
private getSeasonDateWindow(season: Season): { start?: Date; endInclusive?: Date } {
const start = season.startDate ?? season.schedule?.startDate;
const window: { start?: Date; endInclusive?: Date } = {};
if (start) {
window.start = start;
}
if (season.endDate) {
window.endInclusive = season.endDate;
return window;
}
if (season.schedule) {
const slots = SeasonScheduleGenerator.generateSlotsUpTo(
season.schedule,
season.schedule.plannedRounds,
);
const last = slots.at(-1);
if (last?.scheduledAt) {
window.endInclusive = last.scheduledAt;
}
return window;
}
return window;
}
}

View File

@@ -22,7 +22,7 @@ describe('League', () => {
name: 'Test League',
description: 'A test league',
ownerId: 'owner1',
})).toThrow('League ID cannot be empty');
})).toThrow('League ID is required');
});
it('should throw on invalid name', () => {
@@ -31,7 +31,7 @@ describe('League', () => {
name: '',
description: 'A test league',
ownerId: 'owner1',
})).toThrow('League name cannot be empty');
})).toThrow('League name is required');
});
it('should throw on name too long', () => {
@@ -50,7 +50,7 @@ describe('League', () => {
name: 'Test League',
description: '',
ownerId: 'owner1',
})).toThrow('League description cannot be empty');
})).toThrow('League description is required');
});
it('should throw on description too long', () => {
@@ -69,7 +69,7 @@ describe('League', () => {
name: 'Test League',
description: 'A test league',
ownerId: '',
})).toThrow('League owner ID cannot be empty');
})).toThrow('League owner ID is required');
});
it('should create with social links', () => {

View File

@@ -20,7 +20,7 @@ describe('Race', () => {
expect(race.track).toBe('Monza');
expect(race.car).toBe('Ferrari SF21');
expect(race.sessionType).toEqual(SessionType.main());
expect(race.status).toBe('scheduled');
expect(race.status.toString()).toBe('scheduled');
expect(race.trackId).toBeUndefined();
expect(race.carId).toBeUndefined();
expect(race.strengthOfField).toBeUndefined();
@@ -53,10 +53,10 @@ describe('Race', () => {
expect(race.car).toBe('Ferrari SF21');
expect(race.carId).toBe('car-1');
expect(race.sessionType).toEqual(SessionType.qualifying());
expect(race.status).toBe('running');
expect(race.strengthOfField).toBe(1500);
expect(race.registeredCount).toBe(20);
expect(race.maxParticipants).toBe(24);
expect(race.status.toString()).toBe('running');
expect(race.strengthOfField?.toNumber()).toBe(1500);
expect(race.registeredCount?.toNumber()).toBe(20);
expect(race.maxParticipants?.toNumber()).toBe(24);
});
it('should throw error for invalid id', () => {
@@ -126,7 +126,7 @@ describe('Race', () => {
status: 'scheduled',
});
const started = race.start();
expect(started.status).toBe('running');
expect(started.status.toString()).toBe('running');
});
it('should throw error if not scheduled', () => {
@@ -155,7 +155,7 @@ describe('Race', () => {
status: 'running',
});
const completed = race.complete();
expect(completed.status).toBe('completed');
expect(completed.status.toString()).toBe('completed');
});
it('should throw error if already completed', () => {
@@ -197,7 +197,7 @@ describe('Race', () => {
status: 'scheduled',
});
const cancelled = race.cancel();
expect(cancelled.status).toBe('cancelled');
expect(cancelled.status.toString()).toBe('cancelled');
});
it('should throw error if completed', () => {
@@ -238,8 +238,8 @@ describe('Race', () => {
car: 'Ferrari SF21',
});
const updated = race.updateField(1600, 22);
expect(updated.strengthOfField).toBe(1600);
expect(updated.registeredCount).toBe(22);
expect(updated.strengthOfField?.toNumber()).toBe(1600);
expect(updated.registeredCount?.toNumber()).toBe(22);
});
});

View File

@@ -141,14 +141,6 @@ export class Race implements IEntity<string> {
strengthOfField = StrengthOfField.create(props.strengthOfField);
}
// Validate scheduled time is not in the past for new races
// Allow some flexibility for testing and bootstrap scenarios
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
if (status.isScheduled() && props.scheduledAt < oneHourAgo) {
throw new RacingDomainValidationError('Scheduled time cannot be more than 1 hour in the past');
}
return new Race({
id: props.id,
leagueId: props.leagueId,
@@ -219,6 +211,14 @@ export class Race implements IEntity<string> {
* Cancel the race
*/
cancel(): Race {
if (this.status.isCancelled()) {
throw new RacingDomainInvariantError('Race is already cancelled');
}
if (this.status.isCompleted()) {
throw new RacingDomainInvariantError('Cannot cancel completed race');
}
const transition = this.status.canTransitionTo('cancelled');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
@@ -234,9 +234,15 @@ export class Race implements IEntity<string> {
...(this.carId !== undefined ? { carId: this.carId } : {}),
sessionType: this.sessionType,
status: 'cancelled',
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}),
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
...(this.strengthOfField !== undefined
? { strengthOfField: this.strengthOfField.toNumber() }
: {}),
...(this.registeredCount !== undefined
? { registeredCount: this.registeredCount.toNumber() }
: {}),
...(this.maxParticipants !== undefined
? { maxParticipants: this.maxParticipants.toNumber() }
: {}),
});
}
@@ -244,6 +250,14 @@ export class Race implements IEntity<string> {
* Re-open a previously completed or cancelled race
*/
reopen(): Race {
if (this.status.isScheduled()) {
throw new RacingDomainInvariantError('Race is already scheduled');
}
if (this.status.isRunning()) {
throw new RacingDomainInvariantError('Cannot reopen running race');
}
const transition = this.status.canTransitionTo('scheduled');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
@@ -259,9 +273,15 @@ export class Race implements IEntity<string> {
...(this.carId !== undefined ? { carId: this.carId } : {}),
sessionType: this.sessionType,
status: 'scheduled',
...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField.toNumber() } : {}),
...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}),
...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}),
...(this.strengthOfField !== undefined
? { strengthOfField: this.strengthOfField.toNumber() }
: {}),
...(this.registeredCount !== undefined
? { registeredCount: this.registeredCount.toNumber() }
: {}),
...(this.maxParticipants !== undefined
? { maxParticipants: this.maxParticipants.toNumber() }
: {}),
});
}

View File

@@ -76,7 +76,7 @@ describe('Track', () => {
lengthKm: 5.793,
turns: 11,
gameId: 'game1',
})).toThrow('Track name is required');
})).toThrow('Track name cannot be empty');
});
it('should throw on invalid country', () => {

View File

@@ -21,17 +21,17 @@ describe('Season aggregate lifecycle', () => {
const planned = createMinimalSeason({ status: 'planned' });
const activated = planned.activate();
expect(activated.status).toBe('active');
expect(activated.status.toString()).toBe('active');
expect(activated.startDate).toBeInstanceOf(Date);
expect(activated.endDate).toBeUndefined();
const completed = activated.complete();
expect(completed.status).toBe('completed');
expect(completed.status.toString()).toBe('completed');
expect(completed.startDate).toEqual(activated.startDate);
expect(completed.endDate).toBeInstanceOf(Date);
const archived = completed.archive();
expect(archived.status).toBe('archived');
expect(archived.status.toString()).toBe('archived');
expect(archived.startDate).toEqual(completed.startDate);
expect(archived.endDate).toEqual(completed.endDate);
});
@@ -79,12 +79,12 @@ describe('Season aggregate lifecycle', () => {
const archived = createMinimalSeason({ status: 'archived' });
const cancelledFromPlanned = planned.cancel();
expect(cancelledFromPlanned.status).toBe('cancelled');
expect(cancelledFromPlanned.status.toString()).toBe('cancelled');
expect(cancelledFromPlanned.startDate).toBe(planned.startDate);
expect(cancelledFromPlanned.endDate).toBeInstanceOf(Date);
const cancelledFromActive = active.cancel();
expect(cancelledFromActive.status).toBe('cancelled');
expect(cancelledFromActive.status.toString()).toBe('cancelled');
expect(cancelledFromActive.startDate).toBe(active.startDate);
expect(cancelledFromActive.endDate).toBeInstanceOf(Date);

View File

@@ -22,6 +22,7 @@ export class Season implements IEntity<string> {
readonly startDate: Date | undefined;
readonly endDate: Date | undefined;
readonly schedule: SeasonSchedule | undefined;
readonly schedulePublished: boolean;
readonly scoringConfig: SeasonScoringConfig | undefined;
readonly dropPolicy: SeasonDropPolicy | undefined;
readonly stewardingConfig: SeasonStewardingConfig | undefined;
@@ -41,6 +42,7 @@ export class Season implements IEntity<string> {
startDate?: Date;
endDate?: Date;
schedule?: SeasonSchedule;
schedulePublished: boolean;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
@@ -57,6 +59,7 @@ export class Season implements IEntity<string> {
this.startDate = props.startDate;
this.endDate = props.endDate;
this.schedule = props.schedule;
this.schedulePublished = props.schedulePublished;
this.scoringConfig = props.scoringConfig;
this.dropPolicy = props.dropPolicy;
this.stewardingConfig = props.stewardingConfig;
@@ -75,6 +78,7 @@ export class Season implements IEntity<string> {
startDate?: Date;
endDate?: Date | undefined;
schedule?: SeasonSchedule;
schedulePublished?: boolean;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
@@ -162,6 +166,7 @@ export class Season implements IEntity<string> {
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
schedulePublished: props.schedulePublished ?? false,
...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}),
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}),
@@ -348,16 +353,16 @@ export class Season implements IEntity<string> {
* Cancel a planned or active season.
*/
cancel(): Season {
// If already cancelled, return this (idempotent).
if (this.status.isCancelled()) {
return this;
}
const transition = this.status.canTransitionTo('cancelled');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
}
// If already cancelled, return this
if (this.status.isCancelled()) {
return this;
}
// Ensure end date is set
const endDate = this.endDate ?? new Date();
@@ -400,6 +405,28 @@ export class Season implements IEntity<string> {
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
schedule,
schedulePublished: this.schedulePublished,
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: this._participantCount.toNumber(),
});
}
withSchedulePublished(published: boolean): Season {
return Season.create({
id: this.id,
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status.toString(),
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
schedulePublished: published,
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
@@ -544,16 +571,16 @@ export class Season implements IEntity<string> {
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
year: this.year,
order: this.order,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status.toString(),
startDate: this.startDate,
endDate: this.endDate,
schedule: this.schedule,
scoringConfig: this.scoringConfig,
dropPolicy: this.dropPolicy,
stewardingConfig: this.stewardingConfig,
maxDrivers: this.maxDrivers,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: newCount.toNumber(),
});
}
@@ -573,16 +600,16 @@ export class Season implements IEntity<string> {
leagueId: this.leagueId,
gameId: this.gameId,
name: this.name,
year: this.year,
order: this.order,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: this.status.toString(),
startDate: this.startDate,
endDate: this.endDate,
schedule: this.schedule,
scoringConfig: this.scoringConfig,
dropPolicy: this.dropPolicy,
stewardingConfig: this.stewardingConfig,
maxDrivers: this.maxDrivers,
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}),
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: newCount.toNumber(),
});
}

View File

@@ -5,7 +5,7 @@
* Defines async methods using domain entities as types.
*/
import type { Race, RaceStatus } from '../entities/Race';
import type { Race, RaceStatusValue } from '../entities/Race';
export interface IRaceRepository {
/**
@@ -36,7 +36,7 @@ export interface IRaceRepository {
/**
* Find races by status
*/
findByStatus(status: RaceStatus): Promise<Race[]>;
findByStatus(status: RaceStatusValue): Promise<Race[]>;
/**
* Find races scheduled within a date range

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { RaceName } from './RaceName';
describe('RaceName', () => {
it('creates a valid name and exposes stable value/toString', () => {
const name = RaceName.fromString('Valid Race Name');
expect(name.value).toBe('Valid Race Name');
expect(name.toString()).toBe('Valid Race Name');
});
it('trims leading/trailing whitespace', () => {
const name = RaceName.fromString(' Valid Race Name ');
expect(name.value).toBe('Valid Race Name');
});
it('rejects empty/blank values', () => {
expect(() => RaceName.fromString('')).toThrow('Race name cannot be empty');
expect(() => RaceName.fromString(' ')).toThrow('Race name cannot be empty');
});
it('rejects names shorter than 3 characters (after trim)', () => {
expect(() => RaceName.fromString('ab')).toThrow('Race name must be at least 3 characters long');
expect(() => RaceName.fromString(' ab ')).toThrow('Race name must be at least 3 characters long');
});
it('rejects names longer than 100 characters (after trim)', () => {
expect(() => RaceName.fromString('a'.repeat(101))).toThrow('Race name must not exceed 100 characters');
expect(() => RaceName.fromString(` ${'a'.repeat(101)} `)).toThrow('Race name must not exceed 100 characters');
});
it('equals compares by normalized value', () => {
const a = RaceName.fromString(' Test Race ');
const b = RaceName.fromString('Test Race');
const c = RaceName.fromString('Different');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { RaceStatus, type RaceStatusValue } from './RaceStatus';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
describe('RaceStatus', () => {
it('creates a status and exposes stable value/toString/props', () => {
const status = RaceStatus.create('scheduled');
expect(status.value).toBe('scheduled');
expect(status.toString()).toBe('scheduled');
expect(status.props).toEqual({ value: 'scheduled' });
});
it('rejects missing value', () => {
expect(() => RaceStatus.create('' as unknown as RaceStatusValue)).toThrow(
RacingDomainValidationError,
);
expect(() => RaceStatus.create('' as unknown as RaceStatusValue)).toThrow(
'Race status is required',
);
});
it('supports lifecycle guard helpers', () => {
expect(RaceStatus.create('scheduled').canStart()).toBe(true);
expect(RaceStatus.create('running').canStart()).toBe(false);
expect(RaceStatus.create('running').canComplete()).toBe(true);
expect(RaceStatus.create('scheduled').canComplete()).toBe(false);
expect(RaceStatus.create('scheduled').canCancel()).toBe(true);
expect(RaceStatus.create('running').canCancel()).toBe(true);
expect(RaceStatus.create('completed').canCancel()).toBe(false);
expect(RaceStatus.create('completed').canReopen()).toBe(true);
expect(RaceStatus.create('cancelled').canReopen()).toBe(true);
expect(RaceStatus.create('running').canReopen()).toBe(false);
expect(RaceStatus.create('completed').isTerminal()).toBe(true);
expect(RaceStatus.create('cancelled').isTerminal()).toBe(true);
expect(RaceStatus.create('running').isTerminal()).toBe(false);
expect(RaceStatus.create('running').isRunning()).toBe(true);
expect(RaceStatus.create('completed').isCompleted()).toBe(true);
expect(RaceStatus.create('scheduled').isScheduled()).toBe(true);
expect(RaceStatus.create('cancelled').isCancelled()).toBe(true);
});
it('validates allowed transitions', () => {
const scheduled = RaceStatus.create('scheduled');
const running = RaceStatus.create('running');
const completed = RaceStatus.create('completed');
const cancelled = RaceStatus.create('cancelled');
expect(scheduled.canTransitionTo('running')).toEqual({ valid: true });
expect(scheduled.canTransitionTo('cancelled')).toEqual({ valid: true });
expect(running.canTransitionTo('completed')).toEqual({ valid: true });
expect(running.canTransitionTo('cancelled')).toEqual({ valid: true });
expect(completed.canTransitionTo('scheduled')).toEqual({ valid: true });
expect(cancelled.canTransitionTo('scheduled')).toEqual({ valid: true });
});
it('rejects disallowed transitions with a helpful error', () => {
const scheduled = RaceStatus.create('scheduled');
const result = scheduled.canTransitionTo('completed');
expect(result.valid).toBe(false);
expect(result.error).toBe('Cannot transition from scheduled to completed');
});
it('equals compares by value', () => {
const a = RaceStatus.create('scheduled');
const b = RaceStatus.create('scheduled');
const c = RaceStatus.create('running');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
it('only allowed status values compile (type-level)', () => {
const allowed: RaceStatusValue[] = ['scheduled', 'running', 'completed', 'cancelled'];
expect(allowed).toHaveLength(4);
});
});

View File

@@ -100,8 +100,8 @@ export class RaceStatus implements IValueObject<RaceStatusProps> {
const allowedTransitions: Record<RaceStatusValue, RaceStatusValue[]> = {
scheduled: ['running', 'cancelled'],
running: ['completed', 'cancelled'],
completed: [],
cancelled: [],
completed: ['scheduled'],
cancelled: ['scheduled'],
};
if (!allowedTransitions[current].includes(target)) {

View File

@@ -24,8 +24,11 @@ export class StrengthOfField implements IValueObject<StrengthOfFieldProps> {
throw new RacingDomainValidationError('Strength of field must be an integer');
}
if (value < 0 || value > 100) {
throw new RacingDomainValidationError('Strength of field must be between 0 and 100');
// SOF represents iRating-like values (commonly ~0-10k), not a 0-100 percentage.
if (value < 0 || value > 10_000) {
throw new RacingDomainValidationError(
'Strength of field must be between 0 and 10000',
);
}
return new StrengthOfField(value);
@@ -35,9 +38,9 @@ export class StrengthOfField implements IValueObject<StrengthOfFieldProps> {
* Get the strength category
*/
getCategory(): 'beginner' | 'intermediate' | 'advanced' | 'expert' {
if (this.value < 25) return 'beginner';
if (this.value < 50) return 'intermediate';
if (this.value < 75) return 'advanced';
if (this.value < 1500) return 'beginner';
if (this.value < 2500) return 'intermediate';
if (this.value < 4000) return 'advanced';
return 'expert';
}
@@ -45,8 +48,8 @@ export class StrengthOfField implements IValueObject<StrengthOfFieldProps> {
* Check if this SOF is suitable for the given participant count
*/
isSuitableForParticipants(count: number): boolean {
// Higher SOF should generally have more participants
const minExpected = Math.floor(this.value / 10);
// Higher SOF should generally have more participants.
const minExpected = Math.floor(this.value / 500);
return count >= minExpected;
}

View File

@@ -3,30 +3,34 @@ import { TrackId } from './TrackId';
describe('TrackId', () => {
it('should create track id', () => {
const id = TrackId.create('track-123');
expect(id.toString()).toBe('track-123');
expect(id.props).toBe('track-123');
const uuid = '550e8400-e29b-41d4-a716-446655440000';
const id = TrackId.create(uuid);
expect(id.toString()).toBe(uuid);
expect(id.value).toBe(uuid);
});
it('should trim whitespace', () => {
const id = TrackId.create(' track-123 ');
it('should allow fromString without validation', () => {
const id = TrackId.fromString('track-123');
expect(id.toString()).toBe('track-123');
expect(id.value).toBe('track-123');
});
it('should throw for empty id', () => {
expect(() => TrackId.create('')).toThrow('Track ID cannot be empty');
expect(() => TrackId.create(' ')).toThrow('Track ID cannot be empty');
it('should throw for invalid uuid', () => {
expect(() => TrackId.create('')).toThrow('TrackId must be a valid UUID');
expect(() => TrackId.create(' ')).toThrow('TrackId must be a valid UUID');
expect(() => TrackId.create('track-123')).toThrow('TrackId must be a valid UUID');
});
it('should equal same ids', () => {
const i1 = TrackId.create('track-123');
const i2 = TrackId.create('track-123');
const uuid = '550e8400-e29b-41d4-a716-446655440000';
const i1 = TrackId.create(uuid);
const i2 = TrackId.create(uuid);
expect(i1.equals(i2)).toBe(true);
});
it('should not equal different ids', () => {
const i1 = TrackId.create('track-123');
const i2 = TrackId.create('track-456');
const i1 = TrackId.create('550e8400-e29b-41d4-a716-446655440000');
const i2 = TrackId.create('550e8400-e29b-41d4-a716-446655440001');
expect(i1.equals(i2)).toBe(false);
});
});

View File

@@ -5,7 +5,7 @@ describe('TrackName', () => {
it('should create track name', () => {
const name = TrackName.create('Silverstone');
expect(name.toString()).toBe('Silverstone');
expect(name.props).toBe('Silverstone');
expect(name.value).toBe('Silverstone');
});
it('should trim whitespace', () => {
@@ -14,8 +14,8 @@ describe('TrackName', () => {
});
it('should throw for empty name', () => {
expect(() => TrackName.create('')).toThrow('Track name is required');
expect(() => TrackName.create(' ')).toThrow('Track name is required');
expect(() => TrackName.create('')).toThrow('Track name cannot be empty');
expect(() => TrackName.create(' ')).toThrow('Track name cannot be empty');
});
it('should equal same names', () => {

View File

@@ -1,7 +1,7 @@
import { Season } from '@core/racing/domain/entities/season/Season';
import type { SeasonStatus } from '@core/racing/domain/entities/season/Season';
import type { SeasonStatusValue } from '@core/racing/domain/value-objects/SeasonStatus';
export const createMinimalSeason = (overrides?: { status?: SeasonStatus }) =>
export const createMinimalSeason = (overrides?: { status?: SeasonStatusValue }) =>
Season.create({
id: 'season-1',
leagueId: 'league-1',