wip league admin tools
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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' };
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
38
core/racing/domain/value-objects/RaceName.test.ts
Normal file
38
core/racing/domain/value-objects/RaceName.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
core/racing/domain/value-objects/RaceStatus.test.ts
Normal file
82
core/racing/domain/value-objects/RaceStatus.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user