This commit is contained in:
2025-12-16 15:42:38 +01:00
parent 29410708c8
commit 362894d1a5
147 changed files with 780 additions and 375 deletions

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { AcceptSponsorshipRequestUseCase } from './AcceptSponsorshipRequestUseCase';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { INotificationService } from '@core/notifications/application/ports/INotificationService';
import type { IPaymentGateway } from '../ports/IPaymentGateway';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { Logger } from '@core/shared/application';
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
import { Season } from '../../domain/entities/Season';
import { LeagueWallet } from '../../domain/entities/LeagueWallet';
import { Money } from '../../domain/value-objects/Money';
describe('AcceptSponsorshipRequestUseCase', () => {
let mockSponsorshipRequestRepo: {
findById: Mock;
update: Mock;
};
let mockSeasonSponsorshipRepo: {
create: Mock;
};
let mockSeasonRepo: {
findById: Mock;
};
let mockNotificationService: {
sendNotification: Mock;
};
let mockPaymentGateway: {
processPayment: Mock;
};
let mockWalletRepo: {
findById: Mock;
update: Mock;
};
let mockLeagueWalletRepo: {
findById: Mock;
update: Mock;
};
let mockLogger: {
debug: Mock;
info: Mock;
warn: Mock;
error: Mock;
};
beforeEach(() => {
mockSponsorshipRequestRepo = {
findById: vi.fn(),
update: vi.fn(),
};
mockSeasonSponsorshipRepo = {
create: vi.fn(),
};
mockSeasonRepo = {
findById: vi.fn(),
};
mockNotificationService = {
sendNotification: vi.fn(),
};
mockPaymentGateway = {
processPayment: vi.fn(),
};
mockWalletRepo = {
findById: vi.fn(),
update: vi.fn(),
};
mockLeagueWalletRepo = {
findById: vi.fn(),
update: vi.fn(),
};
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
});
it('should send notification to sponsor, process payment, and update wallets when accepting season sponsorship', async () => {
const useCase = new AcceptSponsorshipRequestUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
mockSeasonRepo as unknown as ISeasonRepository,
mockNotificationService as unknown as INotificationService,
mockPaymentGateway as unknown as IPaymentGateway,
mockWalletRepo as unknown as IWalletRepository,
mockLeagueWalletRepo as unknown as ILeagueWalletRepository,
mockLogger as unknown as Logger,
);
const request = SponsorshipRequest.create({
id: 'req1',
sponsorId: 'sponsor1',
entityId: 'season1',
entityType: 'season',
tier: 'main',
offeredAmount: Money.create(1000),
status: 'pending',
});
const season = Season.create({
id: 'season1',
leagueId: 'league1',
gameId: 'game1',
name: 'Season 1',
startDate: new Date(),
endDate: new Date(),
});
mockSponsorshipRequestRepo.findById.mockResolvedValue(request);
mockSeasonRepo.findById.mockResolvedValue(season);
mockNotificationService.sendNotification.mockResolvedValue(undefined);
mockPaymentGateway.processPayment.mockResolvedValue({
success: true,
transactionId: 'txn1',
timestamp: new Date(),
});
mockWalletRepo.findById.mockResolvedValue({
id: 'sponsor1',
leagueId: 'league1',
balance: 2000,
totalRevenue: 0,
totalPlatformFees: 0,
totalWithdrawn: 0,
currency: 'USD',
createdAt: new Date(),
});
const leagueWallet = LeagueWallet.create({
id: 'league1',
leagueId: 'league1',
balance: Money.create(500),
});
mockLeagueWalletRepo.findById.mockResolvedValue(leagueWallet);
const result = await useCase.execute({
requestId: 'req1',
respondedBy: 'driver1',
});
expect(result).toBeDefined();
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({
recipientId: 'sponsor1',
type: 'sponsorship_request_accepted',
title: 'Sponsorship Accepted',
body: 'Your sponsorship request for Season 1 has been accepted.',
channel: 'in_app',
urgency: 'toast',
data: {
requestId: 'req1',
sponsorshipId: expect.any(String),
},
});
expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith(
Money.create(1000),
'sponsor1',
'Sponsorship payment for season season1',
{ requestId: 'req1' }
);
expect(mockWalletRepo.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'sponsor1',
balance: 1000,
})
);
expect(mockLeagueWalletRepo.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'league1',
balance: expect.objectContaining({ amount: 1400 }),
})
);
});
});

View File

@@ -9,6 +9,10 @@ import type { Logger } from '@core/shared/application';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { INotificationService } from '@core/notifications/application/ports/INotificationService';
import type { IPaymentGateway } from '../ports/IPaymentGateway';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@core/shared/application';
@@ -32,6 +36,10 @@ export class AcceptSponsorshipRequestUseCase
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly notificationService: INotificationService,
private readonly paymentGateway: IPaymentGateway,
private readonly walletRepository: IWalletRepository,
private readonly leagueWalletRepository: ILeagueWalletRepository,
private readonly logger: Logger,
) {}
@@ -79,12 +87,59 @@ export class AcceptSponsorshipRequestUseCase
});
await this.seasonSponsorshipRepo.create(sponsorship);
this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId });
}
// TODO: In a real implementation, we would:
// 1. Create notification for the sponsor
// 2. Process payment
// 3. Update wallet balances
// Notify the sponsor
await this.notificationService.sendNotification({
recipientId: request.sponsorId,
type: 'sponsorship_request_accepted',
title: 'Sponsorship Accepted',
body: `Your sponsorship request for ${season.name} has been accepted.`,
channel: 'in_app',
urgency: 'toast',
data: {
requestId: request.id,
sponsorshipId,
},
});
// Process payment
const paymentResult = await this.paymentGateway.processPayment(
request.offeredAmount,
request.sponsorId,
`Sponsorship payment for ${request.entityType} ${request.entityId}`,
{ requestId: request.id }
);
if (!paymentResult.success) {
this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id });
throw new Error('Payment processing failed');
}
// Update wallets
const sponsorWallet = await this.walletRepository.findById(request.sponsorId);
if (!sponsorWallet) {
this.logger.error(`Sponsor wallet not found for ${request.sponsorId}`, undefined, { sponsorId: request.sponsorId });
throw new Error('Sponsor wallet not found');
}
const leagueWallet = await this.leagueWalletRepository.findById(season.leagueId);
if (!leagueWallet) {
this.logger.error(`League wallet not found for ${season.leagueId}`, undefined, { leagueId: season.leagueId });
throw new Error('League wallet not found');
}
const netAmount = acceptedRequest.getNetAmount();
// Deduct from sponsor wallet
const updatedSponsorWallet = {
...sponsorWallet,
balance: sponsorWallet.balance - request.offeredAmount.amount,
};
await this.walletRepository.update(updatedSponsorWallet);
// Add to league wallet
const updatedLeagueWallet = leagueWallet.addFunds(netAmount, paymentResult.transactionId!);
await this.leagueWalletRepository.update(updatedLeagueWallet);
}
this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId });
@@ -97,8 +152,9 @@ export class AcceptSponsorshipRequestUseCase
netAmount: acceptedRequest.getNetAmount().amount,
};
} catch (error) {
this.logger.error(`Failed to accept sponsorship request ${dto.requestId}: ${error.message}`, { requestId: dto.requestId, error: error.message, stack: error.stack });
throw error;
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error(`Failed to accept sponsorship request ${dto.requestId}: ${err.message}`, err, { requestId: dto.requestId });
throw err;
}
}
}

View File

@@ -50,7 +50,7 @@ export class ApplyForSponsorshipUseCase
// Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
if (!sponsor) {
this.logger.error('Sponsor not found', { sponsorId: dto.sponsorId });
this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId });
throw new EntityNotFoundError({ entity: 'sponsor', id: dto.sponsorId });
}

View File

@@ -97,7 +97,7 @@ export class ApplyPenaltyUseCase
return { penaltyId: penalty.id };
} catch (error) {
this.logger.error('ApplyPenaltyUseCase: Failed to apply penalty', { command, error: error.message });
this.logger.error('ApplyPenaltyUseCase: Failed to apply penalty', error, { command });
throw error;
}
}

View File

@@ -0,0 +1,45 @@
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { ApproveLeagueJoinRequestPresenter } from '@apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter';
describe('ApproveLeagueJoinRequestUseCase', () => {
let useCase: ApproveLeagueJoinRequestUseCase;
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
let presenter: ApproveLeagueJoinRequestPresenter;
beforeEach(() => {
leagueMembershipRepository = {
getJoinRequests: jest.fn(),
removeJoinRequest: jest.fn(),
saveMembership: jest.fn(),
} as unknown;
presenter = new ApproveLeagueJoinRequestPresenter();
useCase = new ApproveLeagueJoinRequestUseCase(leagueMembershipRepository);
});
it('should approve join request and save membership', async () => {
const leagueId = 'league-1';
const requestId = 'req-1';
const joinRequests = [{ id: requestId, leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }];
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
await useCase.execute({ leagueId, requestId }, presenter);
expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({
leagueId,
driverId: 'driver-1',
role: 'member',
status: 'active',
joinedAt: expect.any(Date),
});
expect(presenter.viewModel).toEqual({ success: true, message: 'Join request approved.' });
});
it('should throw error if request not found', async () => {
leagueMembershipRepository.getJoinRequests.mockResolvedValue([]);
await expect(useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }, presenter)).rejects.toThrow('Join request not found');
});
});

View File

@@ -54,7 +54,7 @@ export class ApproveTeamJoinRequestUseCase
await this.membershipRepository.removeJoinRequest(requestId);
this.logger.info(`Team join request with ID ${requestId} removed`);
} catch (error) {
this.logger.error(`Failed to approve team join request ${requestId}:`, error);
this.logger.error(`Failed to approve team join request ${requestId}`, error instanceof Error ? error : new Error(String(error)));
throw error;
}
}

View File

