refactor to adapters

This commit is contained in:
2025-12-15 18:34:20 +01:00
parent fc671482c8
commit c817d76092
145 changed files with 906 additions and 361 deletions

View File

@@ -0,0 +1,653 @@
import { describe, it, expect } from 'vitest';
import { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { LeagueMembership } from '@gridpilot/racing/domain/entities/LeagueMembership';
import type { FeedItem } from '@gridpilot/social/domain/types/FeedItem';
import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
DashboardFeedItemSummaryViewModel,
} from '@gridpilot/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

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { JoinLeagueUseCase } from '@gridpilot/racing/application/use-cases/JoinLeagueUseCase';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import {
LeagueMembership,
type MembershipRole,
type MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
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 '@gridpilot/racing/domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '@gridpilot/racing/domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { DriverRatingProvider } from '@gridpilot/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@gridpilot/racing/application/ports/IImageServicePort';
import type {
IRaceDetailPresenter,
RaceDetailViewModel,
} from '@gridpilot/racing/application/presenters/IRaceDetailPresenter';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { LeagueMembership } from '@gridpilot/racing/domain/entities/LeagueMembership';
import { GetRaceDetailUseCase } from '@gridpilot/racing/application/use-cases/GetRaceDetailUseCase';
import { CancelRaceUseCase } from '@gridpilot/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 '@gridpilot/racing/domain/entities/Race';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Penalty } from '@gridpilot/racing/domain/entities/Penalty';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { GetRaceResultsDetailUseCase } from '@gridpilot/racing/application/use-cases/GetRaceResultsDetailUseCase';
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
import type {
IRaceResultsDetailPresenter,
RaceResultsDetailViewModel,
} from '@gridpilot/racing/application/presenters/IRaceResultsDetailPresenter';
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '@gridpilot/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,868 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { IRaceRegistrationRepository } from '@gridpilot/racing/domain/repositories/IRaceRegistrationRepository';
import type { ILeagueMembershipRepository } from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
import type { RaceRegistration } from '@gridpilot/racing/domain/entities/RaceRegistration';
import {
LeagueMembership,
type MembershipStatus,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
import { Team } from '@gridpilot/racing/domain/entities/Team';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '@gridpilot/racing/domain/types/TeamMembership';
import { RegisterForRaceUseCase } from '@gridpilot/racing/application/use-cases/RegisterForRaceUseCase';
import { WithdrawFromRaceUseCase } from '@gridpilot/racing/application/use-cases/WithdrawFromRaceUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@gridpilot/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { GetRaceRegistrationsUseCase } from '@gridpilot/racing/application/use-cases/GetRaceRegistrationsUseCase';
import { CreateTeamUseCase } from '@gridpilot/racing/application/use-cases/CreateTeamUseCase';
import { JoinTeamUseCase } from '@gridpilot/racing/application/use-cases/JoinTeamUseCase';
import { LeaveTeamUseCase } from '@gridpilot/racing/application/use-cases/LeaveTeamUseCase';
import { ApproveTeamJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/ApproveTeamJoinRequestUseCase';
import { RejectTeamJoinRequestUseCase } from '@gridpilot/racing/application/use-cases/RejectTeamJoinRequestUseCase';
import { UpdateTeamUseCase } from '@gridpilot/racing/application/use-cases/UpdateTeamUseCase';
import { GetAllTeamsUseCase } from '@gridpilot/racing/application/use-cases/GetAllTeamsUseCase';
import { GetTeamDetailsUseCase } from '@gridpilot/racing/application/use-cases/GetTeamDetailsUseCase';
import { GetTeamMembersUseCase } from '@gridpilot/racing/application/use-cases/GetTeamMembersUseCase';
import { GetTeamJoinRequestsUseCase } from '@gridpilot/racing/application/use-cases/GetTeamJoinRequestsUseCase';
import { GetDriverTeamUseCase } from '@gridpilot/racing/application/use-cases/GetDriverTeamUseCase';
import type { IDriverRegistrationStatusPresenter } from '@gridpilot/racing/application/presenters/IDriverRegistrationStatusPresenter';
import type { IRaceRegistrationsPresenter } from '@gridpilot/racing/application/presenters/IRaceRegistrationsPresenter';
import type {
IAllTeamsPresenter,
AllTeamsResultDTO,
AllTeamsViewModel,
} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter';
import type { ITeamDetailsPresenter } from '@gridpilot/racing/application/presenters/ITeamDetailsPresenter';
import type {
ITeamMembersPresenter,
TeamMembersResultDTO,
TeamMembersViewModel,
} from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestsResultDTO,
TeamJoinRequestsViewModel,
} from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter';
import type {
IDriverTeamPresenter,
DriverTeamResultDTO,
DriverTeamViewModel,
} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
import type { RaceRegistrationsResultDTO } from '@gridpilot/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 any).specialization,
region: (team as any).region,
languages: (team as any).languages,
})),
totalCount: input.teams.length,
};
}
getViewModel(): AllTeamsViewModel | null {
return this.viewModel;
}
get teams(): any[] {
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 any) === 'owner' ? 'owner' : (membership.role as any) === 'member' ? 'member' : (membership.role as any) === 'manager' ? 'manager' : (membership.role as any) === '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 any) === 'member').length;
this.viewModel = {
members,
totalCount: members.length,
ownerCount,
managerCount,
memberCount,
};
}
getViewModel(): TeamMembersViewModel | null {
return this.viewModel;
}
get members(): any[] {
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(): any[] {
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,449 @@
import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@gridpilot/racing/domain/entities/Season';
import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import {
CreateSeasonForLeagueUseCase,
ListSeasonsForLeagueUseCase,
GetSeasonDetailsUseCase,
ManageSeasonLifecycleUseCase,
type CreateSeasonForLeagueCommand,
type ManageSeasonLifecycleCommand,
} from '@gridpilot/racing/application/use-cases/SeasonUseCases';
import type { LeagueConfigFormModel } from '@gridpilot/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');
});
});