core tests
This commit is contained in:
320
core/dashboard/application/use-cases/GetDashboardUseCase.test.ts
Normal file
320
core/dashboard/application/use-cases/GetDashboardUseCase.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
242
core/leagues/application/use-cases/JoinLeagueUseCase.test.ts
Normal file
242
core/leagues/application/use-cases/JoinLeagueUseCase.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
354
core/rating/application/use-cases/CalculateRatingUseCase.test.ts
Normal file
354
core/rating/application/use-cases/CalculateRatingUseCase.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
48
core/rating/application/use-cases/SaveRatingUseCase.test.ts
Normal file
48
core/rating/application/use-cases/SaveRatingUseCase.test.ts
Normal file
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
69
core/rating/domain/Rating.test.ts
Normal file
69
core/rating/domain/Rating.test.ts
Normal file
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
229
plans/testing-gaps-core.md
Normal file
229
plans/testing-gaps-core.md
Normal file
@@ -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.
|
||||||
|
|
||||||
Reference in New Issue
Block a user