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 { LeagueMembership } from '@gridpilot/racing/domain/entities/LeagueMembership'; 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 { 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(); constructor(races: Race[]) { for (const race of races) { this.races.set(race.id, race); } } async findById(id: string): Promise { return this.races.get(id) ?? null; } async findAll(): Promise { return [...this.races.values()]; } async findByLeagueId(): Promise { return []; } async findUpcomingByLeagueId(): Promise { return []; } async findCompletedByLeagueId(): Promise { return []; } async findByStatus(): Promise { return []; } async findByDateRange(): Promise { return []; } async create(race: Race): Promise { this.races.set(race.id, race); return race; } async update(race: Race): Promise { this.races.set(race.id, race); return race; } async delete(id: string): Promise { this.races.delete(id); } async exists(id: string): Promise { return this.races.has(id); } getStored(id: string): Race | null { return this.races.get(id) ?? null; } } class InMemoryLeagueRepository implements ILeagueRepository { private leagues = new Map(); constructor(leagues: League[]) { for (const league of leagues) { this.leagues.set(league.id, league); } } async findById(id: string): Promise { return this.leagues.get(id) ?? null; } async findAll(): Promise { return [...this.leagues.values()]; } async findByOwnerId(): Promise { return []; } async create(league: League): Promise { this.leagues.set(league.id, league); return league; } async update(league: League): Promise { this.leagues.set(league.id, league); return league; } async delete(id: string): Promise { this.leagues.delete(id); } async exists(id: string): Promise { return this.leagues.has(id); } } class InMemoryDriverRepository implements IDriverRepository { private drivers = new Map(); constructor(drivers: Array<{ id: string; name: string; country: string }>) { for (const driver of drivers) { this.drivers.set(driver.id, { ...driver, }); } } async findById(id: string): Promise<{ id: string; name: string; country: string } | null> { return this.drivers.get(id) ?? null; } async findAll(): Promise> { return [...this.drivers.values()]; } async findByIds(ids: string[]): Promise> { return ids .map(id => this.drivers.get(id)) .filter((d): d is { id: string; name: string; country: string } => !!d); } async create(): Promise { throw new Error('Not needed for these tests'); } async update(): Promise { throw new Error('Not needed for these tests'); } async delete(): Promise { throw new Error('Not needed for these tests'); } async exists(): Promise { return false; } } class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository { private registrations = new Map>(); 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 { return this.registrations.get(raceId)?.has(driverId) ?? false; } async getRegisteredDrivers(raceId: string): Promise { return Array.from(this.registrations.get(raceId) ?? []); } async getRegistrationCount(raceId: string): Promise { return this.registrations.get(raceId)?.size ?? 0; } async register(registration: { raceId: string; driverId: string }): Promise { 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 { this.registrations.get(raceId)?.delete(driverId); } async getDriverRegistrations(): Promise { return []; } async clearRaceRegistrations(): Promise { return; } } class InMemoryResultRepository implements IResultRepository { private results = new Map(); 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 { return this.results.get(raceId) ?? []; } async findById(): Promise { return null; } async findAll(): Promise { return []; } async findByDriverId(): Promise { return []; } async findByDriverIdAndLeagueId(): Promise { return []; } async create(result: Result): Promise { const list = this.results.get(result.raceId) ?? []; list.push(result); this.results.set(result.raceId, list); return result; } async createMany(results: Result[]): Promise { for (const result of results) { await this.create(result); } return results; } async update(): Promise { throw new Error('Not needed for these tests'); } async delete(): Promise { throw new Error('Not needed for these tests'); } async deleteByRaceId(): Promise { throw new Error('Not needed for these tests'); } async exists(): Promise { return false; } async existsByRaceId(): Promise { return false; } } class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository { private memberships: LeagueMembership[] = []; seedMembership(membership: LeagueMembership): void { this.memberships.push(membership); } async getMembership(leagueId: string, driverId: string): Promise { return ( this.memberships.find( m => m.leagueId === leagueId && m.driverId === driverId, ) ?? null ); } async getLeagueMembers(): Promise { return []; } async getJoinRequests(): Promise { throw new Error('Not needed for these tests'); } async saveMembership(membership: LeagueMembership): Promise { this.memberships.push(membership); return membership; } async removeMembership(): Promise { return; } async saveJoinRequest(): Promise { throw new Error('Not needed for these tests'); } async removeJoinRequest(): Promise { throw new Error('Not needed for these tests'); } } class TestDriverRatingProvider implements DriverRatingProvider { private ratings = new Map(); 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 { const map = new Map(); 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; } } 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({ 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, presenter, ); // When (execute the query for the current driver) await useCase.execute({ raceId: race.id, driverId }); 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({ 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, presenter, ); // When (executing the query for the completed race) await useCase.execute({ raceId: race.id, driverId }); 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, presenter, ); // When await useCase.execute({ raceId: 'missing-race', driverId: 'driver-x' }); 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'); }); });