@@ -37,7 +37,7 @@ export class CancelRaceUseCase
await this.raceRepository.update(cancelledRace);
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
} catch (error) {
this.logger.error(`[CancelRaceUseCase] Error cancelling race ${raceId}:`, error);
this.logger.error(`[CancelRaceUseCase] Error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error)));
throw error;
}
}

View File

@@ -1,4 +1,5 @@
import type { UseCase } from '@core/shared/application/UseCase';
import type { Logger } from '@core/shared/application';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IDomainEventPublisher } from '@core/shared/domain';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
@@ -20,6 +21,8 @@ export class CloseRaceEventStewardingUseCase
implements UseCase<CloseRaceEventStewardingCommand, void, void, void>
{
constructor(
private readonly logger: Logger,
private readonly raceEventRepository: IRaceEventRepository,
private readonly domainEventPublisher: IDomainEventPublisher,
) {}
@@ -58,7 +61,7 @@ export class CloseRaceEventStewardingUseCase
await this.domainEventPublisher.publish(event);
} catch (error) {
console.error(`Failed to close stewarding for race event ${raceEvent.id}:`, error);
this.logger.error(`Failed to close stewarding for race event ${raceEvent.id}`, error instanceof Error ? error : new Error(String(error)));
// In production, this would trigger alerts/monitoring
}
}

View File

@@ -81,8 +81,8 @@ export class CompleteRaceUseCaseWithRatings
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
this.logger.info(`Race ID: ${raceId} completed successfully.`);
} catch (error: any) {
this.logger.error(`Error completing race ${raceId}: ${error.message}`);
} catch (error) {
this.logger.error(`Error completing race ${raceId}`, error instanceof Error ? error : new Error(String(error)));
throw error;
}
}

View File

@@ -4,7 +4,6 @@ import { Season } from '../../domain/entities/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import type {
@@ -129,11 +128,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
return result;
} catch (error) {
this.logger.error('Error during CreateLeagueWithSeasonAndScoringUseCase execution.', {
command,
error: error.message,
stack: error.stack,
});
this.logger.error('Error during CreateLeagueWithSeasonAndScoringUseCase execution.', error, { command });
throw error;
}
}

View File

@@ -0,0 +1,653 @@
import { describe, it, expect } from 'vitest';
import { GetDashboardOverviewUseCase } from '@core/racing/application/use-cases/GetDashboardOverviewUseCase';
import { Driver } from '@core/racing/domain/entities/Driver';
import { Race } from '@core/racing/domain/entities/Race';
import { Result } from '@core/racing/domain/entities/Result';
import { League } from '@core/racing/domain/entities/League';
import { Standing } from '@core/racing/domain/entities/Standing';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
DashboardFeedItemSummaryViewModel,
} from '@core/racing/application/presenters/IDashboardOverviewPresenter';
class FakeDashboardOverviewPresenter implements IDashboardOverviewPresenter {
viewModel: DashboardOverviewViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(viewModel: DashboardOverviewViewModel): void {
this.viewModel = viewModel;
}
getViewModel(): DashboardOverviewViewModel | null {
return this.viewModel;
}
}
interface TestImageService {
getDriverAvatar(driverId: string): string;
getTeamLogo(teamId: string): string;
getLeagueCover(leagueId: string): string;
getLeagueLogo(leagueId: string): string;
}
function createTestImageService(): TestImageService {
return {
getDriverAvatar: (driverId: string) => `avatar-${driverId}`,
getTeamLogo: (teamId: string) => `team-logo-${teamId}`,
getLeagueCover: (leagueId: string) => `league-cover-${leagueId}`,
getLeagueLogo: (leagueId: string) => `league-logo-${leagueId}`,
};
}
describe('GetDashboardOverviewUseCase', () => {
it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => {
// Given a driver with memberships in two leagues and future races with mixed registration
const driverId = 'driver-1';
const driver = Driver.create({ id: driverId, iracingId: '12345', name: 'Alice Racer', country: 'US' });
const leagues = [
League.create({ id: 'league-1', name: 'Alpha League', description: 'First league', ownerId: 'owner-1' }),
League.create({ id: 'league-2', name: 'Beta League', description: 'Second league', ownerId: 'owner-2' }),
];
const now = Date.now();
const races = [
Race.create({
id: 'race-1',
leagueId: 'league-1',
track: 'Monza',
car: 'GT3',
scheduledAt: new Date(now + 60 * 60 * 1000),
status: 'scheduled',
}),
Race.create({
id: 'race-2',
leagueId: 'league-1',
track: 'Spa',
car: 'GT3',
scheduledAt: new Date(now + 2 * 60 * 60 * 1000),
status: 'scheduled',
}),
Race.create({
id: 'race-3',
leagueId: 'league-2',
track: 'Silverstone',
car: 'GT4',
scheduledAt: new Date(now + 3 * 60 * 60 * 1000),
status: 'scheduled',
}),
Race.create({
id: 'race-4',
leagueId: 'league-2',
track: 'Imola',
car: 'GT4',
scheduledAt: new Date(now + 4 * 60 * 60 * 1000),
status: 'scheduled',
}),
];
const results: Result[] = [];
const memberships = [
LeagueMembership.create({
leagueId: 'league-1',
driverId,
role: 'member',
status: 'active',
}),
LeagueMembership.create({
leagueId: 'league-2',
driverId,
role: 'member',
status: 'active',
}),
];
const registeredRaceIds = new Set<string>(['race-1', 'race-3']);
const feedItems: FeedItem[] = [];
const friends: Driver[] = [];
const driverRepository = {
findById: async (id: string): Promise<Driver | null> => (id === driver.id ? driver : null),
findByIRacingId: async (): Promise<Driver | null> => null,
findAll: async (): Promise<Driver[]> => [],
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const raceRepository = {
findById: async (): Promise<Race | null> => null,
findAll: async (): Promise<Race[]> => races,
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => results,
findByRaceId: async (): Promise<Result[]> => [],
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (): Promise<League | null> => null,
findAll: async (): Promise<League[]> => leagues,
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const standingRepository = {
findByLeagueId: async (): Promise<Standing[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
findAll: async (): Promise<Standing[]> => [],
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
recalculate: async (): Promise<Standing[]> => [],
};
const leagueMembershipRepository = {
getMembership: async (leagueId: string, driverIdParam: string): Promise<LeagueMembership | null> => {
return (
memberships.find(
(m) => m.leagueId === leagueId && m.driverId === driverIdParam,
) ?? null
);
},
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
getJoinRequests: async (): Promise<any[]> => [],
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
};
const raceRegistrationRepository = {
isRegistered: async (raceId: string, driverIdParam: string): Promise<boolean> => {
if (driverIdParam !== driverId) return false;
return registeredRaceIds.has(raceId);
},
getRegisteredDrivers: async (): Promise<string[]> => [],
getRegistrationCount: async (): Promise<number> => 0,
register: async (): Promise<void> => { throw new Error('Not implemented'); },
withdraw: async (): Promise<void> => { throw new Error('Not implemented'); },
getDriverRegistrations: async (): Promise<string[]> => [],
clearRaceRegistrations: async (): Promise<void> => { throw new Error('Not implemented'); },
};
const feedRepository = {
getFeedForDriver: async (): Promise<FeedItem[]> => feedItems,
getGlobalFeed: async (): Promise<FeedItem[]> => [],
};
const socialRepository = {
getFriends: async (): Promise<Driver[]> => friends,
getFriendIds: async (): Promise<string[]> => [],
getSuggestedFriends: async (): Promise<Driver[]> => [],
};
const imageService = createTestImageService();
const getDriverStats = (id: string) =>
id === driverId
? {
rating: 1600,
wins: 5,
podiums: 12,
totalRaces: 40,
overallRank: 42,
consistency: 88,
}
: null;
const presenter = new FakeDashboardOverviewPresenter();
const useCase = new GetDashboardOverviewUseCase(
driverRepository,
raceRepository,
resultRepository,
leagueRepository,
standingRepository,
leagueMembershipRepository,
raceRegistrationRepository,
feedRepository,
socialRepository,
imageService,
getDriverStats,
);
// When
await useCase.execute({ driverId }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
const vm = viewModel!;
// Then myUpcomingRaces only contains registered races from the driver's leagues
expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']);
// And otherUpcomingRaces contains the other upcoming races in those leagues
expect(vm.otherUpcomingRaces.map((r) => r.id)).toEqual(['race-2', 'race-4']);
// And nextRace is the earliest upcoming race from myUpcomingRaces
expect(vm.nextRace).not.toBeNull();
expect(vm.nextRace!.id).toBe('race-1');
});
it('builds recentResults sorted by date descending and leagueStandingsSummaries from standings', async () => {
// Given completed races with results and standings
const driverId = 'driver-2';
const driver = Driver.create({ id: driverId, iracingId: '67890', name: 'Result Driver', country: 'DE' });
const leagues = [
League.create({ id: 'league-A', name: 'Results League A', description: 'League A', ownerId: 'owner-A' }),
League.create({ id: 'league-B', name: 'Results League B', description: 'League B', ownerId: 'owner-B' }),
];
const raceOld = Race.create({
id: 'race-old',
leagueId: 'league-A',
track: 'Old Circuit',
car: 'GT3',
scheduledAt: new Date('2024-01-01T10:00:00Z'),
status: 'completed',
});
const raceNew = Race.create({
id: 'race-new',
leagueId: 'league-B',
track: 'New Circuit',
car: 'GT4',
scheduledAt: new Date('2024-02-01T10:00:00Z'),
status: 'completed',
});
const races = [raceOld, raceNew];
const results = [
Result.create({
id: 'result-old',
raceId: raceOld.id,
driverId,
position: 5,
fastestLap: 120,
incidents: 3,
startPosition: 5,
}),
Result.create({
id: 'result-new',
raceId: raceNew.id,
driverId,
position: 2,
fastestLap: 115,
incidents: 1,
startPosition: 2,
}),
];
const memberships = [
LeagueMembership.create({
leagueId: 'league-A',
driverId,
role: 'member',
status: 'active',
}),
LeagueMembership.create({
leagueId: 'league-B',
driverId,
role: 'member',
status: 'active',
}),
];
const standingsByLeague = new Map<
string,
Standing[]
>();
standingsByLeague.set('league-A', [
Standing.create({ leagueId: 'league-A', driverId, position: 3, points: 50 }),
Standing.create({ leagueId: 'league-A', driverId: 'other-1', position: 1, points: 80 }),
]);
standingsByLeague.set('league-B', [
Standing.create({ leagueId: 'league-B', driverId, position: 1, points: 100 }),
Standing.create({ leagueId: 'league-B', driverId: 'other-2', position: 2, points: 90 }),
]);
const driverRepository = {
findById: async (id: string): Promise<Driver | null> => (id === driver.id ? driver : null),
findByIRacingId: async (): Promise<Driver | null> => null,
findAll: async (): Promise<Driver[]> => [],
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const raceRepository = {
findById: async (): Promise<Race | null> => null,
findAll: async (): Promise<Race[]> => races,
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => results,
findByRaceId: async (): Promise<Result[]> => [],
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (): Promise<League | null> => null,
findAll: async (): Promise<League[]> => leagues,
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const standingRepository = {
findByLeagueId: async (leagueId: string): Promise<Standing[]> =>
standingsByLeague.get(leagueId) ?? [],
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
findAll: async (): Promise<Standing[]> => [],
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
recalculate: async (): Promise<Standing[]> => [],
};
const leagueMembershipRepository = {
getMembership: async (leagueId: string, driverIdParam: string): Promise<LeagueMembership | null> => {
return (
memberships.find(
(m) => m.leagueId === leagueId && m.driverId === driverIdParam,
) ?? null
);
},
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
getJoinRequests: async (): Promise<any[]> => [],
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
};
const raceRegistrationRepository = {
isRegistered: async (): Promise<boolean> => false,
getRegisteredDrivers: async (): Promise<string[]> => [],
getRegistrationCount: async (): Promise<number> => 0,
register: async (): Promise<void> => { throw new Error('Not implemented'); },
withdraw: async (): Promise<void> => { throw new Error('Not implemented'); },
getDriverRegistrations: async (): Promise<string[]> => [],
clearRaceRegistrations: async (): Promise<void> => { throw new Error('Not implemented'); },
};
const feedRepository = {
getFeedForDriver: async (): Promise<FeedItem[]> => [],
getGlobalFeed: async (): Promise<FeedItem[]> => [],
};
const socialRepository = {
getFriends: async (): Promise<Driver[]> => [],
getFriendIds: async (): Promise<string[]> => [],
getSuggestedFriends: async (): Promise<Driver[]> => [],
};
const imageService = createTestImageService();
const getDriverStats = (id: string) =>
id === driverId
? {
rating: 1800,
wins: 3,
podiums: 7,
totalRaces: 20,
overallRank: 10,
consistency: 92,
}
: null;
const presenter = new FakeDashboardOverviewPresenter();
const useCase = new GetDashboardOverviewUseCase(
driverRepository,
raceRepository,
resultRepository,
leagueRepository,
standingRepository,
leagueMembershipRepository,
raceRegistrationRepository,
feedRepository,
socialRepository,
imageService,
getDriverStats,
);
// When
await useCase.execute({ driverId }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
const vm = viewModel!;
// Then recentResults are sorted by finishedAt descending (newest first)
expect(vm.recentResults.length).toBe(2);
expect(vm.recentResults[0]!.raceId).toBe('race-new');
expect(vm.recentResults[1]!.raceId).toBe('race-old');
// And leagueStandingsSummaries reflect the driver's position and points per league
const summariesByLeague = new Map(
vm.leagueStandingsSummaries.map((s) => [s.leagueId, s]),
);
const summaryA = summariesByLeague.get('league-A');
const summaryB = summariesByLeague.get('league-B');
expect(summaryA).toBeDefined();
expect(summaryA!.position).toBe(3);
expect(summaryA!.points).toBe(50);
expect(summaryA!.totalDrivers).toBe(2);
expect(summaryB).toBeDefined();
expect(summaryB!.position).toBe(1);
expect(summaryB!.points).toBe(100);
expect(summaryB!.totalDrivers).toBe(2);
});
it('returns empty collections and safe defaults when driver has no races or standings', async () => {
// Given a driver with no related data
const driverId = 'driver-empty';
const driver = Driver.create({ id: driverId, iracingId: '11111', name: 'New Racer', country: 'FR' });
const driverRepository = {
findById: async (id: string): Promise<Driver | null> => (id === driver.id ? driver : null),
findByIRacingId: async (): Promise<Driver | null> => null,
findAll: async (): Promise<Driver[]> => [],
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const raceRepository = {
findById: async (): Promise<Race | null> => null,
findAll: async (): Promise<Race[]> => [],
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => [],
findByRaceId: async (): Promise<Result[]> => [],
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (): Promise<League | null> => null,
findAll: async (): Promise<League[]> => [],
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const standingRepository = {
findByLeagueId: async (): Promise<Standing[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
findAll: async (): Promise<Standing[]> => [],
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
recalculate: async (): Promise<Standing[]> => [],
};
const leagueMembershipRepository = {
getMembership: async (): Promise<LeagueMembership | null> => null,
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
getJoinRequests: async (): Promise<any[]> => [],
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
};
const raceRegistrationRepository = {
isRegistered: async (): Promise<boolean> => false,
getRegisteredDrivers: async (): Promise<string[]> => [],
getRegistrationCount: async (): Promise<number> => 0,
register: async (): Promise<void> => { throw new Error('Not implemented'); },
withdraw: async (): Promise<void> => { throw new Error('Not implemented'); },
getDriverRegistrations: async (): Promise<string[]> => [],
clearRaceRegistrations: async (): Promise<void> => { throw new Error('Not implemented'); },
};
const feedRepository = {
getFeedForDriver: async (): Promise<FeedItem[]> => [],
getGlobalFeed: async (): Promise<FeedItem[]> => [],
};
const socialRepository = {
getFriends: async (): Promise<Driver[]> => [],
getFriendIds: async (): Promise<string[]> => [],
getSuggestedFriends: async (): Promise<Driver[]> => [],
};
const imageService = createTestImageService();
const getDriverStats = () => null;
const presenter = new FakeDashboardOverviewPresenter();
const useCase = new GetDashboardOverviewUseCase(
driverRepository,
raceRepository,
resultRepository,
leagueRepository,
standingRepository,
leagueMembershipRepository,
raceRegistrationRepository,
feedRepository,
socialRepository,
imageService,
getDriverStats,
);
// When
await useCase.execute({ driverId }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
const vm = viewModel!;
// Then collections are empty and no errors are thrown
expect(vm.myUpcomingRaces).toEqual([]);
expect(vm.otherUpcomingRaces).toEqual([]);
expect(vm.nextRace).toBeNull();
expect(vm.recentResults).toEqual([]);
expect(vm.leagueStandingsSummaries).toEqual([]);
expect(vm.friends).toEqual([]);
expect(vm.feedSummary.notificationCount).toBe(0);
expect(vm.feedSummary.items).toEqual([]);
});
});

View File

@@ -5,7 +5,6 @@ import type {
AllTeamsResultDTO,
} from '../presenters/IAllTeamsPresenter';
import type { UseCase } from '@core/shared/application';
import type { Team } from '../../domain/entities/Team';
import { Logger } from "@core/shared/application";
/**
@@ -54,7 +53,7 @@ export class GetAllTeamsUseCase
presenter.present(dto);
this.logger.info('Successfully retrieved all teams.');
} catch (error) {
this.logger.error('Error retrieving all teams:', error);
this.logger.error('Error retrieving all teams', error instanceof Error ? error : new Error(String(error)));
throw error; // Re-throw the error after logging
}
}

View File

@@ -1,6 +1,6 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsResultDTO, GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter';
import type { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
export interface GetLeagueAdminPermissionsUseCaseParams {

View File

@@ -1,5 +1,6 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IGetLeagueAdminPresenter, GetLeagueAdminResultDTO, GetLeagueAdminViewModel } from '../presenters/IGetLeagueAdminPresenter';
import { GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter';
import type { IGetLeagueAdminPresenter } from '../presenters/IGetLeagueAdminPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
export interface GetLeagueAdminUseCaseParams {
@@ -14,7 +15,7 @@ export interface GetLeagueAdminResultDTO {
// Additional data would be populated by combining multiple use cases
}
export class GetLeagueAdminUseCase implements UseCase<GetLeagueAdminUseCaseParams, GetLeagueAdminResultDTO, GetLeagueAdminViewModel, IGetLeagueAdminPresenter> {
export class GetLeagueAdminUseCase implements UseCase<GetLeagueAdminUseCaseParams, GetLeagueAdminResultDTO, GetLeagueAdminPermissionsViewModel, IGetLeagueAdminPresenter> {
constructor(
private readonly leagueRepository: ILeagueRepository,
) {}

View File

@@ -0,0 +1,46 @@
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
import { LeagueJoinRequestsPresenter } from '@apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
describe('GetLeagueJoinRequestsUseCase', () => {
let useCase: GetLeagueJoinRequestsUseCase;
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
let driverRepository: jest.Mocked<IDriverRepository>;
let presenter: LeagueJoinRequestsPresenter;
beforeEach(() => {
leagueMembershipRepository = {
getJoinRequests: jest.fn(),
} as unknown;
driverRepository = {
findByIds: jest.fn(),
} as unknown;
presenter = new LeagueJoinRequestsPresenter();
useCase = new GetLeagueJoinRequestsUseCase(leagueMembershipRepository, driverRepository);
});
it('should return join requests with drivers', async () => {
const leagueId = 'league-1';
const joinRequests = [
{ id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' },
];
const drivers = [{ id: 'driver-1', name: 'Driver 1' }];
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
driverRepository.findByIds.mockResolvedValue(drivers);
await useCase.execute({ leagueId }, presenter);
expect(presenter.viewModel.joinRequests).toEqual([
{
id: 'req-1',
leagueId,
driverId: 'driver-1',
requestedAt: expect.any(Date),
message: 'msg',
driver: { id: 'driver-1', name: 'Driver 1' },
},
]);
});
});

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
private memberships: LeagueMembership[] = [];
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
return (
this.memberships.find(
(m) => m.leagueId === leagueId && m.driverId === driverId,
) || null
);
}
async getActiveMembershipForDriver(driverId: string): Promise<LeagueMembership | null> {
return (
this.memberships.find(
(m) => m.driverId === driverId && m.status === 'active',
) || null
);
}
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
return this.memberships.filter(
(m) => m.leagueId === leagueId && m.status === 'active',
);
}
async getTeamMembers(leagueId: string): Promise<LeagueMembership[]> {
return this.memberships.filter(
(m) => m.leagueId === leagueId && m.status === 'active',
);
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
const existingIndex = this.memberships.findIndex(
(m) => m.leagueId === membership.leagueId && m.driverId === membership.driverId,
);
if (existingIndex >= 0) {
this.memberships[existingIndex] = membership;
} else {
this.memberships.push(membership);
}
return membership;
}
async removeMembership(leagueId: string, driverId: string): Promise<void> {
this.memberships = this.memberships.filter(
(m) => !(m.leagueId === leagueId && m.driverId === driverId),
);
}
async getJoinRequests(): Promise<never> {
throw new Error('Not implemented for this test');
}
async saveJoinRequest(): Promise<never> {
throw new Error('Not implemented for this test');
}
async removeJoinRequest(): Promise<never> {
throw new Error('Not implemented for this test');
}
seedMembership(membership: LeagueMembership): void {
this.memberships.push(membership);
}
getAllMemberships(): LeagueMembership[] {
return [...this.memberships];
}
}
describe('Membership use-cases', () => {
describe('JoinLeagueUseCase', () => {
let repository: InMemoryLeagueMembershipRepository;
let useCase: JoinLeagueUseCase;
beforeEach(() => {
repository = new InMemoryLeagueMembershipRepository();
useCase = new JoinLeagueUseCase(repository);
});
it('creates an active member when driver has no membership', async () => {
const leagueId = 'league-1';
const driverId = 'driver-1';
await useCase.execute({ leagueId, driverId });
const membership = await repository.getMembership(leagueId, driverId);
expect(membership).not.toBeNull();
expect(membership?.leagueId).toBe(leagueId);
expect(membership?.driverId).toBe(driverId);
expect(membership?.role as MembershipRole).toBe('member');
expect(membership?.status as MembershipStatus).toBe('active');
expect(membership?.joinedAt).toBeInstanceOf(Date);
});
it('throws when driver already has membership for league', async () => {
const leagueId = 'league-1';
const driverId = 'driver-1';
repository.seedMembership(LeagueMembership.create({
leagueId,
driverId,
role: 'member',
status: 'active',
joinedAt: new Date('2024-01-01'),
}));
await expect(
useCase.execute({ leagueId, driverId }),
).rejects.toThrow('Already a member or have a pending request');
});
});
});

View File

@@ -0,0 +1,636 @@
import { describe, it, expect } from 'vitest';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type {
IRaceDetailPresenter,
RaceDetailViewModel,
} from '@core/racing/application/presenters/IRaceDetailPresenter';
import { Race } from '@core/racing/domain/entities/Race';
import { League } from '@core/racing/domain/entities/League';
import { Result } from '@core/racing/domain/entities/Result';
import { Driver } from '@core/racing/domain/entities/Driver';
import { GetRaceDetailUseCase } from '@core/racing/application/use-cases/GetRaceDetailUseCase';
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
class InMemoryRaceRepository implements IRaceRepository {
private races = new Map<string, Race>();
constructor(races: Race[]) {
for (const race of races) {
this.races.set(race.id, race);
}
}
async findById(id: string): Promise<Race | null> {
return this.races.get(id) ?? null;
}
async findAll(): Promise<Race[]> {
return [...this.races.values()];
}
async findByLeagueId(): Promise<Race[]> {
return [];
}
async findUpcomingByLeagueId(): Promise<Race[]> {
return [];
}
async findCompletedByLeagueId(): Promise<Race[]> {
return [];
}
async findByStatus(): Promise<Race[]> {
return [];
}
async findByDateRange(): Promise<Race[]> {
return [];
}
async create(race: Race): Promise<Race> {
this.races.set(race.id, race);
return race;
}
async update(race: Race): Promise<Race> {
this.races.set(race.id, race);
return race;
}
async delete(id: string): Promise<void> {
this.races.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.races.has(id);
}
getStored(id: string): Race | null {
return this.races.get(id) ?? null;
}
}
class InMemoryLeagueRepository implements ILeagueRepository {
private leagues = new Map<string, League>();
constructor(leagues: League[]) {
for (const league of leagues) {
this.leagues.set(league.id, league);
}
}
async findById(id: string): Promise<League | null> {
return this.leagues.get(id) ?? null;
}
async findAll(): Promise<League[]> {
return [...this.leagues.values()];
}
async findByOwnerId(): Promise<League[]> {
return [];
}
async create(league: League): Promise<League> {
this.leagues.set(league.id, league);
return league;
}
async update(league: League): Promise<League> {
this.leagues.set(league.id, league);
return league;
}
async delete(id: string): Promise<void> {
this.leagues.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.leagues.has(id);
}
async searchByName(): Promise<League[]> {
return [];
}
}
class InMemoryDriverRepository implements IDriverRepository {
private drivers = new Map<string, Driver>();
constructor(drivers: Array<{ id: string; name: string; country: string }>) {
for (const driver of drivers) {
this.drivers.set(driver.id, Driver.create({
id: driver.id,
iracingId: `iracing-${driver.id}`,
name: driver.name,
country: driver.country,
joinedAt: new Date('2024-01-01'),
}));
}
}
async findById(id: string): Promise<Driver | null> {
return this.drivers.get(id) ?? null;
}
async findAll(): Promise<Driver[]> {
return [...this.drivers.values()];
}
async findByIds(ids: string[]): Promise<Driver[]> {
return ids
.map(id => this.drivers.get(id))
.filter((d): d is Driver => !!d);
}
async create(): Promise<any> {
throw new Error('Not needed for these tests');
}
async update(): Promise<any> {
throw new Error('Not needed for these tests');
}
async delete(): Promise<void> {
throw new Error('Not needed for these tests');
}
async exists(): Promise<boolean> {
return false;
}
async findByIRacingId(): Promise<Driver | null> {
return null;
}
async existsByIRacingId(): Promise<boolean> {
return false;
}
}
class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
private registrations = new Map<string, Set<string>>();
constructor(seed: Array<{ raceId: string; driverId: string }> = []) {
for (const { raceId, driverId } of seed) {
if (!this.registrations.has(raceId)) {
this.registrations.set(raceId, new Set());
}
this.registrations.get(raceId)!.add(driverId);
}
}
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
return this.registrations.get(raceId)?.has(driverId) ?? false;
}
async getRegisteredDrivers(raceId: string): Promise<string[]> {
return Array.from(this.registrations.get(raceId) ?? []);
}
async getRegistrationCount(raceId: string): Promise<number> {
return this.registrations.get(raceId)?.size ?? 0;
}
async register(registration: { raceId: string; driverId: string }): Promise<void> {
if (!this.registrations.has(registration.raceId)) {
this.registrations.set(registration.raceId, new Set());
}
this.registrations.get(registration.raceId)!.add(registration.driverId);
}
async withdraw(raceId: string, driverId: string): Promise<void> {
this.registrations.get(raceId)?.delete(driverId);
}
async getDriverRegistrations(): Promise<string[]> {
return [];
}
async clearRaceRegistrations(): Promise<void> {
return;
}
}
class InMemoryResultRepository implements IResultRepository {
private results = new Map<string, Result[]>();
constructor(results: Result[]) {
for (const result of results) {
const list = this.results.get(result.raceId) ?? [];
list.push(result);
this.results.set(result.raceId, list);
}
}
async findByRaceId(raceId: string): Promise<Result[]> {
return this.results.get(raceId) ?? [];
}
async findById(): Promise<Result | null> {
return null;
}
async findAll(): Promise<Result[]> {
return [];
}
async findByDriverId(): Promise<Result[]> {
return [];
}
async findByDriverIdAndLeagueId(): Promise<Result[]> {
return [];
}
async create(result: Result): Promise<Result> {
const list = this.results.get(result.raceId) ?? [];
list.push(result);
this.results.set(result.raceId, list);
return result;
}
async createMany(results: Result[]): Promise<Result[]> {
for (const result of results) {
await this.create(result);
}
return results;
}
async update(): Promise<Result> {
throw new Error('Not needed for these tests');
}
async delete(): Promise<void> {
throw new Error('Not needed for these tests');
}
async deleteByRaceId(): Promise<void> {
throw new Error('Not needed for these tests');
}
async exists(): Promise<boolean> {
return false;
}
async existsByRaceId(): Promise<boolean> {
return false;
}
}
class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
private memberships: LeagueMembership[] = [];
seedMembership(membership: LeagueMembership): void {
this.memberships.push(membership);
}
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
return (
this.memberships.find(
m => m.leagueId === leagueId && m.driverId === driverId,
) ?? null
);
}
async getLeagueMembers(): Promise<LeagueMembership[]> {
return [];
}
async getJoinRequests(): Promise<never> {
throw new Error('Not needed for these tests');
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
this.memberships.push(membership);
return membership;
}
async removeMembership(): Promise<void> {
return;
}
async saveJoinRequest(): Promise<never> {
throw new Error('Not needed for these tests');
}
async removeJoinRequest(): Promise<never> {
throw new Error('Not needed for these tests');
}
}
class TestDriverRatingProvider implements DriverRatingProvider {
private ratings = new Map<string, number>();
seed(driverId: string, rating: number): void {
this.ratings.set(driverId, rating);
}
getRating(driverId: string): number | null {
return this.ratings.get(driverId) ?? null;
}
getRatings(driverIds: string[]): Map<string, number> {
const map = new Map<string, number>();
for (const id of driverIds) {
const rating = this.ratings.get(id);
if (rating != null) {
map.set(id, rating);
}
}
return map;
}
}
class TestImageService implements IImageServicePort {
getDriverAvatar(driverId: string): string {
return `avatar-${driverId}`;
}
getTeamLogo(teamId: string): string {
return `team-logo-${teamId}`;
}
getLeagueCover(leagueId: string): string {
return `league-cover-${leagueId}`;
}
getLeagueLogo(leagueId: string): string {
return `league-logo-${leagueId}`;
}
}
class FakeRaceDetailPresenter implements IRaceDetailPresenter {
viewModel: RaceDetailViewModel | null = null;
present(viewModel: RaceDetailViewModel): RaceDetailViewModel {
this.viewModel = viewModel;
return viewModel;
}
getViewModel(): RaceDetailViewModel | null {
return this.viewModel;
}
reset(): void {
this.viewModel = null;
}
}
describe('GetRaceDetailUseCase', () => {
it('builds entry list and registration flags for an upcoming race', async () => {
// Given (arrange a scheduled race with one registered driver)
const league = League.create({
id: 'league-1',
name: 'Test League',
description: 'League for testing',
ownerId: 'owner-1',
});
const race = Race.create({
id: 'race-1',
leagueId: league.id,
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
track: 'Test Track',
car: 'GT3',
sessionType: 'race',
status: 'scheduled',
});
const driverId = 'driver-1';
const otherDriverId = 'driver-2';
const raceRepo = new InMemoryRaceRepository([race]);
const leagueRepo = new InMemoryLeagueRepository([league]);
const driverRepo = new InMemoryDriverRepository([
{ id: driverId, name: 'Alice Racer', country: 'US' },
{ id: otherDriverId, name: 'Bob Driver', country: 'GB' },
]);
const registrationRepo = new InMemoryRaceRegistrationRepository([
{ raceId: race.id, driverId },
{ raceId: race.id, driverId: otherDriverId },
]);
const resultRepo = new InMemoryResultRepository([]);
const membershipRepo = new InMemoryLeagueMembershipRepository();
membershipRepo.seedMembership(LeagueMembership.create({
leagueId: league.id,
driverId,
role: 'member',
status: 'active',
joinedAt: new Date('2024-01-01'),
}));
const ratingProvider = new TestDriverRatingProvider();
ratingProvider.seed(driverId, 1500);
ratingProvider.seed(otherDriverId, 1600);
const imageService = new TestImageService();
const presenter = new FakeRaceDetailPresenter();
const useCase = new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
driverRepo,
registrationRepo,
resultRepo,
membershipRepo,
ratingProvider,
imageService,
);
// When (execute the query for the current driver)
await useCase.execute({ raceId: race.id, driverId }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
// Then (verify race, league and registration flags)
expect(viewModel!.race?.id).toBe(race.id);
expect(viewModel!.league?.id).toBe(league.id);
expect(viewModel!.registration.isUserRegistered).toBe(true);
expect(viewModel!.registration.canRegister).toBe(true);
// Then (entry list contains both drivers with rating and avatar)
expect(viewModel!.entryList.length).toBe(2);
const currentDriver = viewModel!.entryList.find(e => e.id === driverId);
const otherDriver = viewModel!.entryList.find(e => e.id === otherDriverId);
expect(currentDriver).toBeDefined();
expect(currentDriver!.isCurrentUser).toBe(true);
expect(currentDriver!.rating).toBe(1500);
expect(currentDriver!.avatarUrl).toBe(`avatar-${driverId}`);
expect(otherDriver).toBeDefined();
expect(otherDriver!.isCurrentUser).toBe(false);
expect(otherDriver!.rating).toBe(1600);
});
it('computes rating change for a completed race result using legacy formula', async () => {
// Given (a completed race with a result for the current driver)
const league = League.create({
id: 'league-2',
name: 'Results League',
description: 'League with results',
ownerId: 'owner-2',
});
const race = Race.create({
id: 'race-2',
leagueId: league.id,
scheduledAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
track: 'Historic Circuit',
car: 'LMP2',
sessionType: 'race',
status: 'completed',
});
const driverId = 'driver-results';
const raceRepo = new InMemoryRaceRepository([race]);
const leagueRepo = new InMemoryLeagueRepository([league]);
const driverRepo = new InMemoryDriverRepository([
{ id: driverId, name: 'Result Hero', country: 'DE' },
]);
const registrationRepo = new InMemoryRaceRegistrationRepository([
{ raceId: race.id, driverId },
]);
const resultEntity = Result.create({
id: 'result-1',
raceId: race.id,
driverId,
position: 1,
fastestLap: 90.123,
incidents: 0,
startPosition: 3,
});
const resultRepo = new InMemoryResultRepository([resultEntity]);
const membershipRepo = new InMemoryLeagueMembershipRepository();
membershipRepo.seedMembership(LeagueMembership.create({
leagueId: league.id,
driverId,
role: 'member',
status: 'active',
joinedAt: new Date('2024-01-01'),
}));
const ratingProvider = new TestDriverRatingProvider();
ratingProvider.seed(driverId, 2000);
const imageService = new TestImageService();
const presenter = new FakeRaceDetailPresenter();
const useCase = new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
driverRepo,
registrationRepo,
resultRepo,
membershipRepo,
ratingProvider,
imageService,
);
// When (executing the query for the completed race)
await useCase.execute({ raceId: race.id, driverId }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
expect(viewModel!.userResult).not.toBeNull();
// Then (rating change uses the same formula as the legacy UI)
// For P1: baseChange = 25, positionBonus = (20 - 1) * 2 = 38, total = 63
expect(viewModel!.userResult!.ratingChange).toBe(63);
expect(viewModel!.userResult!.position).toBe(1);
expect(viewModel!.userResult!.startPosition).toBe(3);
expect(viewModel!.userResult!.positionChange).toBe(2);
expect(viewModel!.userResult!.isPodium).toBe(true);
expect(viewModel!.userResult!.isClean).toBe(true);
});
it('presents an error when race does not exist', async () => {
// Given (no race in the repository)
const raceRepo = new InMemoryRaceRepository([]);
const leagueRepo = new InMemoryLeagueRepository([]);
const driverRepo = new InMemoryDriverRepository([]);
const registrationRepo = new InMemoryRaceRegistrationRepository();
const resultRepo = new InMemoryResultRepository([]);
const membershipRepo = new InMemoryLeagueMembershipRepository();
const ratingProvider = new TestDriverRatingProvider();
const imageService = new TestImageService();
const presenter = new FakeRaceDetailPresenter();
const useCase = new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
driverRepo,
registrationRepo,
resultRepo,
membershipRepo,
ratingProvider,
imageService,
);
// When
await useCase.execute({ raceId: 'missing-race', driverId: 'driver-x' }, presenter);
const viewModel = presenter.getViewModel();
// Then
expect(viewModel).not.toBeNull();
expect(viewModel!.race).toBeNull();
expect(viewModel!.error).toBe('Race not found');
});
});
describe('CancelRaceUseCase', () => {
it('cancels a scheduled race and persists it via the repository', async () => {
// Given (a scheduled race in the repository)
const race = Race.create({
id: 'cancel-me',
leagueId: 'league-cancel',
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
track: 'Cancel Circuit',
car: 'GT4',
sessionType: 'race',
status: 'scheduled',
});
const raceRepo = new InMemoryRaceRepository([race]);
const useCase = new CancelRaceUseCase(raceRepo);
// When
await useCase.execute({ raceId: race.id });
// Then (the stored race is now cancelled)
const updated = raceRepo.getStored(race.id);
expect(updated).not.toBeNull();
expect(updated!.status).toBe('cancelled');
});
it('throws when trying to cancel a non-existent race', async () => {
// Given
const raceRepo = new InMemoryRaceRepository([]);
const useCase = new CancelRaceUseCase(raceRepo);
// When / Then
await expect(
useCase.execute({ raceId: 'does-not-exist' }),
).rejects.toThrow('Race not found');
});
});

View File

@@ -0,0 +1,716 @@
import { describe, it, expect } from 'vitest';
import { Race } from '@core/racing/domain/entities/Race';
import { League } from '@core/racing/domain/entities/League';
import { Result } from '@core/racing/domain/entities/Result';
import { Penalty } from '@core/racing/domain/entities/Penalty';
import { Standing } from '@core/racing/domain/entities/Standing';
import { GetRaceResultsDetailUseCase } from '@core/racing/application/use-cases/GetRaceResultsDetailUseCase';
import { ImportRaceResultsUseCase } from '@core/racing/application/use-cases/ImportRaceResultsUseCase';
import type {
IRaceResultsDetailPresenter,
RaceResultsDetailViewModel,
} from '@core/racing/application/presenters/IRaceResultsDetailPresenter';
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '@core/racing/application/presenters/IImportRaceResultsPresenter';
class FakeRaceResultsDetailPresenter implements IRaceResultsDetailPresenter {
viewModel: RaceResultsDetailViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel {
this.viewModel = viewModel;
return viewModel;
}
getViewModel(): RaceResultsDetailViewModel | null {
return this.viewModel;
}
}
class FakeImportRaceResultsPresenter implements IImportRaceResultsPresenter {
viewModel: ImportRaceResultsSummaryViewModel | null = null;
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel {
this.viewModel = viewModel;
return viewModel;
}
getViewModel(): ImportRaceResultsSummaryViewModel | null {
return this.viewModel;
}
}
describe('ImportRaceResultsUseCase', () => {
it('imports results and triggers standings recalculation for the league', async () => {
// Given a league, a race, empty results, and a standing repository
const league = League.create({
id: 'league-1',
name: 'Import League',
description: 'League for import tests',
ownerId: 'owner-1',
});
const race = Race.create({
id: 'race-1',
leagueId: league.id,
scheduledAt: new Date(),
track: 'Import Circuit',
car: 'GT3',
sessionType: 'race',
status: 'completed',
});
const races = new Map<string, typeof race>();
races.set(race.id, race);
const leagues = new Map<string, typeof league>();
leagues.set(league.id, league);
const storedResults: Result[] = [];
let existsByRaceIdCalled = false;
const recalcCalls: string[] = [];
const raceRepository = {
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
findAll: async (): Promise<Race[]> => [],
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
findAll: async (): Promise<League[]> => [],
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => [],
findByRaceId: async (): Promise<Result[]> => [],
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (results: Result[]): Promise<Result[]> => {
storedResults.push(...results);
return results;
},
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (raceId: string): Promise<boolean> => {
existsByRaceIdCalled = true;
return storedResults.some((r) => r.raceId === raceId);
},
};
const standingRepository = {
findByLeagueId: async (): Promise<Standing[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
findAll: async (): Promise<Standing[]> => [],
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
recalculate: async (leagueId: string): Promise<Standing[]> => {
recalcCalls.push(leagueId);
return [];
},
};
const presenter = new FakeImportRaceResultsPresenter();
const useCase = new ImportRaceResultsUseCase(
raceRepository,
leagueRepository,
resultRepository,
standingRepository,
presenter,
);
const importedResults = [
Result.create({
id: 'result-1',
raceId: race.id,
driverId: 'driver-1',
position: 1,
fastestLap: 90.123,
incidents: 0,
startPosition: 3,
}),
Result.create({
id: 'result-2',
raceId: race.id,
driverId: 'driver-2',
position: 2,
fastestLap: 91.456,
incidents: 2,
startPosition: 1,
}),
];
// When executing the import
await useCase.execute({
raceId: race.id,
results: importedResults,
});
// Then new Result entries are persisted
expect(existsByRaceIdCalled).toBe(true);
expect(storedResults.length).toBe(2);
expect(storedResults.map((r) => r.id)).toEqual(['result-1', 'result-2']);
// And standings are recalculated exactly once for the correct league
expect(recalcCalls).toEqual([league.id]);
// And the presenter receives a summary
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
expect(viewModel!.importedCount).toBe(2);
expect(viewModel!.standingsRecalculated).toBe(true);
});
it('rejects import when results already exist for the race', async () => {
const league = League.create({
id: 'league-2',
name: 'Existing Results League',
description: 'League with existing results',
ownerId: 'owner-2',
});
const race = Race.create({
id: 'race-2',
leagueId: league.id,
scheduledAt: new Date(),
track: 'Existing Circuit',
car: 'GT4',
sessionType: 'race',
status: 'completed',
});
const races = new Map<string, typeof race>([[race.id, race]]);
const leagues = new Map<string, typeof league>([[league.id, league]]);
const storedResults: Result[] = [
Result.create({
id: 'existing',
raceId: race.id,
driverId: 'driver-x',
position: 1,
fastestLap: 90.0,
incidents: 1,
startPosition: 1,
}),
];
const raceRepository = {
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
findAll: async (): Promise<Race[]> => [],
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
findAll: async (): Promise<League[]> => [],
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => [],
findByRaceId: async (): Promise<Result[]> => [],
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (_results: Result[]): Promise<Result[]> => {
throw new Error('Should not be called when results already exist');
},
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (raceId: string): Promise<boolean> => {
return storedResults.some((r) => r.raceId === raceId);
},
};
const standingRepository = {
findByLeagueId: async (): Promise<Standing[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Standing | null> => null,
findAll: async (): Promise<Standing[]> => [],
save: async (): Promise<Standing> => { throw new Error('Not implemented'); },
saveMany: async (): Promise<Standing[]> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByLeagueId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
recalculate: async (_leagueId: string): Promise<Standing[]> => {
throw new Error('Should not be called when results already exist');
},
};
const presenter = new FakeImportRaceResultsPresenter();
const driverRepository = {
findById: async (): Promise<Driver | null> => null,
findByIRacingId: async (iracingId: string): Promise<Driver | null> => {
// Mock finding driver by iracingId
if (iracingId === 'driver-1') {
return Driver.create({ id: 'driver-1', iracingId: 'driver-1', name: 'Driver One', country: 'US' });
}
if (iracingId === 'driver-2') {
return Driver.create({ id: 'driver-2', iracingId: 'driver-2', name: 'Driver Two', country: 'GB' });
}
return null;
},
findAll: async (): Promise<Driver[]> => [],
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const useCase = new ImportRaceResultsUseCase(
raceRepository,
leagueRepository,
resultRepository,
driverRepository,
standingRepository,
presenter,
);
const importedResults = [
Result.create({
id: 'new-result',
raceId: race.id,
driverId: 'driver-1',
position: 2,
fastestLap: 91.0,
incidents: 0,
startPosition: 2,
}),
];
await expect(
useCase.execute({
raceId: race.id,
results: importedResults,
}),
).rejects.toThrow('Results already exist for this race');
});
});
describe('GetRaceResultsDetailUseCase', () => {
it('computes points system from league settings and identifies fastest lap', async () => {
// Given a league with default scoring configuration and two results
const league = League.create({
id: 'league-scoring',
name: 'Scoring League',
description: 'League with scoring settings',
ownerId: 'owner-scoring',
});
const race = Race.create({
id: 'race-scoring',
leagueId: league.id,
scheduledAt: new Date(),
track: 'Scoring Circuit',
car: 'Prototype',
sessionType: 'race',
status: 'completed',
});
const driver1: { id: string; name: string; country: string } = {
id: 'driver-a',
name: 'Driver A',
country: 'US',
};
const driver2: { id: string; name: string; country: string } = {
id: 'driver-b',
name: 'Driver B',
country: 'GB',
};
const result1 = Result.create({
id: 'r1',
raceId: race.id,
driverId: driver1.id,
position: 1,
fastestLap: 90.123,
incidents: 0,
startPosition: 3,
});
const result2 = Result.create({
id: 'r2',
raceId: race.id,
driverId: driver2.id,
position: 2,
fastestLap: 88.456,
incidents: 2,
startPosition: 1,
});
const races = new Map<string, typeof race>([[race.id, race]]);
const leagues = new Map<string, typeof league>([[league.id, league]]);
const results = [result1, result2];
const drivers = [driver1, driver2];
const raceRepository = {
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
findAll: async (): Promise<Race[]> => [],
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
findAll: async (): Promise<League[]> => [],
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => [],
findByRaceId: async (raceId: string): Promise<Result[]> =>
results.filter((r) => r.raceId === raceId),
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (): Promise<boolean> => false,
};
const driverRepository = {
findById: async (): Promise<Driver | null> => null,
findByIRacingId: async (): Promise<Driver | null> => null,
findAll: async (): Promise<Driver[]> => drivers.map(d => Driver.create({ id: d.id, iracingId: '123', name: d.name, country: d.country })),
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const penaltyRepository = {
findById: async (): Promise<Penalty | null> => null,
findByRaceId: async (): Promise<Penalty[]> => [] as Penalty[],
findByDriverId: async (): Promise<Penalty[]> => [],
findByProtestId: async (): Promise<Penalty[]> => [],
findPending: async (): Promise<Penalty[]> => [],
findIssuedBy: async (): Promise<Penalty[]> => [],
create: async (): Promise<void> => { throw new Error('Not implemented'); },
update: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const presenter = new FakeRaceResultsDetailPresenter();
const useCase = new GetRaceResultsDetailUseCase(
raceRepository,
leagueRepository,
resultRepository,
driverRepository,
penaltyRepository,
);
// When executing the query
await useCase.execute({ raceId: race.id }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
// Then points system matches the default F1-style configuration
expect(viewModel!.pointsSystem?.[1]).toBe(25);
expect(viewModel!.pointsSystem?.[2]).toBe(18);
// And fastest lap is identified correctly
expect(viewModel!.fastestLapTime).toBeCloseTo(88.456, 3);
});
it('builds race results view model including penalties', async () => {
// Given a race with one result and one applied penalty
const league = League.create({
id: 'league-penalties',
name: 'Penalty League',
description: 'League with penalties',
ownerId: 'owner-penalties',
});
const race = Race.create({
id: 'race-penalties',
leagueId: league.id,
scheduledAt: new Date(),
track: 'Penalty Circuit',
car: 'Touring',
sessionType: 'race',
status: 'completed',
});
const driver: { id: string; name: string; country: string } = {
id: 'driver-pen',
name: 'Penalty Driver',
country: 'DE',
};
const result = Result.create({
id: 'res-pen',
raceId: race.id,
driverId: driver.id,
position: 3,
fastestLap: 95.0,
incidents: 4,
startPosition: 5,
});
const penalty = Penalty.create({
id: 'pen-1',
leagueId: league.id,
raceId: race.id,
driverId: driver.id,
type: 'points_deduction',
value: 3,
reason: 'Track limits',
issuedBy: 'steward-1',
status: 'applied',
issuedAt: new Date(),
});
const races = new Map<string, typeof race>([[race.id, race]]);
const leagues = new Map<string, typeof league>([[league.id, league]]);
const results = [result];
const drivers = [driver];
const penalties = [penalty];
const raceRepository = {
findById: async (id: string): Promise<Race | null> => races.get(id) ?? null,
findAll: async (): Promise<Race[]> => [],
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (id: string): Promise<League | null> => leagues.get(id) ?? null,
findAll: async (): Promise<League[]> => [],
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => [],
findByRaceId: async (raceId: string): Promise<Result[]> =>
results.filter((r) => r.raceId === raceId),
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (): Promise<boolean> => false,
};
const driverRepository = {
findById: async (): Promise<Driver | null> => null,
findByIRacingId: async (): Promise<Driver | null> => null,
findAll: async (): Promise<Driver[]> => drivers.map(d => Driver.create({ id: d.id, iracingId: '123', name: d.name, country: d.country })),
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const penaltyRepository = {
findById: async (): Promise<Penalty | null> => null,
findByRaceId: async (raceId: string): Promise<Penalty[]> =>
penalties.filter((p) => p.raceId === raceId),
findByDriverId: async (): Promise<Penalty[]> => [],
findByProtestId: async (): Promise<Penalty[]> => [],
findPending: async (): Promise<Penalty[]> => [],
findIssuedBy: async (): Promise<Penalty[]> => [],
create: async (): Promise<void> => { throw new Error('Not implemented'); },
update: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const presenter = new FakeRaceResultsDetailPresenter();
const useCase = new GetRaceResultsDetailUseCase(
raceRepository,
leagueRepository,
resultRepository,
driverRepository,
penaltyRepository,
);
// When
await useCase.execute({ raceId: race.id }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
// Then header and league info are present
expect(viewModel!.race).not.toBeNull();
expect(viewModel!.race!.id).toBe(race.id);
expect(viewModel!.league).not.toBeNull();
expect(viewModel!.league!.id).toBe(league.id);
// And classification and penalties match the underlying data
expect(viewModel!.results.length).toBe(1);
expect(viewModel!.results[0]!.id).toBe(result.id);
expect(viewModel!.penalties.length).toBe(1);
expect(viewModel!.penalties[0]!.driverId).toBe(driver.id);
expect(viewModel!.penalties[0]!.type).toBe('points_deduction');
expect(viewModel!.penalties[0]!.value).toBe(3);
});
it('presents an error when race does not exist', async () => {
// Given repositories without the requested race
const raceRepository = {
findById: async (): Promise<Race | null> => null,
findAll: async (): Promise<Race[]> => [],
findByLeagueId: async (): Promise<Race[]> => [],
findUpcomingByLeagueId: async (): Promise<Race[]> => [],
findCompletedByLeagueId: async (): Promise<Race[]> => [],
findByStatus: async (): Promise<Race[]> => [],
findByDateRange: async (): Promise<Race[]> => [],
create: async (): Promise<Race> => { throw new Error('Not implemented'); },
update: async (): Promise<Race> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const leagueRepository = {
findById: async (): Promise<League | null> => null,
findAll: async (): Promise<League[]> => [],
findByOwnerId: async (): Promise<League[]> => [],
create: async (): Promise<League> => { throw new Error('Not implemented'); },
update: async (): Promise<League> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
searchByName: async (): Promise<League[]> => [],
};
const resultRepository = {
findById: async (): Promise<Result | null> => null,
findAll: async (): Promise<Result[]> => [],
findByRaceId: async (): Promise<Result[]> => [] as Result[],
findByDriverId: async (): Promise<Result[]> => [],
findByDriverIdAndLeagueId: async (): Promise<Result[]> => [],
create: async (): Promise<Result> => { throw new Error('Not implemented'); },
createMany: async (): Promise<Result[]> => { throw new Error('Not implemented'); },
update: async (): Promise<Result> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
deleteByRaceId: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByRaceId: async (): Promise<boolean> => false,
};
const driverRepository = {
findById: async (): Promise<Driver | null> => null,
findByIRacingId: async (): Promise<Driver | null> => null,
findAll: async (): Promise<Driver[]> => [],
create: async (): Promise<Driver> => { throw new Error('Not implemented'); },
update: async (): Promise<Driver> => { throw new Error('Not implemented'); },
delete: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
existsByIRacingId: async (): Promise<boolean> => false,
};
const penaltyRepository = {
findById: async (): Promise<Penalty | null> => null,
findByRaceId: async (): Promise<Penalty[]> => [] as Penalty[],
findByDriverId: async (): Promise<Penalty[]> => [],
findByProtestId: async (): Promise<Penalty[]> => [],
findPending: async (): Promise<Penalty[]> => [],
findIssuedBy: async (): Promise<Penalty[]> => [],
create: async (): Promise<void> => { throw new Error('Not implemented'); },
update: async (): Promise<void> => { throw new Error('Not implemented'); },
exists: async (): Promise<boolean> => false,
};
const presenter = new FakeRaceResultsDetailPresenter();
const useCase = new GetRaceResultsDetailUseCase(
raceRepository,
leagueRepository,
resultRepository,
driverRepository,
penaltyRepository,
);
// When
await useCase.execute({ raceId: 'missing-race' }, presenter);
const viewModel = presenter.getViewModel();
expect(viewModel).not.toBeNull();
expect(viewModel!.race).toBeNull();
expect(viewModel!.error).toBe('Race not found');
});
});

View File

@@ -0,0 +1,865 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { Driver } from '@core/racing/domain/entities/Driver';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '@core/racing/domain/types/TeamMembership';
import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/RegisterForRaceUseCase';
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { GetRaceRegistrationsUseCase } from '@core/racing/application/use-cases/GetRaceRegistrationsUseCase';
import type { IDriverRegistrationStatusPresenter } from '@core/racing/application/presenters/IDriverRegistrationStatusPresenter';
import type { IRaceRegistrationsPresenter } from '@core/racing/application/presenters/IRaceRegistrationsPresenter';
import type {
IAllTeamsPresenter,
AllTeamsResultDTO,
AllTeamsViewModel,
} from '@core/racing/application/presenters/IAllTeamsPresenter';
import type { ITeamDetailsPresenter } from '@core/racing/application/presenters/ITeamDetailsPresenter';
import type {
ITeamMembersPresenter,
TeamMembersResultDTO,
TeamMembersViewModel,
} from '@core/racing/application/presenters/ITeamMembersPresenter';
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestsResultDTO,
TeamJoinRequestsViewModel,
} from '@core/racing/application/presenters/ITeamJoinRequestsPresenter';
import type {
IDriverTeamPresenter,
DriverTeamResultDTO,
DriverTeamViewModel,
} from '@core/racing/application/presenters/IDriverTeamPresenter';
import type { RaceRegistrationsResultDTO } from '@core/racing/application/presenters/IRaceRegistrationsPresenter';
/**
* Simple in-memory fakes mirroring current alpha behavior.
*/
class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
private registrations = new Map<string, Set<string>>(); // raceId -> driverIds
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
const set = this.registrations.get(raceId);
return set ? set.has(driverId) : false;
}
async getRegisteredDrivers(raceId: string): Promise<string[]> {
const set = this.registrations.get(raceId);
return set ? Array.from(set) : [];
}
async getRegistrationCount(raceId: string): Promise<number> {
const set = this.registrations.get(raceId);
return set ? set.size : 0;
}
async register(registration: RaceRegistration): Promise<void> {
if (!this.registrations.has(registration.raceId)) {
this.registrations.set(registration.raceId, new Set());
}
this.registrations.get(registration.raceId)!.add(registration.driverId);
}
async withdraw(raceId: string, driverId: string): Promise<void> {
const set = this.registrations.get(raceId);
if (!set || !set.has(driverId)) {
throw new Error('Not registered for this race');
}
set.delete(driverId);
if (set.size === 0) {
this.registrations.delete(raceId);
}
}
async getDriverRegistrations(driverId: string): Promise<string[]> {
const result: string[] = [];
for (const [raceId, set] of this.registrations.entries()) {
if (set.has(driverId)) {
result.push(raceId);
}
}
return result;
}
async clearRaceRegistrations(raceId: string): Promise<void> {
this.registrations.delete(raceId);
}
}
class InMemoryLeagueMembershipRepositoryForRegistrations implements ILeagueMembershipRepository {
private memberships: LeagueMembership[] = [];
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
return (
this.memberships.find(
(m) => m.leagueId === leagueId && m.leagueId === leagueId && m.driverId === driverId,
) || null
);
}
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
return this.memberships.filter(
(m) => m.leagueId === leagueId && m.status === 'active',
);
}
async getJoinRequests(): Promise<never> {
throw new Error('Not needed for registration tests');
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
this.memberships.push(membership);
return membership;
}
async removeMembership(): Promise<void> {
throw new Error('Not needed for registration tests');
}
async saveJoinRequest(): Promise<never> {
throw new Error('Not needed for registration tests');
}
async removeJoinRequest(): Promise<never> {
throw new Error('Not needed for registration tests');
}
seedActiveMembership(leagueId: string, driverId: string): void {
this.memberships.push(
LeagueMembership.create({
leagueId,
driverId,
role: 'member',
status: 'active' as MembershipStatus,
joinedAt: new Date('2024-01-01'),
}),
);
}
}
class TestDriverRegistrationStatusPresenter implements IDriverRegistrationStatusPresenter {
isRegistered: boolean | null = null;
raceId: string | null = null;
driverId: string | null = null;
present(isRegistered: boolean, raceId: string, driverId: string) {
this.isRegistered = isRegistered;
this.raceId = raceId;
this.driverId = driverId;
return {
isRegistered,
raceId,
driverId,
};
}
getViewModel() {
return {
isRegistered: this.isRegistered!,
raceId: this.raceId!,
driverId: this.driverId!,
};
}
}
class TestRaceRegistrationsPresenter implements IRaceRegistrationsPresenter {
raceId: string | null = null;
driverIds: string[] = [];
reset(): void {
this.raceId = null;
this.driverIds = [];
}
present(input: RaceRegistrationsResultDTO) {
this.driverIds = input.registeredDriverIds;
this.raceId = null;
return {
registeredDriverIds: input.registeredDriverIds,
count: input.registeredDriverIds.length,
};
}
getViewModel() {
return {
registeredDriverIds: this.driverIds,
count: this.driverIds.length,
};
}
}
class InMemoryTeamRepository implements ITeamRepository {
private teams: Team[] = [];
async findById(id: string): Promise<Team | null> {
return this.teams.find((t) => t.id === id) || null;
}
async findAll(): Promise<Team[]> {
return [...this.teams];
}
async findByLeagueId(leagueId: string): Promise<Team[]> {
return this.teams.filter((t) => t.leagues.includes(leagueId));
}
async create(team: Team): Promise<Team> {
this.teams.push(team);
return team;
}
async update(team: Team): Promise<Team> {
const index = this.teams.findIndex((t) => t.id === team.id);
if (index >= 0) {
this.teams[index] = team;
} else {
this.teams.push(team);
}
return team;
}
async delete(id: string): Promise<void> {
this.teams = this.teams.filter((t) => t.id !== id);
}
async exists(id: string): Promise<boolean> {
return this.teams.some((t) => t.id === id);
}
seedTeam(team: Team): void {
this.teams.push(team);
}
}
class InMemoryTeamMembershipRepository implements ITeamMembershipRepository {
private memberships: TeamMembership[] = [];
private joinRequests: TeamJoinRequest[] = [];
async getMembership(teamId: string, driverId: string): Promise<TeamMembership | null> {
return (
this.memberships.find(
(m) => m.teamId === teamId && m.driverId === driverId,
) || null
);
}
async getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null> {
return (
this.memberships.find(
(m) => m.driverId === driverId && m.status === 'active',
) || null
);
}
async getTeamMembers(teamId: string): Promise<TeamMembership[]> {
return this.memberships.filter(
(m) => m.teamId === teamId && m.status === 'active',
);
}
async findByTeamId(teamId: string): Promise<TeamMembership[]> {
return this.memberships.filter((m) => m.teamId === teamId);
}
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
const index = this.memberships.findIndex(
(m) => m.teamId === membership.teamId && m.driverId === membership.driverId,
);
if (index >= 0) {
this.memberships[index] = membership;
} else {
this.memberships.push(membership);
}
return membership;
}
async removeMembership(teamId: string, driverId: string): Promise<void> {
this.memberships = this.memberships.filter(
(m) => !(m.teamId === teamId && m.driverId === driverId),
);
}
async getJoinRequests(teamId: string): Promise<TeamJoinRequest[]> {
// For these tests we ignore teamId and return all,
// allowing use-cases to look up by request ID only.
return [...this.joinRequests];
}
async saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest> {
const index = this.joinRequests.findIndex((r) => r.id === request.id);
if (index >= 0) {
this.joinRequests[index] = request;
} else {
this.joinRequests.push(request);
}
return request;
}
async removeJoinRequest(requestId: string): Promise<void> {
this.joinRequests = this.joinRequests.filter((r) => r.id !== requestId);
}
seedMembership(membership: TeamMembership): void {
this.memberships.push(membership);
}
seedJoinRequest(request: TeamJoinRequest): void {
this.joinRequests.push(request);
}
getAllMemberships(): TeamMembership[] {
return [...this.memberships];
}
getAllJoinRequests(): TeamJoinRequest[] {
return [...this.joinRequests];
}
async countByTeamId(teamId: string): Promise<number> {
return this.memberships.filter((m) => m.teamId === teamId).length;
}
}
describe('Racing application use-cases - registrations', () => {
let registrationRepo: InMemoryRaceRegistrationRepository;
let membershipRepo: InMemoryLeagueMembershipRepositoryForRegistrations;
let registerForRace: RegisterForRaceUseCase;
let withdrawFromRace: WithdrawFromRaceUseCase;
let isDriverRegistered: IsDriverRegisteredForRaceUseCase;
let getRaceRegistrations: GetRaceRegistrationsUseCase;
let driverRegistrationPresenter: TestDriverRegistrationStatusPresenter;
let raceRegistrationsPresenter: TestRaceRegistrationsPresenter;
beforeEach(() => {
registrationRepo = new InMemoryRaceRegistrationRepository();
membershipRepo = new InMemoryLeagueMembershipRepositoryForRegistrations();
registerForRace = new RegisterForRaceUseCase(registrationRepo, membershipRepo);
withdrawFromRace = new WithdrawFromRaceUseCase(registrationRepo);
driverRegistrationPresenter = new TestDriverRegistrationStatusPresenter();
isDriverRegistered = new IsDriverRegisteredForRaceUseCase(
registrationRepo,
driverRegistrationPresenter,
);
raceRegistrationsPresenter = new TestRaceRegistrationsPresenter();
getRaceRegistrations = new GetRaceRegistrationsUseCase(registrationRepo);
});
it('registers an active league member for a race and tracks registration', async () => {
const raceId = 'race-1';
const leagueId = 'league-1';
const driverId = 'driver-1';
membershipRepo.seedActiveMembership(leagueId, driverId);
await registerForRace.execute({ raceId, leagueId, driverId });
await isDriverRegistered.execute({ raceId, driverId });
expect(driverRegistrationPresenter.isRegistered).toBe(true);
expect(driverRegistrationPresenter.raceId).toBe(raceId);
expect(driverRegistrationPresenter.driverId).toBe(driverId);
await getRaceRegistrations.execute({ raceId }, raceRegistrationsPresenter);
expect(raceRegistrationsPresenter.driverIds).toContain(driverId);
});
it('throws when registering a non-member for a race', async () => {
const raceId = 'race-1';
const leagueId = 'league-1';
const driverId = 'driver-1';
await expect(
registerForRace.execute({ raceId, leagueId, driverId }),
).rejects.toThrow('Must be an active league member to register for races');
});
it('withdraws a registration and reflects state in queries', async () => {
const raceId = 'race-1';
const leagueId = 'league-1';
const driverId = 'driver-1';
membershipRepo.seedActiveMembership(leagueId, driverId);
await registerForRace.execute({ raceId, leagueId, driverId });
await withdrawFromRace.execute({ raceId, driverId });
await isDriverRegistered.execute({ raceId, driverId });
expect(driverRegistrationPresenter.isRegistered).toBe(false);
await getRaceRegistrations.execute({ raceId }, raceRegistrationsPresenter);
expect(raceRegistrationsPresenter.driverIds).toEqual([]);
});
});
describe('Racing application use-cases - teams', () => {
let teamRepo: InMemoryTeamRepository;
let membershipRepo: InMemoryTeamMembershipRepository;
let createTeam: CreateTeamUseCase;
let joinTeam: JoinTeamUseCase;
let leaveTeam: LeaveTeamUseCase;
let approveJoin: ApproveTeamJoinRequestUseCase;
let rejectJoin: RejectTeamJoinRequestUseCase;
let updateTeamUseCase: UpdateTeamUseCase;
let getAllTeamsUseCase: GetAllTeamsUseCase;
let getTeamDetailsUseCase: GetTeamDetailsUseCase;
let getTeamMembersUseCase: GetTeamMembersUseCase;
let getTeamJoinRequestsUseCase: GetTeamJoinRequestsUseCase;
let getDriverTeamUseCase: GetDriverTeamUseCase;
class FakeDriverRepository {
async findById(driverId: string): Promise<Driver | null> {
return Driver.create({ id: driverId, iracingId: '123', name: `Driver ${driverId}`, country: 'US' });
}
async findByIRacingId(id: string): Promise<Driver | null> {
return null;
}
async findAll(): Promise<Driver[]> {
return [];
}
async create(driver: Driver): Promise<Driver> {
return driver;
}
async update(driver: Driver): Promise<Driver> {
return driver;
}
async delete(id: string): Promise<void> {
}
async exists(id: string): Promise<boolean> {
return false;
}
async existsByIRacingId(iracingId: string): Promise<boolean> {
return false;
}
async findByLeagueId(leagueId: string): Promise<Driver[]> {
return [];
}
async findByTeamId(teamId: string): Promise<Driver[]> {
return [];
}
}
class FakeImageService {
getDriverAvatar(driverId: string): string {
return `https://example.com/avatar/${driverId}.png`;
}
getTeamLogo(teamId: string): string {
return `https://example.com/logo/${teamId}.png`;
}
getLeagueCover(leagueId: string): string {
return `https://example.com/cover/${leagueId}.png`;
}
getLeagueLogo(leagueId: string): string {
return `https://example.com/logo/${leagueId}.png`;
}
}
class TestAllTeamsPresenter implements IAllTeamsPresenter {
private viewModel: AllTeamsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: AllTeamsResultDTO): void {
this.viewModel = {
teams: input.teams.map((team) => ({
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
memberCount: team.memberCount,
leagues: team.leagues,
specialization: (team as unknown).specialization,
region: (team as unknown).region,
languages: (team as unknown).languages,
})),
totalCount: input.teams.length,
};
}
getViewModel(): AllTeamsViewModel | null {
return this.viewModel;
}
get teams(): unknown[] {
return this.viewModel?.teams ?? [];
}
}
class TestTeamDetailsPresenter implements ITeamDetailsPresenter {
viewModel: any = null;
reset(): void {
this.viewModel = null;
}
present(input: any): void {
this.viewModel = input;
}
getViewModel(): any {
return this.viewModel;
}
}
class TestTeamMembersPresenter implements ITeamMembersPresenter {
private viewModel: TeamMembersViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: TeamMembersResultDTO): void {
const members = input.memberships.map((membership) => {
const driverId = membership.driverId;
const driverName = input.driverNames[driverId] ?? driverId;
const avatarUrl = input.avatarUrls[driverId] ?? '';
return {
driverId,
driverName,
role: ((membership.role as unknown) === 'owner' ? 'owner' : (membership.role as unknown) === 'member' ? 'member' : (membership.role as unknown) === 'manager' ? 'manager' : (membership.role as unknown) === 'driver' ? 'member' : 'member') as "owner" | "member" | "manager",
joinedAt: membership.joinedAt.toISOString(),
isActive: membership.status === 'active',
avatarUrl,
};
});
const ownerCount = members.filter((m) => m.role === 'owner').length;
const managerCount = members.filter((m) => m.role === 'manager').length;
const memberCount = members.filter((m) => (m.role as unknown) === 'member').length;
this.viewModel = {
members,
totalCount: members.length,
ownerCount,
managerCount,
memberCount,
};
}
getViewModel(): TeamMembersViewModel | null {
return this.viewModel;
}
get members(): unknown[] {
return this.viewModel?.members ?? [];
}
}
class TestTeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
private viewModel: TeamJoinRequestsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: TeamJoinRequestsResultDTO): void {
const requests = input.requests.map((request) => {
const driverId = request.driverId;
const driverName = input.driverNames[driverId] ?? driverId;
const avatarUrl = input.avatarUrls[driverId] ?? '';
return {
requestId: request.id,
driverId,
driverName,
teamId: request.teamId,
status: 'pending' as const,
requestedAt: request.requestedAt.toISOString(),
avatarUrl,
};
});
const pendingCount = requests.filter((r) => r.status === 'pending').length;
this.viewModel = {
requests,
pendingCount,
totalCount: requests.length,
};
}
getViewModel(): TeamJoinRequestsViewModel | null {
return this.viewModel;
}
get requests(): unknown[] {
return this.viewModel?.requests ?? [];
}
}
class TestDriverTeamPresenter implements IDriverTeamPresenter {
viewModel: DriverTeamViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: DriverTeamResultDTO): void {
const { team, membership, driverId } = input;
const isOwner = team.ownerId === driverId;
const canManage = membership.role === 'owner' || membership.role === 'manager';
this.viewModel = {
team: {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
leagues: team.leagues,
},
membership: {
role: (membership.role === 'owner' || membership.role === 'manager') ? membership.role : 'member' as "owner" | "member" | "manager",
joinedAt: membership.joinedAt.toISOString(),
isActive: membership.status === 'active',
},
isOwner,
canManage,
};
}
getViewModel(): DriverTeamViewModel | null {
return this.viewModel;
}
}
let allTeamsPresenter: TestAllTeamsPresenter;
let teamDetailsPresenter: TestTeamDetailsPresenter;
let teamMembersPresenter: TestTeamMembersPresenter;
let teamJoinRequestsPresenter: TestTeamJoinRequestsPresenter;
let driverTeamPresenter: TestDriverTeamPresenter;
beforeEach(() => {
teamRepo = new InMemoryTeamRepository();
membershipRepo = new InMemoryTeamMembershipRepository();
createTeam = new CreateTeamUseCase(teamRepo, membershipRepo);
joinTeam = new JoinTeamUseCase(teamRepo, membershipRepo);
leaveTeam = new LeaveTeamUseCase(membershipRepo);
approveJoin = new ApproveTeamJoinRequestUseCase(membershipRepo);
rejectJoin = new RejectTeamJoinRequestUseCase(membershipRepo);
updateTeamUseCase = new UpdateTeamUseCase(teamRepo, membershipRepo);
allTeamsPresenter = new TestAllTeamsPresenter();
getAllTeamsUseCase = new GetAllTeamsUseCase(
teamRepo,
membershipRepo,
);
teamDetailsPresenter = new TestTeamDetailsPresenter();
getTeamDetailsUseCase = new GetTeamDetailsUseCase(
teamRepo,
membershipRepo,
);
const driverRepository = new FakeDriverRepository();
const imageService = new FakeImageService();
teamMembersPresenter = new TestTeamMembersPresenter();
getTeamMembersUseCase = new GetTeamMembersUseCase(
membershipRepo,
driverRepository,
imageService,
teamMembersPresenter,
);
teamJoinRequestsPresenter = new TestTeamJoinRequestsPresenter();
getTeamJoinRequestsUseCase = new GetTeamJoinRequestsUseCase(
membershipRepo,
driverRepository,
imageService,
teamJoinRequestsPresenter,
);
driverTeamPresenter = new TestDriverTeamPresenter();
getDriverTeamUseCase = new GetDriverTeamUseCase(
teamRepo,
membershipRepo,
driverTeamPresenter,
);
});
it('creates a team and assigns creator as active owner', async () => {
const ownerId = 'driver-1';
const result = await createTeam.execute({
name: 'Apex Racing',
tag: 'APEX',
description: 'Professional GT3 racing',
ownerId,
leagues: ['league-1'],
});
expect(result.team.id).toBeDefined();
expect(result.team.ownerId).toBe(ownerId);
const membership = await membershipRepo.getActiveMembershipForDriver(ownerId);
expect(membership?.teamId).toBe(result.team.id);
expect(membership?.role as TeamRole).toBe('owner');
expect(membership?.status as TeamMembershipStatus).toBe('active');
});
it('prevents driver from joining multiple teams and mirrors legacy error message', async () => {
const ownerId = 'driver-1';
const otherTeamId = 'team-2';
// Seed an existing active membership
membershipRepo.seedMembership({
teamId: otherTeamId,
driverId: ownerId,
role: 'driver',
status: 'active',
joinedAt: new Date('2024-02-01'),
});
await expect(
joinTeam.execute({ teamId: 'team-1', driverId: ownerId }),
).rejects.toThrow('Driver already belongs to a team');
});
it('approves a join request and moves it into active membership', async () => {
const teamId = 'team-1';
const driverId = 'driver-2';
const request: TeamJoinRequest = {
id: 'req-1',
teamId,
driverId,
requestedAt: new Date('2024-03-01'),
message: 'Let me in',
};
membershipRepo.seedJoinRequest(request);
await approveJoin.execute({ requestId: request.id });
const membership = await membershipRepo.getMembership(teamId, driverId);
expect(membership).not.toBeNull();
expect(membership?.status as TeamMembershipStatus).toBe('active');
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
});
it('rejects a join request and removes it', async () => {
const teamId = 'team-1';
const driverId = 'driver-2';
const request: TeamJoinRequest = {
id: 'req-2',
teamId,
driverId,
requestedAt: new Date('2024-03-02'),
message: 'Please?',
};
membershipRepo.seedJoinRequest(request);
await rejectJoin.execute({ requestId: request.id });
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
});
it('updates team details when performed by owner or manager and reflects in queries', async () => {
const ownerId = 'driver-1';
const created = await createTeam.execute({
name: 'Original Name',
tag: 'ORIG',
description: 'Original description',
ownerId,
leagues: [],
});
await updateTeamUseCase.execute({
teamId: created.team.id,
updates: { name: 'Updated Name', description: 'Updated description' },
updatedBy: ownerId,
});
await getTeamDetailsUseCase.execute({ teamId: created.team.id, driverId: ownerId }, teamDetailsPresenter);
expect(teamDetailsPresenter.viewModel.team.name).toBe('Updated Name');
expect(teamDetailsPresenter.viewModel.team.description).toBe('Updated description');
});
it('returns driver team via query matching legacy getDriverTeam behavior', async () => {
const ownerId = 'driver-1';
const { team } = await createTeam.execute({
name: 'Apex Racing',
tag: 'APEX',
description: 'Professional GT3 racing',
ownerId,
leagues: [],
});
await getDriverTeamUseCase.execute({ driverId: ownerId }, driverTeamPresenter);
const result = driverTeamPresenter.viewModel;
expect(result).not.toBeNull();
expect(result?.team.id).toBe(team.id);
expect(result?.membership.isActive).toBe(true);
expect(result?.isOwner).toBe(true);
});
it('lists all teams and members via queries after multiple operations', async () => {
const ownerId = 'driver-1';
const otherDriverId = 'driver-2';
const { team } = await createTeam.execute({
name: 'Apex Racing',
tag: 'APEX',
description: 'Professional GT3 racing',
ownerId,
leagues: [],
});
await joinTeam.execute({ teamId: team.id, driverId: otherDriverId });
await getAllTeamsUseCase.execute(undefined as void, allTeamsPresenter);
expect(allTeamsPresenter.teams.length).toBe(1);
await getTeamMembersUseCase.execute({ teamId: team.id }, teamMembersPresenter);
const memberIds = teamMembersPresenter.members.map((m) => m.driverId).sort();
expect(memberIds).toEqual([ownerId, otherDriverId].sort());
});
});

