Files
gridpilot.gg/tests/unit/racing-application/RaceDetailUseCases.test.ts
2025-12-11 14:39:57 +01:00

618 lines
17 KiB
TypeScript

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<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);
}
}
class InMemoryDriverRepository implements IDriverRepository {
private drivers = new Map<string, any>();
constructor(drivers: Array<{ id: string; name: string; country: string }>) {
for (const driver of drivers) {
this.drivers.set(driver.id, {
...driver,
} as any);
}
}
async findById(id: string): Promise<any | null> {
return this.drivers.get(id) ?? null;
}
async findAll(): Promise<any[]> {
return [...this.drivers.values()];
}
async findByIds(ids: string[]): Promise<any[]> {
return ids
.map(id => this.drivers.get(id))
.filter((d): d is any => !!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;
}
}
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;
}
}
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');
});
});