diff --git a/apps/website/lib/apiClient.ts b/apps/website/lib/apiClient.ts new file mode 100644 index 000000000..7ee58ec54 --- /dev/null +++ b/apps/website/lib/apiClient.ts @@ -0,0 +1,5 @@ +import { ApiClient } from './api/index'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + +export const apiClient = new ApiClient(API_BASE_URL); \ No newline at end of file diff --git a/apps/website/lib/blockers/ThrottleBlocker.ts b/apps/website/lib/blockers/ThrottleBlocker.ts index befe62722..c1965e5f0 100644 --- a/apps/website/lib/blockers/ThrottleBlocker.ts +++ b/apps/website/lib/blockers/ThrottleBlocker.ts @@ -8,7 +8,9 @@ import { Blocker } from './Blocker'; export class ThrottleBlocker extends Blocker { private lastExecutionTime = 0; - constructor(private readonly delayMs: number) {} + constructor(private readonly delayMs: number) { + super(); + } canExecute(): boolean { return Date.now() - this.lastExecutionTime >= this.delayMs; diff --git a/apps/website/lib/services/dashboard/DashboardService.test.ts b/apps/website/lib/services/dashboard/DashboardService.test.ts new file mode 100644 index 000000000..cca28c1f3 --- /dev/null +++ b/apps/website/lib/services/dashboard/DashboardService.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { DashboardService } from './DashboardService'; +import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient'; +import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel'; + +describe('DashboardService', () => { + let mockApiClient: Mocked; + let service: DashboardService; + + beforeEach(() => { + mockApiClient = { + getDashboardOverview: vi.fn(), + } as Mocked; + + service = new DashboardService(mockApiClient); + }); + + describe('getDashboardOverview', () => { + it('should call apiClient.getDashboardOverview and return DashboardOverviewViewModel', async () => { + const mockDto = { + currentDriver: { + id: 'driver-123', + name: 'Test Driver', + avatarUrl: 'https://example.com/avatar.jpg', + country: 'US', + totalRaces: 42, + wins: 10, + podiums: 20, + rating: 1500, + globalRank: 5, + consistency: 85, + }, + myUpcomingRaces: [], + otherUpcomingRaces: [], + upcomingRaces: [], + activeLeaguesCount: 3, + nextRace: null, + recentResults: [], + leagueStandingsSummaries: [], + feedSummary: { feedItems: [] }, + friends: [], + }; + + mockApiClient.getDashboardOverview.mockResolvedValue(mockDto); + + const result = await service.getDashboardOverview(); + + expect(mockApiClient.getDashboardOverview).toHaveBeenCalled(); + expect(result).toBeInstanceOf(DashboardOverviewViewModel); + expect(result.activeLeaguesCount).toBe(3); + }); + + it('should throw error when apiClient.getDashboardOverview fails', async () => { + const error = new Error('API call failed'); + mockApiClient.getDashboardOverview.mockRejectedValue(error); + + await expect(service.getDashboardOverview()).rejects.toThrow('API call failed'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/landing/LandingService.test.ts b/apps/website/lib/services/landing/LandingService.test.ts new file mode 100644 index 000000000..ef088f88c --- /dev/null +++ b/apps/website/lib/services/landing/LandingService.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { LandingService } from './LandingService'; +import { RacesApiClient } from '../../api/races/RacesApiClient'; +import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient'; +import { TeamsApiClient } from '../../api/teams/TeamsApiClient'; +import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel'; + +describe('LandingService', () => { + let mockRacesApi: Mocked; + let mockLeaguesApi: Mocked; + let mockTeamsApi: Mocked; + let service: LandingService; + + beforeEach(() => { + mockRacesApi = { + getPageData: vi.fn(), + } as unknown as Mocked; + + mockLeaguesApi = { + getAllWithCapacity: vi.fn(), + } as unknown as Mocked; + + mockTeamsApi = { + getAll: vi.fn(), + } as unknown as Mocked; + + service = new LandingService(mockRacesApi, mockLeaguesApi, mockTeamsApi); + }); + + describe('getHomeDiscovery', () => { + it('should return home discovery data with top leagues, teams, and upcoming races', async () => { + const racesDto = { + races: [ + { + id: 'race-1', + track: 'Monza', + car: 'Ferrari', + scheduledAt: '2023-10-01T10:00:00Z', + status: 'upcoming', + leagueId: 'league-1', + leagueName: 'Test League', + strengthOfField: 1500, + isUpcoming: true, + isLive: false, + isPast: false, + }, + { + id: 'race-2', + track: 'Silverstone', + car: 'Mercedes', + scheduledAt: '2023-10-02T10:00:00Z', + status: 'upcoming', + leagueId: 'league-1', + leagueName: 'Test League', + strengthOfField: 1600, + isUpcoming: true, + isLive: false, + isPast: false, + }, + ], + }; + + const leaguesDto = { + leagues: [ + { id: 'league-1', name: 'League One' }, + { id: 'league-2', name: 'League Two' }, + { id: 'league-3', name: 'League Three' }, + { id: 'league-4', name: 'League Four' }, + { id: 'league-5', name: 'League Five' }, + ], + }; + + const teamsDto = { + teams: [ + { id: 'team-1', name: 'Team One', tag: 'T1', description: 'Best team' }, + { id: 'team-2', name: 'Team Two', tag: 'T2', description: 'Great team' }, + { id: 'team-3', name: 'Team Three', tag: 'T3', description: 'Awesome team' }, + { id: 'team-4', name: 'Team Four', tag: 'T4', description: 'Cool team' }, + { id: 'team-5', name: 'Team Five', tag: 'T5', description: 'Pro team' }, + ], + }; + + mockRacesApi.getPageData.mockResolvedValue(racesDto as any); + mockLeaguesApi.getAllWithCapacity.mockResolvedValue(leaguesDto as any); + mockTeamsApi.getAll.mockResolvedValue(teamsDto as any); + + const result = await service.getHomeDiscovery(); + + expect(result).toBeInstanceOf(HomeDiscoveryViewModel); + expect(result.topLeagues).toHaveLength(4); // First 4 leagues + expect(result.teams).toHaveLength(4); // First 4 teams + expect(result.upcomingRaces).toHaveLength(2); // All upcoming races (first 4) + }); + + it('should handle empty data', async () => { + mockRacesApi.getPageData.mockResolvedValue({ races: [] } as any); + mockLeaguesApi.getAllWithCapacity.mockResolvedValue({ leagues: [] } as any); + mockTeamsApi.getAll.mockResolvedValue({ teams: [] } as any); + + const result = await service.getHomeDiscovery(); + + expect(result.topLeagues).toHaveLength(0); + expect(result.teams).toHaveLength(0); + expect(result.upcomingRaces).toHaveLength(0); + }); + + it('should throw error when any API call fails', async () => { + mockRacesApi.getPageData.mockRejectedValue(new Error('Races API failed')); + + await expect(service.getHomeDiscovery()).rejects.toThrow('Races API failed'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueSettingsService.test.ts b/apps/website/lib/services/leagues/LeagueSettingsService.test.ts new file mode 100644 index 000000000..38bf0c3ff --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueSettingsService.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { LeagueSettingsService } from './LeagueSettingsService'; +import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient'; +import { DriversApiClient } from '../../api/drivers/DriversApiClient'; +import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; + +describe('LeagueSettingsService', () => { + let mockLeaguesApiClient: Mocked; + let mockDriversApiClient: Mocked; + let service: LeagueSettingsService; + + beforeEach(() => { + mockLeaguesApiClient = { + getAllWithCapacity: vi.fn(), + getLeagueConfig: vi.fn(), + getScoringPresets: vi.fn(), + getMemberships: vi.fn(), + transferOwnership: vi.fn(), + } as unknown as Mocked; + + mockDriversApiClient = { + getLeaderboard: vi.fn(), + getDriver: vi.fn(), + } as unknown as Mocked; + + service = new LeagueSettingsService(mockLeaguesApiClient, mockDriversApiClient); + }); + + describe('getLeagueSettings', () => { + it('should return league settings with all data', async () => { + const leagueId = 'league-123'; + + // Mock getAllWithCapacity + mockLeaguesApiClient.getAllWithCapacity.mockResolvedValue({ + leagues: [ + { id: leagueId, name: 'Test League', ownerId: 'owner-123', capacity: 20, currentMembers: 10 }, + ], + }); + + // Mock getLeagueConfig + mockLeaguesApiClient.getLeagueConfig.mockResolvedValue({ + config: { someConfig: 'value' }, + }); + + // Mock getScoringPresets + mockLeaguesApiClient.getScoringPresets.mockResolvedValue({ + presets: [{ id: 'preset-1', name: 'Preset 1' }], + }); + + // Mock getLeaderboard + mockDriversApiClient.getLeaderboard.mockResolvedValue({ + drivers: [ + { id: 'owner-123', name: 'Owner Driver', rating: 1500, rank: 10 }, + { id: 'member-1', name: 'Member 1', rating: 1400, rank: 20 }, + ], + }); + + // Mock getDriver for owner + mockDriversApiClient.getDriver.mockResolvedValue({ + id: 'owner-123', + name: 'Owner Driver', + avatarUrl: 'https://example.com/avatar.jpg', + country: 'US', + }); + + // Mock getMemberships + mockLeaguesApiClient.getMemberships.mockResolvedValue({ + members: [ + { driverId: 'member-1', role: 'member' }, + ], + }); + + const result = await service.getLeagueSettings(leagueId); + + expect(result).toBeInstanceOf(LeagueSettingsViewModel); + expect(result.league.id).toBe(leagueId); + expect(result.league.name).toBe('Test League'); + expect(result.league.ownerId).toBe('owner-123'); + expect(result.presets).toHaveLength(1); + expect(result.owner).toBeDefined(); + expect(result.members).toHaveLength(1); + }); + + it('should return null when league not found', async () => { + const leagueId = 'non-existent-league'; + + mockLeaguesApiClient.getAllWithCapacity.mockResolvedValue({ + leagues: [], + }); + + const result = await service.getLeagueSettings(leagueId); + + expect(result).toBeNull(); + }); + + it('should handle errors gracefully', async () => { + const leagueId = 'league-123'; + + mockLeaguesApiClient.getAllWithCapacity.mockRejectedValue(new Error('API error')); + + const result = await service.getLeagueSettings(leagueId); + + expect(result).toBeNull(); + }); + }); + + describe('transferOwnership', () => { + it('should call apiClient.transferOwnership and return success', async () => { + const leagueId = 'league-123'; + const currentOwnerId = 'owner-123'; + const newOwnerId = 'new-owner-456'; + + mockLeaguesApiClient.transferOwnership.mockResolvedValue({ success: true }); + + const result = await service.transferOwnership(leagueId, currentOwnerId, newOwnerId); + + expect(mockLeaguesApiClient.transferOwnership).toHaveBeenCalledWith(leagueId, currentOwnerId, newOwnerId); + expect(result).toBe(true); + }); + + it('should throw error when apiClient.transferOwnership fails', async () => { + const leagueId = 'league-123'; + const currentOwnerId = 'owner-123'; + const newOwnerId = 'new-owner-456'; + + const error = new Error('API call failed'); + mockLeaguesApiClient.transferOwnership.mockRejectedValue(error); + + await expect(service.transferOwnership(leagueId, currentOwnerId, newOwnerId)).rejects.toThrow('API call failed'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueStewardingService.test.ts b/apps/website/lib/services/leagues/LeagueStewardingService.test.ts new file mode 100644 index 000000000..a80393920 --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueStewardingService.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { LeagueStewardingService } from './LeagueStewardingService'; +import { RaceService } from '../races/RaceService'; +import { ProtestService } from '../protests/ProtestService'; +import { PenaltyService } from '../penalties/PenaltyService'; +import { DriverService } from '../drivers/DriverService'; +import { LeagueMembershipService } from './LeagueMembershipService'; +import { LeagueStewardingViewModel } from '../../view-models/LeagueStewardingViewModel'; + +describe('LeagueStewardingService', () => { + let mockRaceService: Mocked; + let mockProtestService: Mocked; + let mockPenaltyService: Mocked; + let mockDriverService: Mocked; + let mockLeagueMembershipService: Mocked; + let service: LeagueStewardingService; + + beforeEach(() => { + mockRaceService = { + findByLeagueId: vi.fn(), + } as Mocked; + + mockProtestService = { + findByRaceId: vi.fn(), + } as Mocked; + + mockPenaltyService = { + findByRaceId: vi.fn(), + } as Mocked; + + mockDriverService = { + findByIds: vi.fn(), + } as Mocked; + + mockLeagueMembershipService = {} as Mocked; + + service = new LeagueStewardingService( + mockRaceService, + mockProtestService, + mockPenaltyService, + mockDriverService, + mockLeagueMembershipService + ); + }); + + describe('getLeagueStewardingData', () => { + it('should return stewarding data with all races and their protests/penalties', async () => { + const leagueId = 'league-123'; + + // Mock races + mockRaceService.findByLeagueId.mockResolvedValue([ + { id: 'race-1', track: 'Monza', scheduledAt: '2023-10-01T10:00:00Z' }, + { id: 'race-2', track: 'Silverstone', scheduledAt: '2023-09-15T10:00:00Z' }, + ]); + + // Mock protests for race-1 + mockProtestService.findByRaceId.mockImplementation(async (raceId) => { + if (raceId === 'race-1') { + return [ + { id: 'protest-1', protestingDriverId: 'driver-1', accusedDriverId: 'driver-2', status: 'pending' }, + { id: 'protest-2', protestingDriverId: 'driver-3', accusedDriverId: 'driver-4', status: 'upheld' }, + ]; + } + return []; + }); + + // Mock penalties for race-1 + mockPenaltyService.findByRaceId.mockImplementation(async (raceId) => { + if (raceId === 'race-1') { + return [ + { id: 'penalty-1', driverId: 'driver-2', type: 'time', value: 5, reason: 'Incident' }, + ]; + } + return []; + }); + + // Mock driver service + mockDriverService.findByIds.mockResolvedValue([ + { id: 'driver-1', name: 'Driver 1' }, + { id: 'driver-2', name: 'Driver 2' }, + { id: 'driver-3', name: 'Driver 3' }, + { id: 'driver-4', name: 'Driver 4' }, + ]); + + const result = await service.getLeagueStewardingData(leagueId); + + expect(result).toBeInstanceOf(LeagueStewardingViewModel); + expect(result.racesWithData).toHaveLength(2); + expect(result.totalPending).toBe(1); + expect(result.totalResolved).toBe(1); + expect(result.totalPenalties).toBe(1); + expect(result.pendingRaces).toHaveLength(1); + expect(result.historyRaces).toHaveLength(1); + }); + + it('should handle empty races array', async () => { + const leagueId = 'league-123'; + + mockRaceService.findByLeagueId.mockResolvedValue([]); + mockProtestService.findByRaceId.mockResolvedValue([]); + mockPenaltyService.findByRaceId.mockResolvedValue([]); + mockDriverService.findByIds.mockResolvedValue([]); + + const result = await service.getLeagueStewardingData(leagueId); + + expect(result.racesWithData).toHaveLength(0); + expect(result.totalPending).toBe(0); + expect(result.totalResolved).toBe(0); + expect(result.totalPenalties).toBe(0); + }); + }); + + describe('reviewProtest', () => { + it('should call protestService.reviewProtest', async () => { + const input = { + protestId: 'protest-123', + stewardId: 'steward-456', + decision: 'upheld', + decisionNotes: 'Test notes', + }; + + mockProtestService.reviewProtest = vi.fn().mockResolvedValue(undefined); + + await service.reviewProtest(input); + + expect(mockProtestService.reviewProtest).toHaveBeenCalledWith(input); + }); + }); + + describe('applyPenalty', () => { + it('should call penaltyService.applyPenalty', async () => { + const input = { + driverId: 'driver-123', + raceId: 'race-456', + type: 'time', + value: 10, + reason: 'Test reason', + }; + + mockPenaltyService.applyPenalty = vi.fn().mockResolvedValue(undefined); + + await service.applyPenalty(input); + + expect(mockPenaltyService.applyPenalty).toHaveBeenCalledWith(input); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueWalletService.test.ts b/apps/website/lib/services/leagues/LeagueWalletService.test.ts new file mode 100644 index 000000000..59a97afba --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueWalletService.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { LeagueWalletService } from './LeagueWalletService'; +import { WalletsApiClient } from '../../api/wallets/WalletsApiClient'; +import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; + +describe('LeagueWalletService', () => { + let mockApiClient: Mocked; + let service: LeagueWalletService; + + beforeEach(() => { + mockApiClient = { + getLeagueWallet: vi.fn(), + withdrawFromLeagueWallet: vi.fn(), + } as unknown as Mocked; + + service = new LeagueWalletService(mockApiClient); + }); + + describe('getWalletForLeague', () => { + it('should call apiClient.getLeagueWallet and return LeagueWalletViewModel', async () => { + const leagueId = 'league-123'; + const mockDto = { + balance: 1000, + currency: 'USD', + totalRevenue: 5000, + totalFees: 1000, + totalWithdrawals: 2000, + pendingPayouts: 500, + canWithdraw: true, + transactions: [ + { + id: 'txn-1', + type: 'sponsorship' as const, + description: 'Sponsorship payment', + amount: 100, + fee: 10, + netAmount: 90, + date: '2023-10-01T10:00:00Z', + status: 'completed' as const, + reference: 'ref-1', + }, + ], + }; + + mockApiClient.getLeagueWallet.mockResolvedValue(mockDto); + + const result = await service.getWalletForLeague(leagueId); + + expect(mockApiClient.getLeagueWallet).toHaveBeenCalledWith(leagueId); + expect(result).toBeInstanceOf(LeagueWalletViewModel); + expect(result.balance).toBe(1000); + expect(result.currency).toBe('USD'); + expect(result.transactions).toHaveLength(1); + expect(result.formattedBalance).toBe('$1000.00'); + }); + + it('should throw error when apiClient.getLeagueWallet fails', async () => { + const leagueId = 'league-123'; + const error = new Error('API call failed'); + mockApiClient.getLeagueWallet.mockRejectedValue(error); + + await expect(service.getWalletForLeague(leagueId)).rejects.toThrow('API call failed'); + }); + }); + + describe('withdraw', () => { + it('should call apiClient.withdrawFromLeagueWallet with correct parameters', async () => { + const leagueId = 'league-123'; + const amount = 500; + const currency = 'USD'; + const seasonId = 'season-456'; + const destinationAccount = 'account-789'; + + const mockResponse = { success: true, message: 'Withdrawal successful' }; + mockApiClient.withdrawFromLeagueWallet.mockResolvedValue(mockResponse); + + const result = await service.withdraw(leagueId, amount, currency, seasonId, destinationAccount); + + expect(mockApiClient.withdrawFromLeagueWallet).toHaveBeenCalledWith(leagueId, { + amount, + currency, + seasonId, + destinationAccount, + }); + expect(result).toEqual(mockResponse); + }); + + it('should throw error when apiClient.withdrawFromLeagueWallet fails', async () => { + const leagueId = 'league-123'; + const amount = 500; + const currency = 'USD'; + const seasonId = 'season-456'; + const destinationAccount = 'account-789'; + + const error = new Error('Withdrawal failed'); + mockApiClient.withdrawFromLeagueWallet.mockRejectedValue(error); + + await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Withdrawal failed'); + }); + + it('should block multiple rapid calls due to throttle', async () => { + const leagueId = 'league-123'; + const amount = 500; + const currency = 'USD'; + const seasonId = 'season-456'; + const destinationAccount = 'account-789'; + + const mockResponse = { success: true }; + mockApiClient.withdrawFromLeagueWallet.mockResolvedValue(mockResponse); + + // First call should succeed + await service.withdraw(leagueId, amount, currency, seasonId, destinationAccount); + + // Reset mock + mockApiClient.withdrawFromLeagueWallet.mockClear(); + + // Immediate second call should be blocked by throttle and throw error + await expect(service.withdraw(leagueId, amount, currency, seasonId, destinationAccount)).rejects.toThrow('Request blocked due to rate limiting'); + + expect(mockApiClient.withdrawFromLeagueWallet).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueWizardService.test.ts b/apps/website/lib/services/leagues/LeagueWizardService.test.ts new file mode 100644 index 000000000..85bc5c78d --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueWizardService.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { LeagueWizardService } from './LeagueWizardService'; +import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel'; +import { apiClient } from '@/lib/apiClient'; + +// Mock the apiClient +vi.mock('@/lib/apiClient', () => ({ + apiClient: { + leagues: { + create: vi.fn(), + }, + }, +})); + +describe('LeagueWizardService', () => { + describe('createLeague', () => { + it('should call apiClient.leagues.create with correct command', async () => { + const form = { + name: 'Test League', + description: 'A test league', + toCreateLeagueCommand: vi.fn().mockReturnValue({ + name: 'Test League', + description: 'A test league', + ownerId: 'owner-123', + }), + } as unknown as LeagueWizardCommandModel; + + const ownerId = 'owner-123'; + const mockOutput = { leagueId: 'new-league-id', success: true }; + + (apiClient.leagues.create as any).mockResolvedValue(mockOutput); + + const result = await LeagueWizardService.createLeague(form, ownerId); + + expect(form.toCreateLeagueCommand).toHaveBeenCalledWith(ownerId); + expect(apiClient.leagues.create).toHaveBeenCalledWith({ + name: 'Test League', + description: 'A test league', + ownerId: 'owner-123', + }); + expect(result).toEqual(mockOutput); + }); + + it('should throw error when apiClient.leagues.create fails', async () => { + const form = { + name: 'Test League', + description: 'A test league', + toCreateLeagueCommand: vi.fn().mockReturnValue({ + name: 'Test League', + description: 'A test league', + ownerId: 'owner-123', + }), + } as unknown as LeagueWizardCommandModel; + + const ownerId = 'owner-123'; + const error = new Error('API call failed'); + + (apiClient.leagues.create as Mocked).mockRejectedValue(error); + + await expect(LeagueWizardService.createLeague(form, ownerId)).rejects.toThrow('API call failed'); + }); + }); + + describe('createLeagueFromConfig', () => { + it('should call createLeague with same parameters', async () => { + const form = { + name: 'Test League', + description: 'A test league', + toCreateLeagueCommand: vi.fn().mockReturnValue({ + name: 'Test League', + description: 'A test league', + ownerId: 'owner-123', + }), + } as unknown as LeagueWizardCommandModel; + + const ownerId = 'owner-123'; + const mockOutput = { leagueId: 'new-league-id', success: true }; + + (apiClient.leagues.create as Mocked).mockResolvedValue(mockOutput); + + const result = await LeagueWizardService.createLeagueFromConfig(form, ownerId); + + expect(apiClient.leagues.create).toHaveBeenCalled(); + expect(result).toEqual(mockOutput); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/penalties/PenaltyService.test.ts b/apps/website/lib/services/penalties/PenaltyService.test.ts new file mode 100644 index 000000000..c4f407810 --- /dev/null +++ b/apps/website/lib/services/penalties/PenaltyService.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { PenaltyService } from './PenaltyService'; +import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; + +describe('PenaltyService', () => { + let mockApiClient: Mocked; + let service: PenaltyService; + + beforeEach(() => { + mockApiClient = { + getRacePenalties: vi.fn(), + applyPenalty: vi.fn(), + } as Mocked; + + service = new PenaltyService(mockApiClient); + }); + + describe('findByRaceId', () => { + it('should call apiClient.getRacePenalties and return penalties array', async () => { + const raceId = 'race-123'; + const mockDto = { + penalties: [ + { id: 'penalty-1', driverId: 'driver-1', type: 'time', value: 5, reason: 'Incident' }, + { id: 'penalty-2', driverId: 'driver-2', type: 'grid', value: 3, reason: 'Qualifying incident' }, + ], + }; + + mockApiClient.getRacePenalties.mockResolvedValue(mockDto); + + const result = await service.findByRaceId(raceId); + + expect(mockApiClient.getRacePenalties).toHaveBeenCalledWith(raceId); + expect(result).toEqual(mockDto.penalties); + expect(result).toHaveLength(2); + }); + + it('should handle empty penalties array', async () => { + const raceId = 'race-123'; + const mockDto = { penalties: [] }; + + mockApiClient.getRacePenalties.mockResolvedValue(mockDto); + + const result = await service.findByRaceId(raceId); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should throw error when apiClient.getRacePenalties fails', async () => { + const raceId = 'race-123'; + const error = new Error('API call failed'); + mockApiClient.getRacePenalties.mockRejectedValue(error); + + await expect(service.findByRaceId(raceId)).rejects.toThrow('API call failed'); + }); + }); + + describe('applyPenalty', () => { + it('should call apiClient.applyPenalty with input', async () => { + const input = { + driverId: 'driver-123', + raceId: 'race-456', + type: 'time', + value: 10, + reason: 'Test reason', + }; + + mockApiClient.applyPenalty.mockResolvedValue(undefined); + + await service.applyPenalty(input); + + expect(mockApiClient.applyPenalty).toHaveBeenCalledWith(input); + }); + + it('should throw error when apiClient.applyPenalty fails', async () => { + const input = { + driverId: 'driver-123', + raceId: 'race-456', + type: 'time', + value: 10, + reason: 'Test reason', + }; + + const error = new Error('API call failed'); + mockApiClient.applyPenalty.mockRejectedValue(error); + + await expect(service.applyPenalty(input)).rejects.toThrow('API call failed'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/protests/ProtestService.test.ts b/apps/website/lib/services/protests/ProtestService.test.ts new file mode 100644 index 000000000..7bf5c9a2e --- /dev/null +++ b/apps/website/lib/services/protests/ProtestService.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { ProtestService } from './ProtestService'; +import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; +import { ProtestViewModel } from '../../view-models/ProtestViewModel'; +import { RaceViewModel } from '../../view-models/RaceViewModel'; +import { ProtestDriverViewModel } from '../../view-models/ProtestDriverViewModel'; + +describe('ProtestService', () => { + let mockApiClient: Mocked; + let service: ProtestService; + + beforeEach(() => { + mockApiClient = { + getLeagueProtests: vi.fn(), + getLeagueProtest: vi.fn(), + applyPenalty: vi.fn(), + requestDefense: vi.fn(), + reviewProtest: vi.fn(), + getRaceProtests: vi.fn(), + } as Mocked; + + service = new ProtestService(mockApiClient); + }); + + describe('getLeagueProtests', () => { + it('should call apiClient.getLeagueProtests and return transformed data', async () => { + const leagueId = 'league-123'; + const mockDto = { + protests: [ + { + id: 'protest-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Contact at turn 1' }, + filedAt: '2023-10-01T10:00:00Z', + status: 'pending', + }, + ], + racesById: { 'race-1': { id: 'race-1', track: 'Monza' } }, + driversById: { + 'driver-1': { id: 'driver-1', name: 'Driver 1' }, + 'driver-2': { id: 'driver-2', name: 'Driver 2' }, + }, + }; + + mockApiClient.getLeagueProtests.mockResolvedValue(mockDto); + + const result = await service.getLeagueProtests(leagueId); + + expect(mockApiClient.getLeagueProtests).toHaveBeenCalledWith(leagueId); + expect(result.protests).toHaveLength(1); + expect(result.protests[0]).toBeInstanceOf(ProtestViewModel); + expect(result.racesById).toEqual(mockDto.racesById); + expect(result.driversById).toEqual(mockDto.driversById); + }); + + it('should throw error when apiClient.getLeagueProtests fails', async () => { + const leagueId = 'league-123'; + const error = new Error('API call failed'); + mockApiClient.getLeagueProtests.mockRejectedValue(error); + + await expect(service.getLeagueProtests(leagueId)).rejects.toThrow('API call failed'); + }); + }); + + describe('getProtestById', () => { + it('should call apiClient.getLeagueProtest and return transformed data', async () => { + const leagueId = 'league-123'; + const protestId = 'protest-1'; + const mockDto = { + protests: [ + { + id: protestId, + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Contact at turn 1' }, + filedAt: '2023-10-01T10:00:00Z', + status: 'pending', + }, + ], + racesById: { 'race-1': { id: 'race-1', track: 'Monza', scheduledAt: '2023-10-01T10:00:00Z' } }, + driversById: { + 'driver-1': { id: 'driver-1', name: 'Driver 1', avatarUrl: 'https://example.com/avatar1.jpg' }, + 'driver-2': { id: 'driver-2', name: 'Driver 2', avatarUrl: 'https://example.com/avatar2.jpg' }, + }, + }; + + mockApiClient.getLeagueProtest.mockResolvedValue(mockDto); + + const result = await service.getProtestById(leagueId, protestId); + + expect(mockApiClient.getLeagueProtest).toHaveBeenCalledWith(leagueId, protestId); + expect(result).not.toBeNull(); + expect(result?.protest).toBeInstanceOf(ProtestViewModel); + expect(result?.race).toBeInstanceOf(RaceViewModel); + expect(result?.protestingDriver).toBeInstanceOf(ProtestDriverViewModel); + expect(result?.accusedDriver).toBeInstanceOf(ProtestDriverViewModel); + }); + + it('should return null when protest not found', async () => { + const leagueId = 'league-123'; + const protestId = 'protest-999'; + const mockDto = { + protests: [], + racesById: {}, + driversById: {}, + }; + + mockApiClient.getLeagueProtest.mockResolvedValue(mockDto); + + const result = await service.getProtestById(leagueId, protestId); + + expect(result).toBeNull(); + }); + + it('should throw error when apiClient.getLeagueProtest fails', async () => { + const leagueId = 'league-123'; + const protestId = 'protest-1'; + const error = new Error('API call failed'); + mockApiClient.getLeagueProtest.mockRejectedValue(error); + + await expect(service.getProtestById(leagueId, protestId)).rejects.toThrow('API call failed'); + }); + }); + + describe('applyPenalty', () => { + it('should call apiClient.applyPenalty', async () => { + const input = { + protestId: 'protest-123', + driverId: 'driver-456', + type: 'time', + value: 10, + reason: 'Test reason', + }; + + mockApiClient.applyPenalty.mockResolvedValue(undefined); + + await service.applyPenalty(input); + + expect(mockApiClient.applyPenalty).toHaveBeenCalledWith(input); + }); + }); + + describe('requestDefense', () => { + it('should call apiClient.requestDefense', async () => { + const input = { + protestId: 'protest-123', + driverId: 'driver-456', + message: 'Please provide defense', + }; + + mockApiClient.requestDefense.mockResolvedValue(undefined); + + await service.requestDefense(input); + + expect(mockApiClient.requestDefense).toHaveBeenCalledWith(input); + }); + }); + + describe('reviewProtest', () => { + it('should call apiClient.reviewProtest', async () => { + const input = { + protestId: 'protest-123', + stewardId: 'steward-456', + decision: 'upheld', + decisionNotes: 'Test notes', + }; + + mockApiClient.reviewProtest.mockResolvedValue(undefined); + + await service.reviewProtest(input); + + expect(mockApiClient.reviewProtest).toHaveBeenCalledWith(input); + }); + }); + + describe('findByRaceId', () => { + it('should call apiClient.getRaceProtests and return protests array', async () => { + const raceId = 'race-123'; + const mockDto = { + protests: [ + { id: 'protest-1', protestingDriverId: 'driver-1', accusedDriverId: 'driver-2', status: 'pending' }, + { id: 'protest-2', protestingDriverId: 'driver-3', accusedDriverId: 'driver-4', status: 'resolved' }, + ], + driverMap: {}, + }; + + mockApiClient.getRaceProtests.mockResolvedValue(mockDto); + + const result = await service.findByRaceId(raceId); + + expect(mockApiClient.getRaceProtests).toHaveBeenCalledWith(raceId); + expect(result).toEqual(mockDto.protests); + expect(result).toHaveLength(2); + }); + + it('should throw error when apiClient.getRaceProtests fails', async () => { + const raceId = 'race-123'; + const error = new Error('API call failed'); + mockApiClient.getRaceProtests.mockRejectedValue(error); + + await expect(service.findByRaceId(raceId)).rejects.toThrow('API call failed'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceStewardingService.test.ts b/apps/website/lib/services/races/RaceStewardingService.test.ts new file mode 100644 index 000000000..16ee7a456 --- /dev/null +++ b/apps/website/lib/services/races/RaceStewardingService.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, Mocked } from 'vitest'; +import { RaceStewardingService } from './RaceStewardingService'; +import { RacesApiClient } from '../../api/races/RacesApiClient'; +import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; +import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; +import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel'; + +describe('RaceStewardingService', () => { + let mockRacesApiClient: Mocked; + let mockProtestsApiClient: Mocked; + let mockPenaltiesApiClient: Mocked; + let service: RaceStewardingService; + + beforeEach(() => { + mockRacesApiClient = { + getDetail: vi.fn(), + } as Mocked; + + mockProtestsApiClient = { + getRaceProtests: vi.fn(), + } as Mocked; + + mockPenaltiesApiClient = { + getRacePenalties: vi.fn(), + } as Mocked; + + service = new RaceStewardingService(mockRacesApiClient, mockProtestsApiClient, mockPenaltiesApiClient); + }); + + describe('getRaceStewardingData', () => { + it('should return race stewarding data with all protests and penalties', async () => { + const raceId = 'race-123'; + const driverId = 'driver-456'; + + const raceDetailDto = { + race: { + id: raceId, + track: 'Monza', + scheduledAt: '2023-10-01T10:00:00Z', + status: 'completed', + }, + league: { + id: 'league-1', + name: 'Test League', + }, + }; + + const protestsDto = { + protests: [ + { + id: 'protest-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Contact at turn 1' }, + filedAt: '2023-10-01T10:00:00Z', + status: 'pending', + }, + { + id: 'protest-2', + protestingDriverId: 'driver-3', + accusedDriverId: 'driver-4', + incident: { lap: 10, description: 'Off-track' }, + filedAt: '2023-10-01T10:05:00Z', + status: 'upheld', + }, + ], + driverMap: { + 'driver-1': { id: 'driver-1', name: 'Driver 1' }, + 'driver-2': { id: 'driver-2', name: 'Driver 2' }, + 'driver-3': { id: 'driver-3', name: 'Driver 3' }, + 'driver-4': { id: 'driver-4', name: 'Driver 4' }, + }, + }; + + const penaltiesDto = { + penalties: [ + { id: 'penalty-1', driverId: 'driver-2', type: 'time', value: 5, reason: 'Incident' }, + { id: 'penalty-2', driverId: 'driver-4', type: 'grid', value: 3, reason: 'Qualifying incident' }, + ], + driverMap: { + 'driver-2': { id: 'driver-2', name: 'Driver 2' }, + 'driver-4': { id: 'driver-4', name: 'Driver 4' }, + }, + }; + + mockRacesApiClient.getDetail.mockResolvedValue(raceDetailDto as any); + mockProtestsApiClient.getRaceProtests.mockResolvedValue(protestsDto as any); + mockPenaltiesApiClient.getRacePenalties.mockResolvedValue(penaltiesDto as any); + + const result = await service.getRaceStewardingData(raceId, driverId); + + expect(mockRacesApiClient.getDetail).toHaveBeenCalledWith(raceId, driverId); + expect(mockProtestsApiClient.getRaceProtests).toHaveBeenCalledWith(raceId); + expect(mockPenaltiesApiClient.getRacePenalties).toHaveBeenCalledWith(raceId); + + expect(result).toBeInstanceOf(RaceStewardingViewModel); + expect(result.race).toEqual(raceDetailDto.race); + expect(result.league).toEqual(raceDetailDto.league); + expect(result.protests).toHaveLength(2); + expect(result.penalties).toHaveLength(2); + expect(result.pendingProtests).toHaveLength(1); + expect(result.resolvedProtests).toHaveLength(1); + expect(result.pendingCount).toBe(1); + expect(result.resolvedCount).toBe(1); + expect(result.penaltiesCount).toBe(2); + }); + + it('should handle empty protests and penalties', async () => { + const raceId = 'race-123'; + const driverId = 'driver-456'; + + const raceDetailDto = { + race: { id: raceId, track: 'Monza', scheduledAt: '2023-10-01T10:00:00Z', status: 'completed' }, + league: { id: 'league-1', name: 'Test League' }, + }; + + mockRacesApiClient.getDetail.mockResolvedValue(raceDetailDto as any); + mockProtestsApiClient.getRaceProtests.mockResolvedValue({ protests: [], driverMap: {} } as any); + mockPenaltiesApiClient.getRacePenalties.mockResolvedValue({ penalties: [], driverMap: {} } as any); + + const result = await service.getRaceStewardingData(raceId, driverId); + + expect(result.protests).toHaveLength(0); + expect(result.penalties).toHaveLength(0); + expect(result.pendingCount).toBe(0); + expect(result.resolvedCount).toBe(0); + expect(result.penaltiesCount).toBe(0); + }); + + it('should throw error when any API call fails', async () => { + const raceId = 'race-123'; + const driverId = 'driver-456'; + + const error = new Error('API call failed'); + mockRacesApiClient.getDetail.mockRejectedValue(error); + + await expect(service.getRaceStewardingData(raceId, driverId)).rejects.toThrow('API call failed'); + }); + }); +}); \ No newline at end of file diff --git a/package.json b/package.json index 1e4e58277..fd27a956c 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "deploy:website:preview": "npx vercel deploy --cwd apps/website", "deploy:website:prod": "npx vercel deploy --prod", "dev": "echo 'Development server placeholder - to be configured'", + "lint": "npx eslint apps/api/src --ext .ts,.tsx --max-warnings 0", "docker:dev": "docker-compose -f docker-compose.dev.yml up", "docker:dev:build": "docker-compose -f docker-compose.dev.yml up --build", "docker:dev:clean": "docker-compose -f docker-compose.dev.yml down -v", diff --git a/vitest.config.ts b/vitest.config.ts index c158f457f..6207006d1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ }, resolve: { alias: { + '@': path.resolve(__dirname, './apps/website'), '@core': path.resolve(__dirname, './core'), '@adapters': path.resolve(__dirname, './adapters'), '@testing': path.resolve(__dirname, './testing'),