refactor to adapters
This commit is contained in:
653
tests/racing-application/DashboardOverviewUseCase.spec.ts
Normal file
653
tests/racing-application/DashboardOverviewUseCase.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
125
tests/racing-application/MembershipUseCases.spec.ts
Normal file
125
tests/racing-application/MembershipUseCases.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
636
tests/racing-application/RaceDetailUseCases.spec.ts
Normal file
636
tests/racing-application/RaceDetailUseCases.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
716
tests/racing-application/RaceResultsUseCases.spec.ts
Normal file
716
tests/racing-application/RaceResultsUseCases.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
868
tests/racing-application/RegistrationAndTeamUseCases.spec.ts
Normal file
868
tests/racing-application/RegistrationAndTeamUseCases.spec.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
449
tests/racing-application/SeasonUseCases.spec.ts
Normal file
449
tests/racing-application/SeasonUseCases.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user