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