View File

@@ -0,0 +1,26 @@
import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase';
import { RejectLeagueJoinRequestPresenter } from '@apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter';
describe('RejectLeagueJoinRequestUseCase', () => {
let useCase: RejectLeagueJoinRequestUseCase;
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
let presenter: RejectLeagueJoinRequestPresenter;
beforeEach(() => {
leagueMembershipRepository = {
removeJoinRequest: jest.fn(),
} as unknown;
presenter = new RejectLeagueJoinRequestPresenter();
useCase = new RejectLeagueJoinRequestUseCase(leagueMembershipRepository);
});
it('should reject join request', async () => {
const requestId = 'req-1';
await useCase.execute({ requestId }, presenter);
expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(presenter.viewModel).toEqual({ success: true, message: 'Join request rejected.' });
});
});

View File

@@ -0,0 +1,43 @@
import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase';
import { RemoveLeagueMemberPresenter } from '@apps/api/src/modules/league/presenters/RemoveLeagueMemberPresenter';
describe('RemoveLeagueMemberUseCase', () => {
let useCase: RemoveLeagueMemberUseCase;
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
let presenter: RemoveLeagueMemberPresenter;
beforeEach(() => {
leagueMembershipRepository = {
getLeagueMembers: jest.fn(),
saveMembership: jest.fn(),
} as unknown;
presenter = new RemoveLeagueMemberPresenter();
useCase = new RemoveLeagueMemberUseCase(leagueMembershipRepository);
});
it('should remove league member by setting status to inactive', async () => {
const leagueId = 'league-1';
const targetDriverId = 'driver-1';
const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }];
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
await useCase.execute({ leagueId, targetDriverId }, presenter);
expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({
leagueId,
driverId: targetDriverId,
role: 'member',
status: 'inactive',
joinedAt: expect.any(Date),
});
expect(presenter.viewModel).toEqual({ success: true });
});
it('should throw error if membership not found', async () => {
leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]);
await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1' }, presenter)).rejects.toThrow('Membership not found');
});
});

