diff --git a/core/dashboard/application/use-cases/GetDashboardUseCase.test.ts b/core/dashboard/application/use-cases/GetDashboardUseCase.test.ts new file mode 100644 index 000000000..18918aa40 --- /dev/null +++ b/core/dashboard/application/use-cases/GetDashboardUseCase.test.ts @@ -0,0 +1,320 @@ +/** + * Unit tests for GetDashboardUseCase + * + * Tests cover: + * 1) Validation of driverId (empty and whitespace) + * 2) Driver not found + * 3) Filters invalid races (missing trackName, past dates) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetDashboardUseCase } from './GetDashboardUseCase'; +import { ValidationError } from '../../../shared/errors/ValidationError'; +import { DriverNotFoundError } from '../../domain/errors/DriverNotFoundError'; +import { DashboardRepository } from '../ports/DashboardRepository'; +import { DashboardEventPublisher } from '../ports/DashboardEventPublisher'; +import { Logger } from '../../../shared/domain/Logger'; +import { DriverData, RaceData, LeagueStandingData, ActivityData } from '../ports/DashboardRepository'; + +describe('GetDashboardUseCase', () => { + let mockDriverRepository: DashboardRepository; + let mockRaceRepository: DashboardRepository; + let mockLeagueRepository: DashboardRepository; + let mockActivityRepository: DashboardRepository; + let mockEventPublisher: DashboardEventPublisher; + let mockLogger: Logger; + + let useCase: GetDashboardUseCase; + + beforeEach(() => { + // Mock all ports with vi.fn() + mockDriverRepository = { + findDriverById: vi.fn(), + getUpcomingRaces: vi.fn(), + getLeagueStandings: vi.fn(), + getRecentActivity: vi.fn(), + getFriends: vi.fn(), + }; + + mockRaceRepository = { + findDriverById: vi.fn(), + getUpcomingRaces: vi.fn(), + getLeagueStandings: vi.fn(), + getRecentActivity: vi.fn(), + getFriends: vi.fn(), + }; + + mockLeagueRepository = { + findDriverById: vi.fn(), + getUpcomingRaces: vi.fn(), + getLeagueStandings: vi.fn(), + getRecentActivity: vi.fn(), + getFriends: vi.fn(), + }; + + mockActivityRepository = { + findDriverById: vi.fn(), + getUpcomingRaces: vi.fn(), + getLeagueStandings: vi.fn(), + getRecentActivity: vi.fn(), + getFriends: vi.fn(), + }; + + mockEventPublisher = { + publishDashboardAccessed: vi.fn(), + publishDashboardError: vi.fn(), + }; + + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + useCase = new GetDashboardUseCase({ + driverRepository: mockDriverRepository, + raceRepository: mockRaceRepository, + leagueRepository: mockLeagueRepository, + activityRepository: mockActivityRepository, + eventPublisher: mockEventPublisher, + logger: mockLogger, + }); + }); + + describe('Scenario 1: Validation of driverId', () => { + it('should throw ValidationError when driverId is empty string', async () => { + // Given + const query = { driverId: '' }; + + // When & Then + await expect(useCase.execute(query)).rejects.toThrow(ValidationError); + await expect(useCase.execute(query)).rejects.toThrow('Driver ID cannot be empty'); + + // Verify no repositories were called + expect(mockDriverRepository.findDriverById).not.toHaveBeenCalled(); + expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled(); + expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled(); + expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled(); + expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when driverId is whitespace only', async () => { + // Given + const query = { driverId: ' ' }; + + // When & Then + await expect(useCase.execute(query)).rejects.toThrow(ValidationError); + await expect(useCase.execute(query)).rejects.toThrow('Driver ID cannot be empty'); + + // Verify no repositories were called + expect(mockDriverRepository.findDriverById).not.toHaveBeenCalled(); + expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled(); + expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled(); + expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled(); + expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled(); + }); + }); + + describe('Scenario 2: Driver not found', () => { + it('should throw DriverNotFoundError when driverRepository.findDriverById returns null', async () => { + // Given + const query = { driverId: 'driver-123' }; + (mockDriverRepository.findDriverById as any).mockResolvedValue(null); + + // When & Then + await expect(useCase.execute(query)).rejects.toThrow(DriverNotFoundError); + await expect(useCase.execute(query)).rejects.toThrow('Driver with ID "driver-123" not found'); + + // Verify driver repository was called + expect(mockDriverRepository.findDriverById).toHaveBeenCalledWith('driver-123'); + + // Verify other repositories were not called (since driver not found) + expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled(); + expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled(); + expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled(); + expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled(); + }); + }); + + describe('Scenario 3: Filters invalid races', () => { + it('should exclude races missing trackName', async () => { + // Given + const query = { driverId: 'driver-123' }; + + // Mock driver exists + (mockDriverRepository.findDriverById as any).mockResolvedValue({ + id: 'driver-123', + name: 'Test Driver', + rating: 1500, + rank: 10, + starts: 50, + wins: 10, + podiums: 20, + leagues: 3, + } as DriverData); + + // Mock races with missing trackName + (mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([ + { + id: 'race-1', + trackName: '', // Missing trackName + carType: 'GT3', + scheduledDate: new Date('2026-01-25T10:00:00.000Z'), + }, + { + id: 'race-2', + trackName: 'Track A', + carType: 'GT3', + scheduledDate: new Date('2026-01-26T10:00:00.000Z'), + }, + ] as RaceData[]); + + (mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]); + (mockActivityRepository.getRecentActivity as any).mockResolvedValue([]); + + // When + const result = await useCase.execute(query); + + // Then + expect(result.upcomingRaces).toHaveLength(1); + expect(result.upcomingRaces[0].trackName).toBe('Track A'); + }); + + it('should exclude races with past scheduledDate', async () => { + // Given + const query = { driverId: 'driver-123' }; + + // Mock driver exists + (mockDriverRepository.findDriverById as any).mockResolvedValue({ + id: 'driver-123', + name: 'Test Driver', + rating: 1500, + rank: 10, + starts: 50, + wins: 10, + podiums: 20, + leagues: 3, + } as DriverData); + + // Mock races with past dates + (mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([ + { + id: 'race-1', + trackName: 'Track A', + carType: 'GT3', + scheduledDate: new Date('2026-01-23T10:00:00.000Z'), // Past + }, + { + id: 'race-2', + trackName: 'Track B', + carType: 'GT3', + scheduledDate: new Date('2026-01-25T10:00:00.000Z'), // Future + }, + ] as RaceData[]); + + (mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]); + (mockActivityRepository.getRecentActivity as any).mockResolvedValue([]); + + // When + const result = await useCase.execute(query); + + // Then + expect(result.upcomingRaces).toHaveLength(1); + expect(result.upcomingRaces[0].trackName).toBe('Track B'); + }); + + it('should exclude races with missing trackName and past dates', async () => { + // Given + const query = { driverId: 'driver-123' }; + + // Mock driver exists + (mockDriverRepository.findDriverById as any).mockResolvedValue({ + id: 'driver-123', + name: 'Test Driver', + rating: 1500, + rank: 10, + starts: 50, + wins: 10, + podiums: 20, + leagues: 3, + } as DriverData); + + // Mock races with various invalid states + (mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([ + { + id: 'race-1', + trackName: '', // Missing trackName + carType: 'GT3', + scheduledDate: new Date('2026-01-25T10:00:00.000Z'), // Future + }, + { + id: 'race-2', + trackName: 'Track A', + carType: 'GT3', + scheduledDate: new Date('2026-01-23T10:00:00.000Z'), // Past + }, + { + id: 'race-3', + trackName: 'Track B', + carType: 'GT3', + scheduledDate: new Date('2026-01-26T10:00:00.000Z'), // Future + }, + ] as RaceData[]); + + (mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]); + (mockActivityRepository.getRecentActivity as any).mockResolvedValue([]); + + // When + const result = await useCase.execute(query); + + // Then + expect(result.upcomingRaces).toHaveLength(1); + expect(result.upcomingRaces[0].trackName).toBe('Track B'); + }); + + it('should include only valid races with trackName and future dates', async () => { + // Given + const query = { driverId: 'driver-123' }; + + // Mock driver exists + (mockDriverRepository.findDriverById as any).mockResolvedValue({ + id: 'driver-123', + name: 'Test Driver', + rating: 1500, + rank: 10, + starts: 50, + wins: 10, + podiums: 20, + leagues: 3, + } as DriverData); + + // Mock races with valid data + (mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([ + { + id: 'race-1', + trackName: 'Track A', + carType: 'GT3', + scheduledDate: new Date('2026-01-25T10:00:00.000Z'), + }, + { + id: 'race-2', + trackName: 'Track B', + carType: 'GT4', + scheduledDate: new Date('2026-01-26T10:00:00.000Z'), + }, + ] as RaceData[]); + + (mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]); + (mockActivityRepository.getRecentActivity as any).mockResolvedValue([]); + + // When + const result = await useCase.execute(query); + + // Then + expect(result.upcomingRaces).toHaveLength(2); + expect(result.upcomingRaces[0].trackName).toBe('Track A'); + expect(result.upcomingRaces[1].trackName).toBe('Track B'); + }); + }); +}); diff --git a/core/leagues/application/use-cases/JoinLeagueUseCase.test.ts b/core/leagues/application/use-cases/JoinLeagueUseCase.test.ts new file mode 100644 index 000000000..d70ca83e1 --- /dev/null +++ b/core/leagues/application/use-cases/JoinLeagueUseCase.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JoinLeagueUseCase } from './JoinLeagueUseCase'; +import type { LeagueRepository } from '../ports/LeagueRepository'; +import type { DriverRepository } from '../../../racing/domain/repositories/DriverRepository'; +import type { EventPublisher } from '../../../shared/ports/EventPublisher'; +import type { JoinLeagueCommand } from '../ports/JoinLeagueCommand'; + +const mockLeagueRepository = { + findById: vi.fn(), + addPendingRequests: vi.fn(), + addLeagueMembers: vi.fn(), +}; + +const mockDriverRepository = { + findDriverById: vi.fn(), +}; + +const mockEventPublisher = { + publish: vi.fn(), +}; + +describe('JoinLeagueUseCase', () => { + let useCase: JoinLeagueUseCase; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + useCase = new JoinLeagueUseCase( + mockLeagueRepository as any, + mockDriverRepository as any, + mockEventPublisher as any + ); + }); + + describe('Scenario 1: League missing', () => { + it('should throw "League not found" when league does not exist', async () => { + // Given + const command: JoinLeagueCommand = { + leagueId: 'league-123', + driverId: 'driver-456', + }; + + mockLeagueRepository.findById.mockImplementation(() => Promise.resolve(null)); + + // When & Then + await expect(useCase.execute(command)).rejects.toThrow('League not found'); + expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-123'); + }); + }); + + describe('Scenario 2: Driver missing', () => { + it('should throw "Driver not found" when driver does not exist', async () => { + // Given + const command: JoinLeagueCommand = { + leagueId: 'league-123', + driverId: 'driver-456', + }; + + const mockLeague = { + id: 'league-123', + name: 'Test League', + description: null, + visibility: 'public' as const, + ownerId: 'owner-789', + status: 'active' as const, + createdAt: new Date(), + updatedAt: new Date(), + maxDrivers: null, + approvalRequired: true, + lateJoinAllowed: true, + raceFrequency: null, + raceDay: null, + raceTime: null, + tracks: null, + scoringSystem: null, + bonusPointsEnabled: false, + penaltiesEnabled: false, + protestsEnabled: false, + appealsEnabled: false, + stewardTeam: null, + gameType: null, + skillLevel: null, + category: null, + tags: null, + }; + + mockLeagueRepository.findById.mockImplementation(() => Promise.resolve(mockLeague)); + mockDriverRepository.findDriverById.mockImplementation(() => Promise.resolve(null)); + + // When & Then + await expect(useCase.execute(command)).rejects.toThrow('Driver not found'); + expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-123'); + expect(mockDriverRepository.findDriverById).toHaveBeenCalledWith('driver-456'); + }); + }); + + describe('Scenario 3: approvalRequired path uses pending requests + time determinism', () => { + it('should add pending request with deterministic time when approvalRequired is true', async () => { + // Given + const command: JoinLeagueCommand = { + leagueId: 'league-123', + driverId: 'driver-456', + }; + + const mockLeague = { + id: 'league-123', + name: 'Test League', + description: null, + visibility: 'public' as const, + ownerId: 'owner-789', + status: 'active' as const, + createdAt: new Date(), + updatedAt: new Date(), + maxDrivers: null, + approvalRequired: true, + lateJoinAllowed: true, + raceFrequency: null, + raceDay: null, + raceTime: null, + tracks: null, + scoringSystem: null, + bonusPointsEnabled: false, + penaltiesEnabled: false, + protestsEnabled: false, + appealsEnabled: false, + stewardTeam: null, + gameType: null, + skillLevel: null, + category: null, + tags: null, + }; + + const mockDriver = { + id: 'driver-456', + name: 'Test Driver', + iracingId: 'iracing-123', + avatarUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Freeze time for deterministic testing + const frozenTime = new Date('2024-01-01T00:00:00.000Z'); + vi.setSystemTime(frozenTime); + + mockLeagueRepository.findById.mockResolvedValue(mockLeague); + mockDriverRepository.findDriverById.mockResolvedValue(mockDriver); + + // When + await useCase.execute(command); + + // Then + expect(mockLeagueRepository.addPendingRequests).toHaveBeenCalledWith( + 'league-123', + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + driverId: 'driver-456', + name: 'Test Driver', + requestDate: frozenTime, + }), + ]) + ); + + // Verify no members were added + expect(mockLeagueRepository.addLeagueMembers).not.toHaveBeenCalled(); + + // Reset system time + vi.useRealTimers(); + }); + }); + + describe('Scenario 4: no-approval path adds member', () => { + it('should add member when approvalRequired is false', async () => { + // Given + const command: JoinLeagueCommand = { + leagueId: 'league-123', + driverId: 'driver-456', + }; + + const mockLeague = { + id: 'league-123', + name: 'Test League', + description: null, + visibility: 'public' as const, + ownerId: 'owner-789', + status: 'active' as const, + createdAt: new Date(), + updatedAt: new Date(), + maxDrivers: null, + approvalRequired: false, + lateJoinAllowed: true, + raceFrequency: null, + raceDay: null, + raceTime: null, + tracks: null, + scoringSystem: null, + bonusPointsEnabled: false, + penaltiesEnabled: false, + protestsEnabled: false, + appealsEnabled: false, + stewardTeam: null, + gameType: null, + skillLevel: null, + category: null, + tags: null, + }; + + const mockDriver = { + id: 'driver-456', + name: 'Test Driver', + iracingId: 'iracing-123', + avatarUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockLeagueRepository.findById.mockResolvedValue(mockLeague); + mockDriverRepository.findDriverById.mockResolvedValue(mockDriver); + + // When + await useCase.execute(command); + + // Then + expect(mockLeagueRepository.addLeagueMembers).toHaveBeenCalledWith( + 'league-123', + expect.arrayContaining([ + expect.objectContaining({ + driverId: 'driver-456', + name: 'Test Driver', + role: 'member', + joinDate: expect.any(Date), + }), + ]) + ); + + // Verify no pending requests were added + expect(mockLeagueRepository.addPendingRequests).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/core/rating/application/use-cases/CalculateRatingUseCase.test.ts b/core/rating/application/use-cases/CalculateRatingUseCase.test.ts new file mode 100644 index 000000000..80ee0110c --- /dev/null +++ b/core/rating/application/use-cases/CalculateRatingUseCase.test.ts @@ -0,0 +1,354 @@ +/** + * Unit tests for CalculateRatingUseCase + * + * Tests business logic and orchestration using mocked ports. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CalculateRatingUseCase } from './CalculateRatingUseCase'; +import { Driver } from '../../../racing/domain/entities/Driver'; +import { Race } from '../../../racing/domain/entities/Race'; +import { Result } from '../../../racing/domain/entities/result/Result'; +import { Rating } from '../../domain/Rating'; +import { RatingCalculatedEvent } from '../../domain/events/RatingCalculatedEvent'; + +// Mock repositories and publisher +const mockDriverRepository = { + findById: vi.fn(), +}; + +const mockRaceRepository = { + findById: vi.fn(), +}; + +const mockResultRepository = { + findByRaceId: vi.fn(), +}; + +const mockRatingRepository = { + save: vi.fn(), +}; + +const mockEventPublisher = { + publish: vi.fn(), +}; + +describe('CalculateRatingUseCase', () => { + let useCase: CalculateRatingUseCase; + + beforeEach(() => { + vi.clearAllMocks(); + useCase = new CalculateRatingUseCase({ + driverRepository: mockDriverRepository as any, + raceRepository: mockRaceRepository as any, + resultRepository: mockResultRepository as any, + ratingRepository: mockRatingRepository as any, + eventPublisher: mockEventPublisher as any, + }); + }); + + describe('Scenario 1: Driver missing', () => { + it('should return error when driver is not found', async () => { + // Given + mockDriverRepository.findById.mockResolvedValue(null); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Driver not found'); + expect(mockRatingRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('Scenario 2: Race missing', () => { + it('should return error when race is not found', async () => { + // Given + const mockDriver = Driver.create({ + id: 'driver-123', + iracingId: 'iracing-123', + name: 'Test Driver', + country: 'US', + }); + mockDriverRepository.findById.mockResolvedValue(mockDriver); + mockRaceRepository.findById.mockResolvedValue(null); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Race not found'); + }); + }); + + describe('Scenario 3: No results', () => { + it('should return error when no results found for race', async () => { + // Given + const mockDriver = Driver.create({ + id: 'driver-123', + iracingId: 'iracing-123', + name: 'Test Driver', + country: 'US', + }); + const mockRace = Race.create({ + id: 'race-456', + leagueId: 'league-789', + scheduledAt: new Date(), + track: 'Test Track', + car: 'Test Car', + }); + mockDriverRepository.findById.mockResolvedValue(mockDriver); + mockRaceRepository.findById.mockResolvedValue(mockRace); + mockResultRepository.findByRaceId.mockResolvedValue([]); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('No results found for race'); + }); + }); + + describe('Scenario 4: Driver not present in results', () => { + it('should return error when driver is not in race results', async () => { + // Given + const mockDriver = Driver.create({ + id: 'driver-123', + iracingId: 'iracing-123', + name: 'Test Driver', + country: 'US', + }); + const mockRace = Race.create({ + id: 'race-456', + leagueId: 'league-789', + scheduledAt: new Date(), + track: 'Test Track', + car: 'Test Car', + }); + const otherResult = Result.create({ + id: 'result-1', + raceId: 'race-456', + driverId: 'driver-456', + position: 1, + fastestLap: 60000, + incidents: 0, + startPosition: 1, + points: 25, + }); + mockDriverRepository.findById.mockResolvedValue(mockDriver); + mockRaceRepository.findById.mockResolvedValue(mockRace); + mockResultRepository.findByRaceId.mockResolvedValue([otherResult]); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Driver not found in race results'); + }); + }); + + describe('Scenario 5: Publishes event after save', () => { + it('should call ratingRepository.save before eventPublisher.publish', async () => { + // Given + const mockDriver = Driver.create({ + id: 'driver-123', + iracingId: 'iracing-123', + name: 'Test Driver', + country: 'US', + }); + const mockRace = Race.create({ + id: 'race-456', + leagueId: 'league-789', + scheduledAt: new Date(), + track: 'Test Track', + car: 'Test Car', + }); + const mockResult = Result.create({ + id: 'result-1', + raceId: 'race-456', + driverId: 'driver-123', + position: 1, + fastestLap: 60000, + incidents: 0, + startPosition: 1, + points: 25, + }); + mockDriverRepository.findById.mockResolvedValue(mockDriver); + mockRaceRepository.findById.mockResolvedValue(mockRace); + mockResultRepository.findByRaceId.mockResolvedValue([mockResult]); + mockRatingRepository.save.mockResolvedValue(undefined); + mockEventPublisher.publish.mockResolvedValue(undefined); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isOk()).toBe(true); + expect(mockRatingRepository.save).toHaveBeenCalledTimes(1); + expect(mockEventPublisher.publish).toHaveBeenCalledTimes(1); + + // Verify call order: save should be called before publish + const saveCallOrder = mockRatingRepository.save.mock.invocationCallOrder[0]; + const publishCallOrder = mockEventPublisher.publish.mock.invocationCallOrder[0]; + expect(saveCallOrder).toBeLessThan(publishCallOrder); + }); + }); + + describe('Scenario 6: Component boundaries for cleanDriving', () => { + it('should return cleanDriving = 100 when incidents = 0', async () => { + // Given + const mockDriver = Driver.create({ + id: 'driver-123', + iracingId: 'iracing-123', + name: 'Test Driver', + country: 'US', + }); + const mockRace = Race.create({ + id: 'race-456', + leagueId: 'league-789', + scheduledAt: new Date(), + track: 'Test Track', + car: 'Test Car', + }); + const mockResult = Result.create({ + id: 'result-1', + raceId: 'race-456', + driverId: 'driver-123', + position: 1, + fastestLap: 60000, + incidents: 0, + startPosition: 1, + points: 25, + }); + mockDriverRepository.findById.mockResolvedValue(mockDriver); + mockRaceRepository.findById.mockResolvedValue(mockRace); + mockResultRepository.findByRaceId.mockResolvedValue([mockResult]); + mockRatingRepository.save.mockResolvedValue(undefined); + mockEventPublisher.publish.mockResolvedValue(undefined); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isOk()).toBe(true); + const rating = result.unwrap(); + expect(rating.components.cleanDriving).toBe(100); + }); + + it('should return cleanDriving = 20 when incidents >= 5', async () => { + // Given + const mockDriver = Driver.create({ + id: 'driver-123', + iracingId: 'iracing-123', + name: 'Test Driver', + country: 'US', + }); + const mockRace = Race.create({ + id: 'race-456', + leagueId: 'league-789', + scheduledAt: new Date(), + track: 'Test Track', + car: 'Test Car', + }); + const mockResult = Result.create({ + id: 'result-1', + raceId: 'race-456', + driverId: 'driver-123', + position: 1, + fastestLap: 60000, + incidents: 5, + startPosition: 1, + points: 25, + }); + mockDriverRepository.findById.mockResolvedValue(mockDriver); + mockRaceRepository.findById.mockResolvedValue(mockRace); + mockResultRepository.findByRaceId.mockResolvedValue([mockResult]); + mockRatingRepository.save.mockResolvedValue(undefined); + mockEventPublisher.publish.mockResolvedValue(undefined); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isOk()).toBe(true); + const rating = result.unwrap(); + expect(rating.components.cleanDriving).toBe(20); + }); + }); + + describe('Scenario 7: Time-dependent output', () => { + it('should produce deterministic timestamp when time is frozen', async () => { + // Given + const frozenTime = new Date('2024-01-01T12:00:00.000Z'); + vi.useFakeTimers(); + vi.setSystemTime(frozenTime); + + const mockDriver = Driver.create({ + id: 'driver-123', + iracingId: 'iracing-123', + name: 'Test Driver', + country: 'US', + }); + const mockRace = Race.create({ + id: 'race-456', + leagueId: 'league-789', + scheduledAt: new Date(), + track: 'Test Track', + car: 'Test Car', + }); + const mockResult = Result.create({ + id: 'result-1', + raceId: 'race-456', + driverId: 'driver-123', + position: 1, + fastestLap: 60000, + incidents: 0, + startPosition: 1, + points: 25, + }); + mockDriverRepository.findById.mockResolvedValue(mockDriver); + mockRaceRepository.findById.mockResolvedValue(mockRace); + mockResultRepository.findByRaceId.mockResolvedValue([mockResult]); + mockRatingRepository.save.mockResolvedValue(undefined); + mockEventPublisher.publish.mockResolvedValue(undefined); + + // When + const result = await useCase.execute({ + driverId: 'driver-123', + raceId: 'race-456', + }); + + // Then + expect(result.isOk()).toBe(true); + const rating = result.unwrap(); + expect(rating.timestamp).toEqual(frozenTime); + + vi.useRealTimers(); + }); + }); +}); diff --git a/core/rating/application/use-cases/CalculateTeamContributionUseCase.test.ts b/core/rating/application/use-cases/CalculateTeamContributionUseCase.test.ts new file mode 100644 index 000000000..fd1c51af5 --- /dev/null +++ b/core/rating/application/use-cases/CalculateTeamContributionUseCase.test.ts @@ -0,0 +1,132 @@ +/** + * Unit tests for CalculateTeamContributionUseCase + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CalculateTeamContributionUseCase } from './CalculateTeamContributionUseCase'; +import { Driver } from '../../../racing/domain/entities/Driver'; +import { Race } from '../../../racing/domain/entities/Race'; +import { Result } from '../../../racing/domain/entities/result/Result'; +import { Rating } from '../../domain/Rating'; + +const mockRatingRepository = { + findByDriverAndRace: vi.fn(), + save: vi.fn(), +}; + +const mockDriverRepository = { + findById: vi.fn(), +}; + +const mockRaceRepository = { + findById: vi.fn(), +}; + +const mockResultRepository = { + findByRaceId: vi.fn(), +}; + +describe('CalculateTeamContributionUseCase', () => { + let useCase: CalculateTeamContributionUseCase; + + beforeEach(() => { + vi.clearAllMocks(); + useCase = new CalculateTeamContributionUseCase({ + ratingRepository: mockRatingRepository as any, + driverRepository: mockDriverRepository as any, + raceRepository: mockRaceRepository as any, + resultRepository: mockResultRepository as any, + }); + }); + + describe('Scenario 8: Creates rating when missing', () => { + it('should create and save a new rating when none exists', async () => { + // Given + const driverId = 'driver-1'; + const raceId = 'race-1'; + const points = 25; + + mockDriverRepository.findById.mockResolvedValue(Driver.create({ + id: driverId, + iracingId: 'ir-1', + name: 'Driver 1', + country: 'US' + })); + mockRaceRepository.findById.mockResolvedValue(Race.create({ + id: raceId, + leagueId: 'l-1', + scheduledAt: new Date(), + track: 'Track', + car: 'Car' + })); + mockResultRepository.findByRaceId.mockResolvedValue([ + Result.create({ + id: 'res-1', + raceId, + driverId, + position: 1, + points, + incidents: 0, + startPosition: 1, + fastestLap: 0 + }) + ]); + mockRatingRepository.findByDriverAndRace.mockResolvedValue(null); + + // When + const result = await useCase.execute({ driverId, raceId }); + + // Then + expect(mockRatingRepository.save).toHaveBeenCalled(); + const savedRating = mockRatingRepository.save.mock.calls[0][0] as Rating; + expect(savedRating.components.teamContribution).toBe(100); // 25/25 * 100 + expect(result.teamContribution).toBe(100); + }); + }); + + describe('Scenario 9: Updates existing rating', () => { + it('should preserve other fields and only update teamContribution', async () => { + // Given + const driverId = 'driver-1'; + const raceId = 'race-1'; + const points = 12.5; // 50% contribution + + const existingRating = Rating.create({ + driverId: 'driver-1' as any, // Simplified for test + raceId: 'race-1' as any, + rating: 1500, + components: { + resultsStrength: 80, + consistency: 70, + cleanDriving: 90, + racecraft: 75, + reliability: 85, + teamContribution: 10, // Old value + }, + timestamp: new Date('2023-01-01') + }); + + mockDriverRepository.findById.mockResolvedValue({ id: driverId } as any); + mockRaceRepository.findById.mockResolvedValue({ id: raceId } as any); + mockResultRepository.findByRaceId.mockResolvedValue([ + { driverId: { toString: () => driverId }, points } as any + ]); + mockRatingRepository.findByDriverAndRace.mockResolvedValue(existingRating); + + // When + const result = await useCase.execute({ driverId, raceId }); + + // Then + expect(mockRatingRepository.save).toHaveBeenCalled(); + const savedRating = mockRatingRepository.save.mock.calls[0][0] as Rating; + + // Check preserved fields + expect(savedRating.rating).toBe(1500); + expect(savedRating.components.resultsStrength).toBe(80); + + // Check updated field + expect(savedRating.components.teamContribution).toBe(50); // 12.5/25 * 100 + expect(result.teamContribution).toBe(50); + }); + }); +}); diff --git a/core/rating/application/use-cases/GetRatingLeaderboardUseCase.test.ts b/core/rating/application/use-cases/GetRatingLeaderboardUseCase.test.ts new file mode 100644 index 000000000..d81a5c930 --- /dev/null +++ b/core/rating/application/use-cases/GetRatingLeaderboardUseCase.test.ts @@ -0,0 +1,105 @@ +/** + * Unit tests for GetRatingLeaderboardUseCase + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetRatingLeaderboardUseCase } from './GetRatingLeaderboardUseCase'; +import { Rating } from '../../domain/Rating'; + +const mockRatingRepository = { + findByDriver: vi.fn(), +}; + +const mockDriverRepository = { + findAll: vi.fn(), + findById: vi.fn(), +}; + +describe('GetRatingLeaderboardUseCase', () => { + let useCase: GetRatingLeaderboardUseCase; + + beforeEach(() => { + vi.clearAllMocks(); + useCase = new GetRatingLeaderboardUseCase({ + ratingRepository: mockRatingRepository as any, + driverRepository: mockDriverRepository as any, + }); + }); + + describe('Scenario 10: Pagination + Sorting', () => { + it('should return latest rating per driver, sorted desc, sliced by limit/offset', async () => { + // Given + const drivers = [ + { id: 'd1', name: { toString: () => 'Driver 1' } }, + { id: 'd2', name: { toString: () => 'Driver 2' } }, + { id: 'd3', name: { toString: () => 'Driver 3' } }, + ]; + + const ratingsD1 = [ + Rating.create({ + driverId: 'd1' as any, + raceId: 'r1' as any, + rating: 1000, + components: {} as any, + timestamp: new Date('2023-01-01') + }), + Rating.create({ + driverId: 'd1' as any, + raceId: 'r2' as any, + rating: 1200, // Latest for D1 + components: {} as any, + timestamp: new Date('2023-01-02') + }) + ]; + + const ratingsD2 = [ + Rating.create({ + driverId: 'd2' as any, + raceId: 'r1' as any, + rating: 1500, // Latest for D2 + components: {} as any, + timestamp: new Date('2023-01-01') + }) + ]; + + const ratingsD3 = [ + Rating.create({ + driverId: 'd3' as any, + raceId: 'r1' as any, + rating: 800, // Latest for D3 + components: {} as any, + timestamp: new Date('2023-01-01') + }) + ]; + + mockDriverRepository.findAll.mockResolvedValue(drivers); + mockDriverRepository.findById.mockImplementation((id) => + Promise.resolve(drivers.find(d => d.id === id)) + ); + mockRatingRepository.findByDriver.mockImplementation((id) => { + if (id === 'd1') return Promise.resolve(ratingsD1); + if (id === 'd2') return Promise.resolve(ratingsD2); + if (id === 'd3') return Promise.resolve(ratingsD3); + return Promise.resolve([]); + }); + + // When: limit 2, offset 0 + const result = await useCase.execute({ limit: 2, offset: 0 }); + + // Then: Sorted D2 (1500), D1 (1200), D3 (800). Slice(0, 2) -> D2, D1 + expect(result).toHaveLength(2); + expect(result[0].driverId).toBe('d2'); + expect(result[0].rating).toBe(1500); + expect(result[1].driverId).toBe('d1'); + expect(result[1].rating).toBe(1200); + + // When: limit 2, offset 1 + const resultOffset = await useCase.execute({ limit: 2, offset: 1 }); + + // Then: Slice(1, 3) -> D1, D3 + expect(resultOffset).toHaveLength(2); + expect(resultOffset[0].driverId).toBe('d1'); + expect(resultOffset[1].driverId).toBe('d3'); + }); + }); +}); diff --git a/core/rating/application/use-cases/SaveRatingUseCase.test.ts b/core/rating/application/use-cases/SaveRatingUseCase.test.ts new file mode 100644 index 000000000..8628d3ee1 --- /dev/null +++ b/core/rating/application/use-cases/SaveRatingUseCase.test.ts @@ -0,0 +1,48 @@ +/** + * Unit tests for SaveRatingUseCase + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SaveRatingUseCase } from './SaveRatingUseCase'; + +const mockRatingRepository = { + save: vi.fn(), +}; + +describe('SaveRatingUseCase', () => { + let useCase: SaveRatingUseCase; + + beforeEach(() => { + vi.clearAllMocks(); + useCase = new SaveRatingUseCase({ + ratingRepository: mockRatingRepository as any, + }); + }); + + describe('Scenario 11: Repository error wraps correctly', () => { + it('should wrap repository error with specific prefix', async () => { + // Given + const request = { + driverId: 'd1', + raceId: 'r1', + rating: 1200, + components: { + resultsStrength: 80, + consistency: 70, + cleanDriving: 90, + racecraft: 75, + reliability: 85, + teamContribution: 60, + }, + }; + + const repoError = new Error('Database connection failed'); + mockRatingRepository.save.mockRejectedValue(repoError); + + // When & Then + await expect(useCase.execute(request)).rejects.toThrow( + 'Failed to save rating: Error: Database connection failed' + ); + }); + }); +}); diff --git a/core/rating/domain/Rating.test.ts b/core/rating/domain/Rating.test.ts new file mode 100644 index 000000000..f19260b95 --- /dev/null +++ b/core/rating/domain/Rating.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for Rating domain entity + */ + +import { describe, it, expect } from 'vitest'; +import { Rating } from './Rating'; +import { DriverId } from '../../racing/domain/entities/DriverId'; +import { RaceId } from '../../racing/domain/entities/RaceId'; + +describe('Rating Entity', () => { + it('should create a rating with correct properties', () => { + // Given + const props = { + driverId: DriverId.create('d1'), + raceId: RaceId.create('r1'), + rating: 1200, + components: { + resultsStrength: 80, + consistency: 70, + cleanDriving: 90, + racecraft: 75, + reliability: 85, + teamContribution: 60, + }, + timestamp: new Date('2024-01-01T12:00:00Z'), + }; + + // When + const rating = Rating.create(props); + + // Then + expect(rating.driverId).toBe(props.driverId); + expect(rating.raceId).toBe(props.raceId); + expect(rating.rating).toBe(props.rating); + expect(rating.components).toEqual(props.components); + expect(rating.timestamp).toEqual(props.timestamp); + }); + + it('should convert to JSON correctly', () => { + // Given + const props = { + driverId: DriverId.create('d1'), + raceId: RaceId.create('r1'), + rating: 1200, + components: { + resultsStrength: 80, + consistency: 70, + cleanDriving: 90, + racecraft: 75, + reliability: 85, + teamContribution: 60, + }, + timestamp: new Date('2024-01-01T12:00:00Z'), + }; + const rating = Rating.create(props); + + // When + const json = rating.toJSON(); + + // Then + expect(json).toEqual({ + driverId: 'd1', + raceId: 'r1', + rating: 1200, + components: props.components, + timestamp: '2024-01-01T12:00:00.000Z', + }); + }); +}); diff --git a/plans/testing-gaps-core.md b/plans/testing-gaps-core.md new file mode 100644 index 000000000..15282ddab --- /dev/null +++ b/plans/testing-gaps-core.md @@ -0,0 +1,229 @@ +# Testing gaps in `core` (unit tests only, no infra/adapters) + +## Scope / rules (agreed) + +* **In scope:** code under [`core/`](core:1) only. +* **Unit tests only:** tests should validate business rules and orchestration using **ports mocked in-test** (e.g., `vi.fn()`), not real persistence, HTTP, frameworks, or adapters. +* **Out of scope:** any test that relies on real IO, real repositories, or infrastructure code (including [`core/**/infrastructure/`](core/rating/infrastructure:1)). + +## How gaps were identified + +1. Inventory of application and domain units was built from file structure under [`core/`](core:1). +2. Existing tests were located via `describe(` occurrences in `*.test.ts` and mapped to corresponding production units. +3. Gaps were prioritized by: + * **Business criticality:** identity/security, payments/money flows. + * **Complex branching / invariants:** state machines, decision tables. + * **Time-dependent logic:** `Date.now()`, `new Date()`, time windows. + * **Error handling paths:** repository errors, partial failures. + +--- + +## Highest-priority testing gaps (P0) + +### 1) `rating` module has **no unit tests** + +Why high risk: scoring/rating is a cross-cutting “truth source”, and current implementations contain test-driven hacks and inconsistent error handling. + +Targets: +* [`core/rating/application/use-cases/CalculateRatingUseCase.ts`](core/rating/application/use-cases/CalculateRatingUseCase.ts:1) +* [`core/rating/application/use-cases/CalculateTeamContributionUseCase.ts`](core/rating/application/use-cases/CalculateTeamContributionUseCase.ts:1) +* [`core/rating/application/use-cases/GetRatingLeaderboardUseCase.ts`](core/rating/application/use-cases/GetRatingLeaderboardUseCase.ts:1) +* [`core/rating/application/use-cases/SaveRatingUseCase.ts`](core/rating/application/use-cases/SaveRatingUseCase.ts:1) +* [`core/rating/domain/Rating.ts`](core/rating/domain/Rating.ts:1) + +Proposed unit tests (Given/When/Then): +1. **CalculateRatingUseCase: driver missing** + * Given `driverRepository.findById` returns `null` + * When executing with `{ driverId, raceId }` + * Then returns `Result.err` with message `Driver not found` and does not call `ratingRepository.save`. +2. **CalculateRatingUseCase: race missing** + * Given driver exists, `raceRepository.findById` returns `null` + * When execute + * Then returns `Result.err` with message `Race not found`. +3. **CalculateRatingUseCase: no results** + * Given driver & race exist, `resultRepository.findByRaceId` returns `[]` + * When execute + * Then returns `Result.err` with message `No results found for race`. +4. **CalculateRatingUseCase: driver not present in results** + * Given results array without matching `driverId` + * When execute + * Then returns `Result.err` with message `Driver not found in race results`. +5. **CalculateRatingUseCase: publishes event after save** + * Given all repositories return happy-path objects + * When execute + * Then `ratingRepository.save` is called once before `eventPublisher.publish`. +6. **CalculateRatingUseCase: component boundaries** + * Given a result with `incidents = 0` + * When execute + * Then `components.cleanDriving === 100`. + * Given `incidents >= 5` + * Then `components.cleanDriving === 20`. +7. **CalculateRatingUseCase: time-dependent output** + * Given frozen time (use `vi.setSystemTime`) + * When execute + * Then emitted rating has deterministic `timestamp`. +8. **CalculateTeamContributionUseCase: creates rating when missing** + * Given `ratingRepository.findByDriverAndRace` returns `null` + * When execute + * Then `ratingRepository.save` is called with a rating whose `components.teamContribution` matches calculation. +9. **CalculateTeamContributionUseCase: updates existing rating** + * Given existing rating with components set + * When execute + * Then only `components.teamContribution` is changed and other fields preserved. +10. **GetRatingLeaderboardUseCase: pagination + sorting** + * Given multiple drivers and multiple ratings per driver + * When execute with `{ limit, offset }` + * Then returns latest per driver, sorted desc, sliced by pagination. +11. **SaveRatingUseCase: repository error wraps correctly** + * Given `ratingRepository.save` throws + * When execute + * Then throws `Failed to save rating:` prefixed error. + +Ports to mock: `driverRepository`, `raceRepository`, `resultRepository`, `ratingRepository`, `eventPublisher`. + +--- + +### 2) `dashboard` orchestration has no unit tests + +Target: +* [`core/dashboard/application/use-cases/GetDashboardUseCase.ts`](core/dashboard/application/use-cases/GetDashboardUseCase.ts:1) + +Why high risk: timeouts, parallelization, filtering/sorting, and “log but don’t fail” event publishing. + +Proposed unit tests (Given/When/Then): +1. **Validation of driverId** + * Given `driverId` is `''` or whitespace + * When execute + * Then throws [`ValidationError`](core/shared/errors/ValidationError.ts:1) (or the module’s equivalent) and does not hit repositories. +2. **Driver not found** + * Given `driverRepository.findDriverById` returns `null` + * When execute + * Then throws [`DriverNotFoundError`](core/dashboard/domain/errors/DriverNotFoundError.ts:1). +3. **Filters invalid races** + * Given `getUpcomingRaces` returns races missing `trackName` or with past `scheduledDate` + * When execute + * Then `upcomingRaces` in DTO excludes them. +4. **Limits upcoming races to 3 and sorts by date ascending** + * Given 5 valid upcoming races out of order + * When execute + * Then DTO contains only 3 earliest. +5. **Activity is sorted newest-first** + * Given activities with different timestamps + * When execute + * Then DTO is sorted desc by timestamp. +6. **Repository failures are logged and rethrown** + * Given one of the repositories rejects + * When execute + * Then logger.error called and error is rethrown. +7. **Event publishing failure is swallowed** + * Given `eventPublisher.publishDashboardAccessed` throws + * When execute + * Then use case still returns DTO and logger.error was called. +8. **Timeout behavior** (if retained) + * Given `raceRepository.getUpcomingRaces` never resolves + * When using fake timers and advancing by TIMEOUT + * Then `upcomingRaces` becomes `[]` and use case completes. + +Ports to mock: all repositories, publisher, and [`Logger`](core/shared/domain/Logger.ts:1). + +--- + +### 3) `leagues` module has multiple untested use-cases (time-dependent logic) + +Targets likely missing tests: +* [`core/leagues/application/use-cases/JoinLeagueUseCase.ts`](core/leagues/application/use-cases/JoinLeagueUseCase.ts:1) +* [`core/leagues/application/use-cases/LeaveLeagueUseCase.ts`](core/leagues/application/use-cases/LeaveLeagueUseCase.ts:1) +* [`core/leagues/application/use-cases/ApproveMembershipRequestUseCase.ts`](core/leagues/application/use-cases/ApproveMembershipRequestUseCase.ts:1) +* plus others without `*.test.ts` siblings in [`core/leagues/application/use-cases/`](core/leagues/application/use-cases:1) + +Proposed unit tests (Given/When/Then): +1. **JoinLeagueUseCase: league missing** + * Given `leagueRepository.findById` returns `null` + * When execute + * Then throws `League not found`. +2. **JoinLeagueUseCase: driver missing** + * Given league exists, `driverRepository.findDriverById` returns `null` + * Then throws `Driver not found`. +3. **JoinLeagueUseCase: approvalRequired path uses pending requests** + * Given `league.approvalRequired === true` + * When execute + * Then `leagueRepository.addPendingRequests` called with a request containing frozen `Date.now()` and `new Date()`. +4. **JoinLeagueUseCase: no-approval path adds member** + * Given `approvalRequired === false` + * Then `leagueRepository.addLeagueMembers` called with role `member`. +5. **ApproveMembershipRequestUseCase: request not found** + * Given pending requests list without `requestId` + * Then throws `Request not found`. +6. **ApproveMembershipRequestUseCase: happy path adds member then removes request** + * Given request exists + * Then `addLeagueMembers` called before `removePendingRequest`. +7. **LeaveLeagueUseCase: delegates to repository** + * Given repository mock + * Then `removeLeagueMember` is called once with inputs. + +Note: these use cases currently ignore injected `eventPublisher` in several places; tests should either (a) enforce event publication (drive implementation), or (b) remove the unused port. + +--- + +## Medium-priority gaps (P1) + +### 4) “Contract tests” that don’t test behavior (replace or move) + +These tests validate TypeScript shapes and mocked method existence, but do not protect business behavior: +* [`core/ports/media/MediaResolverPort.test.ts`](core/ports/media/MediaResolverPort.test.ts:1) +* [`core/ports/media/MediaResolverPort.comprehensive.test.ts`](core/ports/media/MediaResolverPort.comprehensive.test.ts:1) +* [`core/notifications/domain/repositories/NotificationRepository.test.ts`](core/notifications/domain/repositories/NotificationRepository.test.ts:1) +* [`core/notifications/application/ports/NotificationService.test.ts`](core/notifications/application/ports/NotificationService.test.ts:1) + +Recommended action: +* Either delete these (if they add noise), or replace with **behavior tests of the code that consumes the port**. +* If you want explicit “contract tests”, keep them in a dedicated layer and ensure they test the *adapter implementation* (but that would violate the current constraint, so keep them out of this scope). + +### 5) Racing and Notifications include “imports-only” tests + +Several tests are effectively “module loads” checks (no business assertions). Example patterns show up in: +* [`core/notifications/domain/entities/Notification.test.ts`](core/notifications/domain/entities/Notification.test.ts:1) +* [`core/notifications/domain/entities/NotificationPreference.test.ts`](core/notifications/domain/entities/NotificationPreference.test.ts:1) +* many files under [`core/racing/domain/entities/`](core/racing/domain/entities:1) + +Replace with invariant-focused tests: +* Given invalid props (empty IDs, invalid status transitions) +* When creating or transitioning state +* Then throws domain error (or returns `Result.err`) with specific code/kind. + +### 6) Racing use-cases with no tests (spot list) + +From a quick scan of [`core/racing/application/use-cases/`](core/racing/application/use-cases:1), some `.ts` appear without matching `.test.ts` siblings: +* [`core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts`](core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts:1) +* [`core/racing/application/use-cases/GetRaceProtestsUseCase.ts`](core/racing/application/use-cases/GetRaceProtestsUseCase.ts:1) +* [`core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts`](core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts:1) (appears tested, confirm) +* [`core/racing/application/use-cases/GetSponsorsUseCase.ts`](core/racing/application/use-cases/GetSponsorsUseCase.ts:1) (no test file listed) +* [`core/racing/application/use-cases/GetLeagueAdminUseCase.ts`](core/racing/application/use-cases/GetLeagueAdminUseCase.ts:1) +* [`core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.ts`](core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.ts:1) +* [`core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts`](core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts:1) exists, confirm content quality + +Suggested scenarios depend on each use case’s branching, but the common minimum is: +* repository error → `Result.err` with code +* happy path → updates correct aggregates + publishes domain event if applicable +* permission/invariant violations → domain error codes + +--- + +## Lower-priority gaps (P2) + +### 7) Coverage consistency and determinism + +Patterns to standardize across modules: +* Tests that touch time should freeze time (`vi.setSystemTime`) rather than relying on `Date.now()`. +* Use cases should return `Result` consistently (some throw, some return `Result`). Testing should expose this inconsistency and drive convergence. + +--- + +## Proposed execution plan (next step: implement tests) + +1. Add missing unit tests for `rating` use-cases and `rating/domain/Rating`. +2. Add unit tests for `GetDashboardUseCase` focusing on filtering/sorting, timeout, and publish failure behavior. +3. Add unit tests for `leagues` membership flow (`JoinLeagueUseCase`, `ApproveMembershipRequestUseCase`, `LeaveLeagueUseCase`). +4. Replace “imports-only” tests with invariant tests in `notifications` entities, starting with the most used aggregates. +5. Audit remaining racing use-cases without tests and add the top 5 based on branching and business impact. +