import { describe, it, expect, beforeEach } from 'vitest'; import type { Logger } from '@core/shared/application'; import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository'; import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; import { Driver } from '@core/racing/domain/entities/Driver'; import type { TeamMembership, TeamMembershipStatus, TeamRole, TeamJoinRequest, } from '@core/racing/domain/types/TeamMembership'; import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/RegisterForRaceUseCase'; import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase'; import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase'; import { GetRaceRegistrationsUseCase } from '@core/racing/application/use-cases/GetRaceRegistrationsUseCase'; import type { IRaceRegistrationsPresenter } from '@core/racing/application/presenters/IRaceRegistrationsPresenter'; import type { GetAllTeamsOutputPort } from '@core/racing/application/ports/output/GetAllTeamsOutputPort'; import type { ITeamDetailsPresenter } from '@core/racing/application/presenters/ITeamDetailsPresenter'; import type { ITeamMembersPresenter, TeamMembersResultDTO, TeamMembersViewModel, } from '@core/racing/application/presenters/ITeamMembersPresenter'; import type { ITeamJoinRequestsPresenter, TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel, } from '@core/racing/application/presenters/ITeamJoinRequestsPresenter'; import type { IDriverTeamPresenter, DriverTeamResultDTO, DriverTeamViewModel, } from '@core/racing/application/presenters/IDriverTeamPresenter'; import type { RaceRegistrationsResultDTO } from '@core/racing/application/presenters/IRaceRegistrationsPresenter'; /** * Simple in-memory fakes mirroring current alpha behavior. */ class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository { private registrations = new Map>(); // raceId -> driverIds async isRegistered(raceId: string, driverId: string): Promise { const set = this.registrations.get(raceId); return set ? set.has(driverId) : false; } async getRegisteredDrivers(raceId: string): Promise { const set = this.registrations.get(raceId); return set ? Array.from(set) : []; } async getRegistrationCount(raceId: string): Promise { const set = this.registrations.get(raceId); return set ? set.size : 0; } async register(registration: RaceRegistration): 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 { 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 { 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 { this.registrations.delete(raceId); } } class InMemoryLeagueMembershipRepositoryForRegistrations implements ILeagueMembershipRepository { private memberships: LeagueMembership[] = []; async getMembership(leagueId: string, driverId: string): Promise { return ( this.memberships.find( (m) => m.leagueId === leagueId && m.leagueId === leagueId && m.driverId === driverId, ) || null ); } async getLeagueMembers(leagueId: string): Promise { return this.memberships.filter( (m) => m.leagueId === leagueId && m.status === 'active', ); } async getJoinRequests(): Promise { throw new Error('Not needed for registration tests'); } async saveMembership(membership: LeagueMembership): Promise { this.memberships.push(membership); return membership; } async removeMembership(): Promise { throw new Error('Not needed for registration tests'); } async saveJoinRequest(): Promise { throw new Error('Not needed for registration tests'); } async removeJoinRequest(): Promise { 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 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 { return this.teams.find((t) => t.id === id) || null; } async findAll(): Promise { return [...this.teams]; } async findByLeagueId(leagueId: string): Promise { return this.teams.filter((t) => t.leagues.includes(leagueId)); } async create(team: Team): Promise { this.teams.push(team); return team; } async update(team: Team): Promise { 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 { this.teams = this.teams.filter((t) => t.id !== id); } async exists(id: string): Promise { 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 { return ( this.memberships.find( (m) => m.teamId === teamId && m.driverId === driverId, ) || null ); } async getActiveMembershipForDriver(driverId: string): Promise { return ( this.memberships.find( (m) => m.driverId === driverId && m.status === 'active', ) || null ); } async getTeamMembers(teamId: string): Promise { return this.memberships.filter( (m) => m.teamId === teamId && m.status === 'active', ); } async findByTeamId(teamId: string): Promise { return this.memberships.filter((m) => m.teamId === teamId); } async saveMembership(membership: TeamMembership): Promise { 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 { this.memberships = this.memberships.filter( (m) => !(m.teamId === teamId && m.driverId === driverId), ); } async getJoinRequests(teamId: string): Promise { // 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 { 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 { 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 { 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 logger: Logger; let raceRegistrationsPresenter: TestRaceRegistrationsPresenter; beforeEach(() => { registrationRepo = new InMemoryRaceRegistrationRepository(); membershipRepo = new InMemoryLeagueMembershipRepositoryForRegistrations(); registerForRace = new RegisterForRaceUseCase(registrationRepo, membershipRepo); withdrawFromRace = new WithdrawFromRaceUseCase(registrationRepo); logger = { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }; isDriverRegistered = new IsDriverRegisteredForRaceUseCase( registrationRepo, logger, ); 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 }); const result = await isDriverRegistered.execute({ raceId, driverId }); expect(result.isOk()).toBe(true); const status = result.unwrap(); expect(status.isRegistered).toBe(true); expect(status.raceId).toBe(raceId); expect(status.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 }); const result = await isDriverRegistered.execute({ raceId, driverId }); expect(result.isOk()).toBe(true); const status = result.unwrap(); expect(status.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 { return Driver.create({ id: driverId, iracingId: '123', name: `Driver ${driverId}`, country: 'US' }); } async findByIRacingId(id: string): Promise { return null; } async findAll(): Promise { return []; } async create(driver: Driver): Promise { return driver; } async update(driver: Driver): Promise { return driver; } async delete(id: string): Promise { } async exists(id: string): Promise { return false; } async existsByIRacingId(iracingId: string): Promise { return false; } async findByLeagueId(leagueId: string): Promise { return []; } async findByTeamId(teamId: string): Promise { 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 unknown).specialization, region: (team as unknown).region, languages: (team as unknown).languages, })), totalCount: input.teams.length, }; } getViewModel(): AllTeamsViewModel | null { return this.viewModel; } get teams(): unknown[] { 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 unknown) === 'owner' ? 'owner' : (membership.role as unknown) === 'member' ? 'member' : (membership.role as unknown) === 'manager' ? 'manager' : (membership.role as unknown) === '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 unknown) === 'member').length; this.viewModel = { members, totalCount: members.length, ownerCount, managerCount, memberCount, }; } getViewModel(): TeamMembersViewModel | null { return this.viewModel; } get members(): unknown[] { 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(): unknown[] { 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()); }); });