View File

@@ -0,0 +1,449 @@
import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@core/racing/domain/entities/Season';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {
CreateSeasonForLeagueUseCase,
ListSeasonsForLeagueUseCase,
GetSeasonDetailsUseCase,
ManageSeasonLifecycleUseCase,
type CreateSeasonForLeagueCommand,
type ManageSeasonLifecycleCommand,
} from '@core/racing/application/use-cases/SeasonUseCases';
import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
findAll: async () => seed,
create: async (league: any) => league,
update: async (league: any) => league,
} as unknown as ILeagueRepository;
}
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
return {
basics: {
name: 'Test League',
visibility: 'ranked',
gameId: 'iracing',
...overrides?.basics,
},
structure: {
mode: 'solo',
maxDrivers: 30,
...overrides?.structure,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
...overrides?.championships,
},
scoring: {
patternId: 'sprint-main-driver',
customScoringEnabled: false,
...overrides?.scoring,
},
dropPolicy: {
strategy: 'bestNResults',
n: 3,
...overrides?.dropPolicy,
},
timings: {
qualifyingMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 8,
seasonStartDate: '2025-01-01',
raceStartTime: '20:00',
timezoneId: 'UTC',
recurrenceStrategy: 'weekly',
weekdays: ['Mon'],
...overrides?.timings,
},
stewarding: {
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
...overrides?.stewarding,
},
...overrides,
};
}
describe('InMemorySeasonRepository', () => {
it('add and findById provide a roundtrip for Season', async () => {
const repo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Test Season',
status: 'planned',
});
await repo.add(season);
const loaded = await repo.findById(season.id);
expect(loaded).not.toBeNull();
expect(loaded!.id).toBe(season.id);
expect(loaded!.leagueId).toBe(season.leagueId);
expect(loaded!.status).toBe('planned');
});
it('update persists changed Season state', async () => {
const repo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Initial Season',
status: 'planned',
});
await repo.add(season);
const activated = season.activate();
await repo.update(activated);
const loaded = await repo.findById(season.id);
expect(loaded).not.toBeNull();
expect(loaded!.status).toBe('active');
});
it('listByLeague returns only seasons for that league', async () => {
const repo = new InMemorySeasonRepository();
const s1 = Season.create({
id: 's1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'L1 S1',
status: 'planned',
});
const s2 = Season.create({
id: 's2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'L1 S2',
status: 'active',
});
const s3 = Season.create({
id: 's3',
leagueId: 'league-2',
gameId: 'iracing',
name: 'L2 S1',
status: 'planned',
});
await repo.add(s1);
await repo.add(s2);
await repo.add(s3);
const league1Seasons = await repo.listByLeague('league-1');
const league2Seasons = await repo.listByLeague('league-2');
expect(league1Seasons.map((s) => s.id).sort()).toEqual(['s1', 's2']);
expect(league2Seasons.map((s) => s.id)).toEqual(['s3']);
});
it('listActiveByLeague returns only active seasons for a league', async () => {
const repo = new InMemorySeasonRepository();
const s1 = Season.create({
id: 's1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Planned',
status: 'planned',
});
const s2 = Season.create({
id: 's2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Active',
status: 'active',
});
const s3 = Season.create({
id: 's3',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Completed',
status: 'completed',
});
const s4 = Season.create({
id: 's4',
leagueId: 'league-2',
gameId: 'iracing',
name: 'Other League Active',
status: 'active',
});
await repo.add(s1);
await repo.add(s2);
await repo.add(s3);
await repo.add(s4);
const activeInLeague1 = await repo.listActiveByLeague('league-1');
expect(activeInLeague1.map((s) => s.id)).toEqual(['s2']);
});
});
describe('CreateSeasonForLeagueUseCase', () => {
it('creates a planned Season for an existing league with config-derived props', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const config = createLeagueConfigFormModel({
basics: {
name: 'League With Config',
visibility: 'ranked',
gameId: 'iracing',
},
scoring: {
patternId: 'club-default',
customScoringEnabled: true,
},
dropPolicy: {
strategy: 'dropWorstN',
n: 2,
},
// Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation,
// focusing this test on scoring/drop/stewarding/maxDrivers mapping.
timings: {
qualifyingMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 8,
},
});
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
name: 'Season from Config',
gameId: 'iracing',
config,
};
const result = await useCase.execute(command);
expect(result.seasonId).toBeDefined();
const created = await seasonRepo.findById(result.seasonId);
expect(created).not.toBeNull();
const season = created!;
expect(season.leagueId).toBe('league-1');
expect(season.gameId).toBe('iracing');
expect(season.name).toBe('Season from Config');
expect(season.status).toBe('planned');
// Schedule is optional when timings lack seasonStartDate / raceStartTime.
expect(season.schedule).toBeUndefined();
expect(season.scoringConfig).toBeDefined();
expect(season.scoringConfig!.scoringPresetId).toBe('club-default');
expect(season.scoringConfig!.customScoringEnabled).toBe(true);
expect(season.dropPolicy).toBeDefined();
expect(season.dropPolicy!.strategy).toBe('dropWorstN');
expect(season.dropPolicy!.n).toBe(2);
expect(season.stewardingConfig).toBeDefined();
expect(season.maxDrivers).toBe(30);
});
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const sourceSeason = Season.create({
id: 'source-season',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Source Season',
status: 'planned',
}).withMaxDrivers(40);
await seasonRepo.add(sourceSeason);
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
name: 'Cloned Season',
gameId: 'iracing',
sourceSeasonId: 'source-season',
};
const result = await useCase.execute(command);
const created = await seasonRepo.findById(result.seasonId);
expect(created).not.toBeNull();
const season = created!;
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.maxDrivers).toBe(sourceSeason.maxDrivers);
expect(season.schedule).toBe(sourceSeason.schedule);
expect(season.scoringConfig).toBe(sourceSeason.scoringConfig);
expect(season.dropPolicy).toBe(sourceSeason.dropPolicy);
expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig);
});
});
describe('ListSeasonsForLeagueUseCase', () => {
it('lists seasons for a league with summaries', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const s1 = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Season One',
status: 'planned',
});
const s2 = Season.create({
id: 'season-2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Season Two',
status: 'active',
});
const sOtherLeague = Season.create({
id: 'season-3',
leagueId: 'league-2',
gameId: 'iracing',
name: 'Season Other',
status: 'planned',
});
await seasonRepo.add(s1);
await seasonRepo.add(s2);
await seasonRepo.add(sOtherLeague);
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.items.map((i) => i.seasonId).sort()).toEqual([
'season-1',
'season-2',
]);
expect(result.items.every((i) => i.leagueId === 'league-1')).toBe(true);
});
});
describe('GetSeasonDetailsUseCase', () => {
it('returns full details for a season belonging to the league', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Detailed Season',
status: 'planned',
}).withMaxDrivers(24);
await seasonRepo.add(season);
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo);
const dto = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
});
expect(dto.seasonId).toBe('season-1');
expect(dto.leagueId).toBe('league-1');
expect(dto.gameId).toBe('iracing');
expect(dto.name).toBe('Detailed Season');
expect(dto.status).toBe('planned');
expect(dto.maxDrivers).toBe(24);
});
});
describe('ManageSeasonLifecycleUseCase', () => {
function setupLifecycleTest() {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Lifecycle Season',
status: 'planned',
});
seasonRepo.seed(season);
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo);
return { leagueRepo, seasonRepo, useCase, season };
}
it('applies activate → complete → archive transitions and persists state', async () => {
const { useCase, seasonRepo, season } = setupLifecycleTest();
const activateCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'activate',
};
const activated = await useCase.execute(activateCommand);
expect(activated.status).toBe('active');
const completeCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'complete',
};
const completed = await useCase.execute(completeCommand);
expect(completed.status).toBe('completed');
const archiveCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'archive',
};
const archived = await useCase.execute(archiveCommand);
expect(archived.status).toBe('archived');
const persisted = await seasonRepo.findById(season.id);
expect(persisted!.status).toBe('archived');
});
it('propagates domain invariant errors for invalid transitions', async () => {
const { useCase, seasonRepo, season } = setupLifecycleTest();
const completeCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'complete',
};
await expect(useCase.execute(completeCommand)).rejects.toThrow();
const persisted = await seasonRepo.findById(season.id);
expect(persisted!.status).toBe('planned');
});
});

View File

@@ -0,0 +1,44 @@
import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase';
import { UpdateLeagueMemberRolePresenter } from '@apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter';
describe('UpdateLeagueMemberRoleUseCase', () => {
let useCase: UpdateLeagueMemberRoleUseCase;
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
let presenter: UpdateLeagueMemberRolePresenter;
beforeEach(() => {
leagueMembershipRepository = {
getLeagueMembers: jest.fn(),
saveMembership: jest.fn(),
} as unknown;
presenter = new UpdateLeagueMemberRolePresenter();
useCase = new UpdateLeagueMemberRoleUseCase(leagueMembershipRepository);
});
it('should update league member role', async () => {
const leagueId = 'league-1';
const targetDriverId = 'driver-1';
const newRole = 'admin';
const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }];
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
await useCase.execute({ leagueId, targetDriverId, newRole }, presenter);
expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({
leagueId,
driverId: targetDriverId,
role: 'admin',
status: 'active',
joinedAt: expect.any(Date),
});
expect(presenter.viewModel).toEqual({ success: true });
});
it('should throw error if membership not found', async () => {
leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]);
await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1', newRole: 'admin' }, presenter)).rejects.toThrow('Membership not found');
});
});