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 type { LeagueMembership, MembershipStatus, } from '@gridpilot/racing/domain/entities/LeagueMembership'; import type { Team, TeamMembership, TeamMembershipStatus, TeamRole, TeamJoinRequest, } from '@gridpilot/racing/domain/entities/Team'; import { RegisterForRaceUseCase } from '@gridpilot/racing/application/use-cases/RegisterForRaceUseCase'; import { WithdrawFromRaceUseCase } from '@gridpilot/racing/application/use-cases/WithdrawFromRaceUseCase'; import { IsDriverRegisteredForRaceQuery } from '@gridpilot/racing/application/use-cases/IsDriverRegisteredForRaceQuery'; import { GetRaceRegistrationsQuery } from '@gridpilot/racing/application/use-cases/GetRaceRegistrationsQuery'; 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 { GetAllTeamsQuery } from '@gridpilot/racing/application/use-cases/GetAllTeamsQuery'; import { GetTeamDetailsQuery } from '@gridpilot/racing/application/use-cases/GetTeamDetailsQuery'; import { GetTeamMembersQuery } from '@gridpilot/racing/application/use-cases/GetTeamMembersQuery'; import { GetTeamJoinRequestsQuery } from '@gridpilot/racing/application/use-cases/GetTeamJoinRequestsQuery'; import { GetDriverTeamQuery } from '@gridpilot/racing/application/use-cases/GetDriverTeamQuery'; /** * 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.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({ leagueId, driverId, role: 'member', status: 'active' as MembershipStatus, joinedAt: new Date('2024-01-01'), }); } } 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 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]; } } describe('Racing application use-cases - registrations', () => { let registrationRepo: InMemoryRaceRegistrationRepository; let membershipRepo: InMemoryLeagueMembershipRepositoryForRegistrations; let registerForRace: RegisterForRaceUseCase; let withdrawFromRace: WithdrawFromRaceUseCase; let isDriverRegistered: IsDriverRegisteredForRaceQuery; let getRaceRegistrations: GetRaceRegistrationsQuery; beforeEach(() => { registrationRepo = new InMemoryRaceRegistrationRepository(); membershipRepo = new InMemoryLeagueMembershipRepositoryForRegistrations(); registerForRace = new RegisterForRaceUseCase(registrationRepo, membershipRepo); withdrawFromRace = new WithdrawFromRaceUseCase(registrationRepo); isDriverRegistered = new IsDriverRegisteredForRaceQuery(registrationRepo); getRaceRegistrations = new GetRaceRegistrationsQuery(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 }); expect(await isDriverRegistered.execute({ raceId, driverId })).toBe(true); const registeredDrivers = await getRaceRegistrations.execute({ raceId }); expect(registeredDrivers).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 }); expect(await isDriverRegistered.execute({ raceId, driverId })).toBe(false); expect(await getRaceRegistrations.execute({ raceId })).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 getAllTeamsQuery: GetAllTeamsQuery; let getTeamDetailsQuery: GetTeamDetailsQuery; let getTeamMembersQuery: GetTeamMembersQuery; let getTeamJoinRequestsQuery: GetTeamJoinRequestsQuery; let getDriverTeamQuery: GetDriverTeamQuery; 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); getAllTeamsQuery = new GetAllTeamsQuery(teamRepo); getTeamDetailsQuery = new GetTeamDetailsQuery(teamRepo, membershipRepo); getTeamMembersQuery = new GetTeamMembersQuery(membershipRepo); getTeamJoinRequestsQuery = new GetTeamJoinRequestsQuery(membershipRepo); getDriverTeamQuery = new GetDriverTeamQuery(teamRepo, membershipRepo); }); 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, }); const teamDetails = await getTeamDetailsQuery.execute({ teamId: created.team.id, driverId: ownerId, }); expect(teamDetails.team.name).toBe('Updated Name'); expect(teamDetails.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: [], }); const result = await getDriverTeamQuery.execute({ driverId: ownerId }); expect(result).not.toBeNull(); expect(result?.team.id).toBe(team.id); expect(result?.membership.driverId).toBe(ownerId); }); 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 }); const teams = await getAllTeamsQuery.execute(); expect(teams.length).toBe(1); const members = await getTeamMembersQuery.execute({ teamId: team.id }); const memberIds = members.map((m) => m.driverId).sort(); expect(memberIds).toEqual([ownerId, otherDriverId].sort()); }); });