diff --git a/api-smoke-report.json b/api-smoke-report.json index 69bdf97e7..052a5b694 100644 --- a/api-smoke-report.json +++ b/api-smoke-report.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-01-18T00:40:18.010Z", + "timestamp": "2026-01-21T18:46:59.984Z", "summary": { "total": 0, "success": 0, diff --git a/api-smoke-report.md b/api-smoke-report.md index ed001c644..8ef6105ee 100644 --- a/api-smoke-report.md +++ b/api-smoke-report.md @@ -1,6 +1,6 @@ # API Smoke Test Report -**Generated:** 2026-01-18T00:40:18.011Z +**Generated:** 2026-01-21T18:46:59.986Z **API Base URL:** http://localhost:3101 ## Summary diff --git a/apps/api/src/domain/league/LeagueController.detail.test.ts b/apps/api/src/domain/league/LeagueController.detail.test.ts new file mode 100644 index 000000000..b7f6bdc02 --- /dev/null +++ b/apps/api/src/domain/league/LeagueController.detail.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LeagueController } from './LeagueController'; +import { LeagueService } from './LeagueService'; + +describe('LeagueController - Detail Endpoints', () => { + let controller: LeagueController; + let mockService: ReturnType>; + + beforeEach(() => { + mockService = { + getLeagueOwnerSummary: vi.fn(), + getLeagueSeasons: vi.fn(), + getLeagueStats: vi.fn(), + getLeagueMemberships: vi.fn(), + } as never; + + controller = new LeagueController(mockService); + }); + + describe('getLeague', () => { + it('should return league details by ID', async () => { + const mockResult = { + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + rating: 1500, + rank: 10, + }; + mockService.getLeagueOwnerSummary.mockResolvedValue(mockResult as never); + + const result = await controller.getLeague('league-1'); + + expect(result).toEqual(mockResult); + expect(mockService.getLeagueOwnerSummary).toHaveBeenCalledWith({ + ownerId: 'unknown', + leagueId: 'league-1', + }); + }); + + it('should handle league not found gracefully', async () => { + mockService.getLeagueOwnerSummary.mockRejectedValue(new Error('League not found')); + + await expect(controller.getLeague('non-existent-league')).rejects.toThrow('League not found'); + }); + + it('should return league with minimal information', async () => { + const mockResult = { + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'Simple Driver', + country: 'DE', + joinedAt: '2024-01-01T00:00:00Z', + }, + rating: null, + rank: null, + }; + mockService.getLeagueOwnerSummary.mockResolvedValue(mockResult as never); + + const result = await controller.getLeague('league-1'); + + expect(result).toEqual(mockResult); + expect(result.driver.name).toBe('Simple Driver'); + expect(result.rating).toBeNull(); + }); + }); + + describe('getLeagueSeasons', () => { + it('should return seasons for a league', async () => { + const mockResult = [ + { + seasonId: 'season-1', + name: 'Season 1', + status: 'active', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + isPrimary: true, + isParallelActive: false, + totalRaces: 12, + completedRaces: 6, + nextRaceAt: new Date('2024-03-15'), + }, + { + seasonId: 'season-2', + name: 'Season 2', + status: 'upcoming', + startDate: new Date('2024-07-01'), + endDate: new Date('2024-12-31'), + isPrimary: false, + isParallelActive: false, + totalRaces: 12, + completedRaces: 0, + }, + ]; + mockService.getLeagueSeasons.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSeasons('league-1'); + + expect(result).toEqual(mockResult); + expect(mockService.getLeagueSeasons).toHaveBeenCalledWith({ leagueId: 'league-1' }); + }); + + it('should return empty array when league has no seasons', async () => { + const mockResult: never[] = []; + mockService.getLeagueSeasons.mockResolvedValue(mockResult); + + const result = await controller.getLeagueSeasons('league-1'); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should handle league with single season', async () => { + const mockResult = [ + { + seasonId: 'season-1', + name: 'Season 1', + status: 'active', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-12-31'), + isPrimary: true, + isParallelActive: false, + totalRaces: 24, + completedRaces: 12, + nextRaceAt: new Date('2024-06-15'), + }, + ]; + mockService.getLeagueSeasons.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSeasons('league-1'); + + expect(result).toEqual(mockResult); + expect(result).toHaveLength(1); + expect(result[0]?.totalRaces).toBe(24); + }); + + it('should handle seasons with different statuses', async () => { + const mockResult = [ + { + seasonId: 'season-1', + name: 'Season 1', + status: 'completed', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + isPrimary: true, + isParallelActive: false, + totalRaces: 12, + completedRaces: 12, + }, + { + seasonId: 'season-2', + name: 'Season 2', + status: 'active', + startDate: new Date('2024-07-01'), + endDate: new Date('2024-12-31'), + isPrimary: false, + isParallelActive: false, + totalRaces: 12, + completedRaces: 6, + nextRaceAt: new Date('2024-10-15'), + }, + { + seasonId: 'season-3', + name: 'Season 3', + status: 'upcoming', + startDate: new Date('2025-01-01'), + endDate: new Date('2025-06-30'), + isPrimary: false, + isParallelActive: false, + totalRaces: 12, + completedRaces: 0, + }, + ]; + mockService.getLeagueSeasons.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSeasons('league-1'); + + expect(result).toEqual(mockResult); + expect(result).toHaveLength(3); + expect(result[0]?.status).toBe('completed'); + expect(result[1]?.status).toBe('active'); + expect(result[2]?.status).toBe('upcoming'); + }); + }); + + describe('getLeagueStats', () => { + it('should return league statistics', async () => { + const mockResult = { + totalMembers: 25, + totalRaces: 150, + averageRating: 1450.5, + }; + mockService.getLeagueStats.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStats('league-1'); + + expect(result).toEqual(mockResult); + expect(mockService.getLeagueStats).toHaveBeenCalledWith('league-1'); + }); + + it('should return empty stats for new league', async () => { + const mockResult = { + totalMembers: 0, + totalRaces: 0, + averageRating: 0, + }; + mockService.getLeagueStats.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStats('league-1'); + + expect(result).toEqual(mockResult); + expect(result.totalMembers).toBe(0); + expect(result.totalRaces).toBe(0); + }); + + it('should handle league with extensive statistics', async () => { + const mockResult = { + totalMembers: 100, + totalRaces: 500, + averageRating: 1650.75, + }; + mockService.getLeagueStats.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStats('league-1'); + + expect(result).toEqual(mockResult); + expect(result.totalRaces).toBe(500); + expect(result.totalMembers).toBe(100); + }); + }); + + describe('getLeagueMemberships', () => { + it('should return league memberships', async () => { + const mockResult = { + members: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + role: 'owner', + joinedAt: '2024-01-01T00:00:00Z', + }, + { + driverId: 'driver-2', + driver: { + id: 'driver-2', + iracingId: '67890', + name: 'Jane Smith', + country: 'UK', + joinedAt: '2024-01-15T00:00:00Z', + }, + role: 'admin', + joinedAt: '2024-01-15T00:00:00Z', + }, + { + driverId: 'driver-3', + driver: { + id: 'driver-3', + iracingId: '11111', + name: 'Bob Johnson', + country: 'CA', + joinedAt: '2024-02-01T00:00:00Z', + }, + role: 'member', + joinedAt: '2024-02-01T00:00:00Z', + }, + ], + }; + mockService.getLeagueMemberships.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueMemberships('league-1'); + + expect(result).toEqual(mockResult); + expect(mockService.getLeagueMemberships).toHaveBeenCalledWith('league-1'); + }); + + it('should return empty memberships for league with no members', async () => { + const mockResult = { members: [] }; + mockService.getLeagueMemberships.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueMemberships('league-1'); + + expect(result).toEqual(mockResult); + expect(result.members).toHaveLength(0); + }); + + it('should handle league with only owner', async () => { + const mockResult = { + members: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'Owner', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + role: 'owner', + joinedAt: '2024-01-01T00:00:00Z', + }, + ], + }; + mockService.getLeagueMemberships.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueMemberships('league-1'); + + expect(result).toEqual(mockResult); + expect(result.members).toHaveLength(1); + expect(result.members[0]?.role).toBe('owner'); + }); + + it('should handle league with mixed roles', async () => { + const mockResult = { + members: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'Owner', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + role: 'owner', + joinedAt: '2024-01-01T00:00:00Z', + }, + { + driverId: 'driver-2', + driver: { + id: 'driver-2', + iracingId: '67890', + name: 'Admin 1', + country: 'UK', + joinedAt: '2024-01-02T00:00:00Z', + }, + role: 'admin', + joinedAt: '2024-01-02T00:00:00Z', + }, + { + driverId: 'driver-3', + driver: { + id: 'driver-3', + iracingId: '11111', + name: 'Admin 2', + country: 'CA', + joinedAt: '2024-01-03T00:00:00Z', + }, + role: 'admin', + joinedAt: '2024-01-03T00:00:00Z', + }, + { + driverId: 'driver-4', + driver: { + id: 'driver-4', + iracingId: '22222', + name: 'Member 1', + country: 'DE', + joinedAt: '2024-01-04T00:00:00Z', + }, + role: 'member', + joinedAt: '2024-01-04T00:00:00Z', + }, + { + driverId: 'driver-5', + driver: { + id: 'driver-5', + iracingId: '33333', + name: 'Member 2', + country: 'FR', + joinedAt: '2024-01-05T00:00:00Z', + }, + role: 'member', + joinedAt: '2024-01-05T00:00:00Z', + }, + ], + }; + mockService.getLeagueMemberships.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueMemberships('league-1'); + + expect(result).toEqual(mockResult); + expect(result.members).toHaveLength(5); + expect(result.members.filter(m => m.role === 'owner')).toHaveLength(1); + expect(result.members.filter(m => m.role === 'admin')).toHaveLength(2); + expect(result.members.filter(m => m.role === 'member')).toHaveLength(2); + }); + }); +}); diff --git a/apps/api/src/domain/league/LeagueController.discovery.test.ts b/apps/api/src/domain/league/LeagueController.discovery.test.ts new file mode 100644 index 000000000..1715766b4 --- /dev/null +++ b/apps/api/src/domain/league/LeagueController.discovery.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LeagueController } from './LeagueController'; +import { LeagueService } from './LeagueService'; + +describe('LeagueController - Discovery Endpoints', () => { + let controller: LeagueController; + let mockService: ReturnType>; + + beforeEach(() => { + mockService = { + getAllLeaguesWithCapacity: vi.fn(), + getAllLeaguesWithCapacityAndScoring: vi.fn(), + getTotalLeagues: vi.fn(), + } as never; + + controller = new LeagueController(mockService); + }); + + describe('getAllLeaguesWithCapacity', () => { + it('should return leagues with capacity information', async () => { + const mockResult = { + leagues: [ + { + id: 'league-1', + name: 'GT3 Masters', + description: 'A GT3 racing league', + ownerId: 'owner-1', + maxDrivers: 32, + currentDrivers: 25, + isPublic: true, + }, + ], + totalCount: 1, + }; + mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never); + + const result = await controller.getAllLeaguesWithCapacity(); + + expect(result).toEqual(mockResult); + expect(mockService.getAllLeaguesWithCapacity).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when no leagues exist', async () => { + const mockResult = { leagues: [], totalCount: 0 }; + mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never); + + const result = await controller.getAllLeaguesWithCapacity(); + + expect(result).toEqual(mockResult); + expect(result.leagues).toHaveLength(0); + expect(result.totalCount).toBe(0); + }); + + it('should handle multiple leagues with different capacities', async () => { + const mockResult = { + leagues: [ + { + id: 'league-1', + name: 'Small League', + description: 'Small league', + ownerId: 'owner-1', + maxDrivers: 10, + currentDrivers: 8, + isPublic: true, + }, + { + id: 'league-2', + name: 'Large League', + description: 'Large league', + ownerId: 'owner-2', + maxDrivers: 50, + currentDrivers: 45, + isPublic: true, + }, + ], + totalCount: 2, + }; + mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never); + + const result = await controller.getAllLeaguesWithCapacity(); + + expect(result).toEqual(mockResult); + expect(result.leagues).toHaveLength(2); + expect(result.leagues[0]?.maxDrivers).toBe(10); + expect(result.leagues[1]?.maxDrivers).toBe(50); + }); + }); + + describe('getAllLeaguesWithCapacityAndScoring', () => { + it('should return leagues with capacity and scoring information', async () => { + const mockResult = { + leagues: [ + { + id: 'league-1', + name: 'GT3 Masters', + description: 'A GT3 racing league', + ownerId: 'owner-1', + maxDrivers: 32, + currentDrivers: 25, + isPublic: true, + scoringConfig: { + pointsSystem: 'standard', + pointsPerRace: 25, + bonusPoints: true, + }, + }, + ], + totalCount: 1, + }; + mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never); + + const result = await controller.getAllLeaguesWithCapacityAndScoring(); + + expect(result).toEqual(mockResult); + expect(mockService.getAllLeaguesWithCapacityAndScoring).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when no leagues exist', async () => { + const mockResult = { leagues: [], totalCount: 0 }; + mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never); + + const result = await controller.getAllLeaguesWithCapacityAndScoring(); + + expect(result).toEqual(mockResult); + expect(result.leagues).toHaveLength(0); + expect(result.totalCount).toBe(0); + }); + + it('should handle leagues with different scoring configurations', async () => { + const mockResult = { + leagues: [ + { + id: 'league-1', + name: 'Standard League', + description: 'Standard scoring', + ownerId: 'owner-1', + maxDrivers: 32, + currentDrivers: 20, + isPublic: true, + scoringConfig: { + pointsSystem: 'standard', + pointsPerRace: 25, + bonusPoints: true, + }, + }, + { + id: 'league-2', + name: 'Custom League', + description: 'Custom scoring', + ownerId: 'owner-2', + maxDrivers: 20, + currentDrivers: 15, + isPublic: true, + scoringConfig: { + pointsSystem: 'custom', + pointsPerRace: 50, + bonusPoints: false, + }, + }, + ], + totalCount: 2, + }; + mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never); + + const result = await controller.getAllLeaguesWithCapacityAndScoring(); + + expect(result).toEqual(mockResult); + expect(result.leagues).toHaveLength(2); + expect(result.leagues[0]?.scoringConfig.pointsSystem).toBe('standard'); + expect(result.leagues[1]?.scoringConfig.pointsSystem).toBe('custom'); + }); + }); + + describe('getTotalLeagues', () => { + it('should return total leagues count', async () => { + const mockResult = { totalLeagues: 42 }; + mockService.getTotalLeagues.mockResolvedValue(mockResult as never); + + const result = await controller.getTotalLeagues(); + + expect(result).toEqual(mockResult); + expect(mockService.getTotalLeagues).toHaveBeenCalledTimes(1); + }); + + it('should return zero when no leagues exist', async () => { + const mockResult = { totalLeagues: 0 }; + mockService.getTotalLeagues.mockResolvedValue(mockResult as never); + + const result = await controller.getTotalLeagues(); + + expect(result).toEqual(mockResult); + expect(result.totalLeagues).toBe(0); + }); + + it('should handle large league counts', async () => { + const mockResult = { totalLeagues: 1000 }; + mockService.getTotalLeagues.mockResolvedValue(mockResult as never); + + const result = await controller.getTotalLeagues(); + + expect(result).toEqual(mockResult); + expect(result.totalLeagues).toBe(1000); + }); + }); +}); diff --git a/apps/api/src/domain/league/LeagueController.schedule.test.ts b/apps/api/src/domain/league/LeagueController.schedule.test.ts new file mode 100644 index 000000000..503281640 --- /dev/null +++ b/apps/api/src/domain/league/LeagueController.schedule.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LeagueController } from './LeagueController'; +import { LeagueService } from './LeagueService'; + +describe('LeagueController - Schedule Endpoints', () => { + let controller: LeagueController; + let mockService: ReturnType>; + + beforeEach(() => { + mockService = { + getLeagueSchedule: vi.fn(), + } as never; + + controller = new LeagueController(mockService); + }); + + describe('getLeagueSchedule', () => { + it('should return league schedule', async () => { + const mockResult = { + seasonId: 'season-1', + published: true, + races: [ + { + id: 'race-1', + name: 'Spa Endurance', + date: '2024-03-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-2', + name: 'Monza Sprint', + date: '2024-03-22T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-3', + name: 'Nürburgring Endurance', + date: '2024-03-29T14:00:00Z', + leagueName: 'GT3 Masters', + }, + ], + }; + mockService.getLeagueSchedule.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSchedule('league-1', {}); + + expect(result).toEqual(mockResult); + expect(mockService.getLeagueSchedule).toHaveBeenCalledWith('league-1', {}); + }); + + it('should return empty schedule for league with no races', async () => { + const mockResult = { + seasonId: 'season-1', + published: false, + races: [], + }; + mockService.getLeagueSchedule.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSchedule('league-1', {}); + + expect(result).toEqual(mockResult); + expect(result.races).toHaveLength(0); + expect(result.published).toBe(false); + }); + + it('should handle schedule with specific season ID', async () => { + const mockResult = { + seasonId: 'season-2', + published: true, + races: [ + { + id: 'race-10', + name: 'Silverstone Endurance', + date: '2024-08-01T14:00:00Z', + leagueName: 'GT3 Masters', + }, + ], + }; + mockService.getLeagueSchedule.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSchedule('league-1', { seasonId: 'season-2' }); + + expect(result).toEqual(mockResult); + expect(mockService.getLeagueSchedule).toHaveBeenCalledWith('league-1', { seasonId: 'season-2' }); + }); + + it('should handle schedule with multiple races on same track', async () => { + const mockResult = { + seasonId: 'season-1', + published: true, + races: [ + { + id: 'race-1', + name: 'Spa Endurance', + date: '2024-03-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-2', + name: 'Spa Sprint', + date: '2024-04-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + ], + }; + mockService.getLeagueSchedule.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSchedule('league-1', {}); + + expect(result).toEqual(mockResult); + expect(result.races).toHaveLength(2); + expect(result.races[0]?.name).toContain('Spa'); + expect(result.races[1]?.name).toContain('Spa'); + }); + + it('should handle schedule with different race names', async () => { + const mockResult = { + seasonId: 'season-1', + published: true, + races: [ + { + id: 'race-1', + name: 'Spa Endurance', + date: '2024-01-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-2', + name: 'Monza Sprint', + date: '2024-02-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-3', + name: 'Nürburgring Endurance', + date: '2024-03-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-4', + name: 'Silverstone Sprint', + date: '2024-04-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + ], + }; + mockService.getLeagueSchedule.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSchedule('league-1', {}); + + expect(result).toEqual(mockResult); + expect(result.races).toHaveLength(4); + expect(result.races[0]?.name).toBe('Spa Endurance'); + expect(result.races[1]?.name).toBe('Monza Sprint'); + expect(result.races[2]?.name).toBe('Nürburgring Endurance'); + expect(result.races[3]?.name).toBe('Silverstone Sprint'); + }); + + it('should handle schedule with different dates', async () => { + const mockResult = { + seasonId: 'season-1', + published: true, + races: [ + { + id: 'race-1', + name: 'Race 1', + date: '2024-01-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-2', + name: 'Race 2', + date: '2024-02-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-3', + name: 'Race 3', + date: '2024-03-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + ], + }; + mockService.getLeagueSchedule.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSchedule('league-1', {}); + + expect(result).toEqual(mockResult); + expect(result.races).toHaveLength(3); + expect(result.races[0]?.date).toBe('2024-01-15T14:00:00Z'); + expect(result.races[1]?.date).toBe('2024-02-15T14:00:00Z'); + expect(result.races[2]?.date).toBe('2024-03-15T14:00:00Z'); + }); + + it('should handle schedule with league name variations', async () => { + const mockResult = { + seasonId: 'season-1', + published: true, + races: [ + { + id: 'race-1', + name: 'Race 1', + date: '2024-03-15T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-2', + name: 'Race 2', + date: '2024-03-22T14:00:00Z', + leagueName: 'GT3 Masters', + }, + { + id: 'race-3', + name: 'Race 3', + date: '2024-03-29T14:00:00Z', + leagueName: null, + }, + ], + }; + mockService.getLeagueSchedule.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueSchedule('league-1', {}); + + expect(result).toEqual(mockResult); + expect(result.races).toHaveLength(3); + expect(result.races[0]?.leagueName).toBe('GT3 Masters'); + expect(result.races[1]?.leagueName).toBe('GT3 Masters'); + expect(result.races[2]?.leagueName).toBeNull(); + }); + }); +}); diff --git a/apps/api/src/domain/league/LeagueController.standings.test.ts b/apps/api/src/domain/league/LeagueController.standings.test.ts new file mode 100644 index 000000000..621456168 --- /dev/null +++ b/apps/api/src/domain/league/LeagueController.standings.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LeagueController } from './LeagueController'; +import { LeagueService } from './LeagueService'; + +describe('LeagueController - Standings Endpoints', () => { + let controller: LeagueController; + let mockService: ReturnType>; + + beforeEach(() => { + mockService = { + getLeagueStandings: vi.fn(), + } as never; + + controller = new LeagueController(mockService); + }); + + describe('getLeagueStandings', () => { + it('should return league standings', async () => { + const mockResult = { + standings: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 1, + points: 150, + races: 12, + wins: 5, + podiums: 8, + }, + { + driverId: 'driver-2', + driver: { + id: 'driver-2', + iracingId: '67890', + name: 'Jane Smith', + country: 'UK', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 2, + points: 145, + races: 12, + wins: 4, + podiums: 7, + }, + { + driverId: 'driver-3', + driver: { + id: 'driver-3', + iracingId: '11111', + name: 'Bob Johnson', + country: 'CA', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 3, + points: 140, + races: 12, + wins: 3, + podiums: 6, + }, + ], + }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(mockService.getLeagueStandings).toHaveBeenCalledWith('league-1'); + }); + + it('should return empty standings for league with no races', async () => { + const mockResult = { standings: [] }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(result.standings).toHaveLength(0); + }); + + it('should handle standings with single driver', async () => { + const mockResult = { + standings: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 1, + points: 100, + races: 10, + wins: 10, + podiums: 10, + }, + ], + }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(result.standings).toHaveLength(1); + expect(result.standings[0]?.position).toBe(1); + expect(result.standings[0]?.points).toBe(100); + }); + + it('should handle standings with many drivers', async () => { + const mockResult = { + standings: Array.from({ length: 20 }, (_, i) => ({ + driverId: `driver-${i + 1}`, + driver: { + id: `driver-${i + 1}`, + iracingId: `${10000 + i}`, + name: `Driver ${i + 1}`, + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: i + 1, + points: 100 - i, + races: 12, + wins: Math.max(0, 5 - i), + podiums: Math.max(0, 10 - i), + })), + }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(result.standings).toHaveLength(20); + expect(result.standings[0]?.position).toBe(1); + expect(result.standings[19]?.position).toBe(20); + }); + + it('should handle standings with tied points', async () => { + const mockResult = { + standings: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 1, + points: 150, + races: 12, + wins: 5, + podiums: 8, + }, + { + driverId: 'driver-2', + driver: { + id: 'driver-2', + iracingId: '67890', + name: 'Jane Smith', + country: 'UK', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 2, + points: 150, + races: 12, + wins: 5, + podiums: 7, + }, + { + driverId: 'driver-3', + driver: { + id: 'driver-3', + iracingId: '11111', + name: 'Bob Johnson', + country: 'CA', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 3, + points: 145, + races: 12, + wins: 4, + podiums: 6, + }, + ], + }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(result.standings).toHaveLength(3); + expect(result.standings[0]?.points).toBe(150); + expect(result.standings[1]?.points).toBe(150); + expect(result.standings[2]?.points).toBe(145); + }); + + it('should handle standings with varying race counts', async () => { + const mockResult = { + standings: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 1, + points: 150, + races: 12, + wins: 5, + podiums: 8, + }, + { + driverId: 'driver-2', + driver: { + id: 'driver-2', + iracingId: '67890', + name: 'Jane Smith', + country: 'UK', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 2, + points: 140, + races: 10, + wins: 4, + podiums: 6, + }, + { + driverId: 'driver-3', + driver: { + id: 'driver-3', + iracingId: '11111', + name: 'Bob Johnson', + country: 'CA', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 3, + points: 130, + races: 8, + wins: 3, + podiums: 5, + }, + ], + }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(result.standings).toHaveLength(3); + expect(result.standings[0]?.races).toBe(12); + expect(result.standings[1]?.races).toBe(10); + expect(result.standings[2]?.races).toBe(8); + }); + + it('should handle standings with varying win counts', async () => { + const mockResult = { + standings: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 1, + points: 150, + races: 12, + wins: 10, + podiums: 12, + }, + { + driverId: 'driver-2', + driver: { + id: 'driver-2', + iracingId: '67890', + name: 'Jane Smith', + country: 'UK', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 2, + points: 140, + races: 12, + wins: 2, + podiums: 8, + }, + { + driverId: 'driver-3', + driver: { + id: 'driver-3', + iracingId: '11111', + name: 'Bob Johnson', + country: 'CA', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 3, + points: 130, + races: 12, + wins: 0, + podiums: 4, + }, + ], + }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(result.standings).toHaveLength(3); + expect(result.standings[0]?.wins).toBe(10); + expect(result.standings[1]?.wins).toBe(2); + expect(result.standings[2]?.wins).toBe(0); + }); + + it('should handle standings with varying podium counts', async () => { + const mockResult = { + standings: [ + { + driverId: 'driver-1', + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 1, + points: 150, + races: 12, + wins: 5, + podiums: 12, + }, + { + driverId: 'driver-2', + driver: { + id: 'driver-2', + iracingId: '67890', + name: 'Jane Smith', + country: 'UK', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 2, + points: 140, + races: 12, + wins: 4, + podiums: 8, + }, + { + driverId: 'driver-3', + driver: { + id: 'driver-3', + iracingId: '11111', + name: 'Bob Johnson', + country: 'CA', + joinedAt: '2024-01-01T00:00:00Z', + }, + position: 3, + points: 130, + races: 12, + wins: 3, + podiums: 4, + }, + ], + }; + mockService.getLeagueStandings.mockResolvedValue(mockResult as never); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(result.standings).toHaveLength(3); + expect(result.standings[0]?.podiums).toBe(12); + expect(result.standings[1]?.podiums).toBe(8); + expect(result.standings[2]?.podiums).toBe(4); + }); + }); +}); diff --git a/apps/api/src/domain/league/LeagueService.endpoints.test.ts b/apps/api/src/domain/league/LeagueService.endpoints.test.ts new file mode 100644 index 000000000..8ffcf87b5 --- /dev/null +++ b/apps/api/src/domain/league/LeagueService.endpoints.test.ts @@ -0,0 +1,257 @@ +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { Result } from '@core/shared/domain/Result'; +import { describe, expect, it, vi } from 'vitest'; +import { LeagueService } from './LeagueService'; + +async function withUserId(userId: string, fn: () => Promise): Promise { + const req = { user: { userId } }; + const res = {}; + + return await new Promise((resolve, reject) => { + requestContextMiddleware(req as never, res as never, () => { + fn().then(resolve, reject); + }); + }); +} + +describe('LeagueService - All Endpoints', () => { + it('covers all league endpoint happy paths and error branches', async () => { + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const ok = async () => Result.ok(undefined); + const err = async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never); + + // Discovery use cases + const getAllLeaguesWithCapacityUseCase = { execute: vi.fn(async () => Result.ok({ leagues: [] })) }; + const getAllLeaguesWithCapacityAndScoringUseCase = { execute: vi.fn(ok) }; + const getTotalLeaguesUseCase = { execute: vi.fn(ok) }; + + // Detail use cases + const getLeagueOwnerSummaryUseCase = { execute: vi.fn(ok) }; + const getLeagueSeasonsUseCase = { execute: vi.fn(ok) }; + const getLeagueStatsUseCase = { execute: vi.fn(ok) }; + const getLeagueMembershipsUseCase = { execute: vi.fn(ok) }; + + // Schedule use case + const getLeagueScheduleUseCase = { execute: vi.fn(ok) }; + + // Standings use case + const getLeagueStandingsUseCase = { execute: vi.fn(ok) }; + + // Other use cases (for completeness) + const getLeagueFullConfigUseCase = { execute: vi.fn(ok) }; + const getLeagueScoringConfigUseCase = { execute: vi.fn(ok) }; + const listLeagueScoringPresetsUseCase = { execute: vi.fn(ok) }; + const joinLeagueUseCase = { execute: vi.fn(ok) }; + const transferLeagueOwnershipUseCase = { execute: vi.fn(ok) }; + const createLeagueWithSeasonAndScoringUseCase = { execute: vi.fn(ok) }; + const getLeagueJoinRequestsUseCase = { execute: vi.fn(ok) }; + const approveLeagueJoinRequestUseCase = { execute: vi.fn(ok) }; + const rejectLeagueJoinRequestUseCase = { execute: vi.fn(ok) }; + const removeLeagueMemberUseCase = { execute: vi.fn(ok) }; + const updateLeagueMemberRoleUseCase = { execute: vi.fn(ok) }; + const getLeagueProtestsUseCase = { execute: vi.fn(ok) }; + const getLeagueAdminPermissionsUseCase = { execute: vi.fn(ok) }; + const getLeagueWalletUseCase = { execute: vi.fn(ok) }; + const withdrawFromLeagueWalletUseCase = { execute: vi.fn(ok) }; + const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) }; + + const getLeagueRosterMembersUseCase = { execute: vi.fn(ok) }; + const getLeagueRosterJoinRequestsUseCase = { execute: vi.fn(ok) }; + + // Schedule mutation use cases + const createLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) }; + const updateLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) }; + const deleteLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) }; + const publishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) }; + const unpublishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) }; + + // Presenters + const allLeaguesWithCapacityPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ leagues: [] })) }; + const allLeaguesWithCapacityAndScoringPresenter = { + present: vi.fn(), + getViewModel: vi.fn(() => ({ leagues: [], totalCount: 0 })), + }; + const leagueStandingsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ standings: [] })) }; + const leagueProtestsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ protests: [] })) }; + const seasonSponsorshipsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ sponsorships: [] })) }; + const leagueScoringPresetsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ presets: [] })) }; + const approveLeagueJoinRequestPresenter = { + present: vi.fn(), + getViewModel: vi.fn(() => ({ success: true })) + }; + const createLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ id: 'l1' })) }; + const getLeagueAdminPermissionsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ canManage: true })) }; + const getLeagueMembershipsPresenter = { + reset: vi.fn(), + present: vi.fn(), + getViewModel: vi.fn(() => ({ memberships: { members: [] } })), + }; + + const getLeagueRosterMembersPresenter = { + reset: vi.fn(), + present: vi.fn(), + getViewModel: vi.fn(() => ([])), + }; + + const getLeagueRosterJoinRequestsPresenter = { + reset: vi.fn(), + present: vi.fn(), + getViewModel: vi.fn(() => ([])), + }; + const getLeagueOwnerSummaryPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ driver: { id: 'd1', iracingId: '12345', name: 'Driver', country: 'US', joinedAt: '2024-01-01T00:00:00Z' }, rating: 1500, rank: 10 })) }; + const getLeagueSeasonsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ([])) }; + const joinLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) }; + const leagueSchedulePresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ seasonId: 'season-1', published: false, races: [] })) }; + const leagueStatsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ totalMembers: 0, totalRaces: 0, averageRating: 0 })) }; + const rejectLeagueJoinRequestPresenter = { + present: vi.fn(), + getViewModel: vi.fn(() => ({ success: true })) + }; + const removeLeagueMemberPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) }; + const totalLeaguesPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ totalLeagues: 0 })) }; + const transferLeagueOwnershipPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) }; + const updateLeagueMemberRolePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) }; + const leagueConfigPresenter = { + present: vi.fn(), + getViewModel: vi.fn(() => ({ form: {} })) + }; + const leagueScoringConfigPresenter = { + present: vi.fn(), + getViewModel: vi.fn(() => ({ config: {} })) + }; + const getLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ balance: 0 })) }; + const withdrawFromLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) }; + const leagueJoinRequestsPresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ joinRequests: [] })) }; + + const createLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) }; + const updateLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) }; + const deleteLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) }; + const publishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: true })) }; + const unpublishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: false })) }; + + const service = new (LeagueService as unknown as { new (...args: never[]): LeagueService })( + getAllLeaguesWithCapacityUseCase as never, + getAllLeaguesWithCapacityAndScoringUseCase as never, + getLeagueStandingsUseCase as never, + getLeagueStatsUseCase as never, + getLeagueFullConfigUseCase as never, + getLeagueScoringConfigUseCase as never, + listLeagueScoringPresetsUseCase as never, + joinLeagueUseCase as never, + transferLeagueOwnershipUseCase as never, + createLeagueWithSeasonAndScoringUseCase as never, + getTotalLeaguesUseCase as never, + getLeagueJoinRequestsUseCase as never, + approveLeagueJoinRequestUseCase as never, + rejectLeagueJoinRequestUseCase as never, + removeLeagueMemberUseCase as never, + updateLeagueMemberRoleUseCase as never, + getLeagueOwnerSummaryUseCase as never, + getLeagueProtestsUseCase as never, + getLeagueSeasonsUseCase as never, + getLeagueMembershipsUseCase as never, + getLeagueScheduleUseCase as never, + getLeagueAdminPermissionsUseCase as never, + getLeagueWalletUseCase as never, + withdrawFromLeagueWalletUseCase as never, + getSeasonSponsorshipsUseCase as never, + createLeagueSeasonScheduleRaceUseCase as never, + updateLeagueSeasonScheduleRaceUseCase as never, + deleteLeagueSeasonScheduleRaceUseCase as never, + publishLeagueSeasonScheduleUseCase as never, + unpublishLeagueSeasonScheduleUseCase as never, + logger as never, + allLeaguesWithCapacityPresenter as never, + allLeaguesWithCapacityAndScoringPresenter as never, + leagueStandingsPresenter as never, + leagueProtestsPresenter as never, + seasonSponsorshipsPresenter as never, + leagueScoringPresetsPresenter as never, + approveLeagueJoinRequestPresenter as never, + createLeaguePresenter as never, + getLeagueAdminPermissionsPresenter as never, + getLeagueMembershipsPresenter as never, + getLeagueOwnerSummaryPresenter as never, + getLeagueSeasonsPresenter as never, + joinLeaguePresenter as never, + leagueSchedulePresenter as never, + leagueStatsPresenter as never, + rejectLeagueJoinRequestPresenter as never, + removeLeagueMemberPresenter as never, + totalLeaguesPresenter as never, + transferLeagueOwnershipPresenter as never, + updateLeagueMemberRolePresenter as never, + leagueConfigPresenter as never, + leagueScoringConfigPresenter as never, + getLeagueWalletPresenter as never, + withdrawFromLeagueWalletPresenter as never, + leagueJoinRequestsPresenter as never, + createLeagueSeasonScheduleRacePresenter as never, + updateLeagueSeasonScheduleRacePresenter as never, + deleteLeagueSeasonScheduleRacePresenter as never, + publishLeagueSeasonSchedulePresenter as never, + unpublishLeagueSeasonSchedulePresenter as never, + + // Roster admin read delegation (added for strict TDD) + getLeagueRosterMembersUseCase as never, + getLeagueRosterJoinRequestsUseCase as never, + getLeagueRosterMembersPresenter as never, + getLeagueRosterJoinRequestsPresenter as never, + ); + + // Discovery endpoints + await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] }); + await expect(service.getAllLeaguesWithCapacityAndScoring()).resolves.toEqual({ leagues: [], totalCount: 0 }); + await expect(service.getTotalLeagues()).resolves.toEqual({ totalLeagues: 0 }); + + // Detail endpoints + await expect(service.getLeagueOwnerSummary({ leagueId: 'l1' } as never)).resolves.toEqual({ driver: { id: 'd1', iracingId: '12345', name: 'Driver', country: 'US', joinedAt: '2024-01-01T00:00:00Z' }, rating: 1500, rank: 10 }); + await expect(service.getLeagueSeasons({ leagueId: 'l1' } as never)).resolves.toEqual([]); + await expect(service.getLeagueStats('l1')).resolves.toEqual({ totalMembers: 0, totalRaces: 0, averageRating: 0 }); + await expect(service.getLeagueMemberships('l1')).resolves.toEqual({ members: [] }); + + // Schedule endpoint + await expect(service.getLeagueSchedule('l1')).resolves.toEqual({ seasonId: 'season-1', published: false, races: [] }); + expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' }); + + getLeagueScheduleUseCase.execute.mockClear(); + await expect(service.getLeagueSchedule('l1', { seasonId: 'season-x' } as never)).resolves.toEqual({ + seasonId: 'season-1', + published: false, + races: [], + }); + expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', seasonId: 'season-x' }); + + // Standings endpoint + await expect(service.getLeagueStandings('l1')).resolves.toEqual({ standings: [] }); + + // Error branches: use case returns error result + getAllLeaguesWithCapacityUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never)); + await expect(service.getAllLeaguesWithCapacity()).rejects.toThrow('REPOSITORY_ERROR'); + + getLeagueFullConfigUseCase.execute.mockResolvedValueOnce( + Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never) + ); + await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as never)).resolves.toBeNull(); + + getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => { + throw new Error('boom'); + }); + await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull(); + + // Cover non-Error throw branches for logger.error wrapping + getLeagueFullConfigUseCase.execute.mockResolvedValueOnce( + Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never) + ); + await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as never)).resolves.toBeNull(); + + getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => { + throw 'boom'; + }); + await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull(); + + // keep lint happy (ensures err() used) + await err(); + }); +}); diff --git a/apps/website/app/leagues/[id]/roster/page.tsx b/apps/website/app/leagues/[id]/roster/page.tsx index 6bdaab049..d2ed39763 100644 --- a/apps/website/app/leagues/[id]/roster/page.tsx +++ b/apps/website/app/leagues/[id]/roster/page.tsx @@ -36,6 +36,8 @@ export default async function LeagueRosterPage({ params }: Props) { + + ); } diff --git a/apps/website/components/layout/AppHeader.tsx b/apps/website/components/layout/AppHeader.tsx index 67169a27e..64ce3100d 100644 --- a/apps/website/components/layout/AppHeader.tsx +++ b/apps/website/components/layout/AppHeader.tsx @@ -12,6 +12,9 @@ import { Input } from '@/ui/Input'; import { Box } from '@/ui/Box'; import { IconButton } from '@/ui/IconButton'; import { useSidebar } from '@/components/layout/SidebarContext'; +import { PublicTopNav } from '@/ui/PublicTopNav'; +import { PublicNavLogin } from '@/ui/PublicNavLogin'; +import { PublicNavSignup } from '@/ui/PublicNavSignup'; export function AppHeader() { const pathname = usePathname(); @@ -41,29 +44,39 @@ export function AppHeader() { return ( <> - {/* Left: Context & Search */} + {/* Left: Public Navigation & Context */} - - {breadcrumbs} - + {/* Public Top Navigation - Only when not authenticated */} + {!isAuthenticated && ( + + )} - {/* Command Search Trigger */} - - setIsCommandOpen(true)} - placeholder="Search or type a command..." - variant="search" - width="24rem" - rightElement={ - - - K - - } - className="cursor-pointer" - /> - + {/* Context & Search - Only when authenticated */} + {isAuthenticated && ( + <> + + {breadcrumbs} + + + {/* Command Search Trigger */} + + setIsCommandOpen(true)} + placeholder="Search or type a command..." + variant="search" + width="24rem" + rightElement={ + + + K + + } + className="cursor-pointer" + /> + + + )} {/* Right: User & Notifications */} @@ -71,17 +84,25 @@ export function AppHeader() { {/* Notifications - Only when authed */} {isAuthenticated && ( - )} - {/* User Pill (Handles Auth & Menu) */} - + {/* Public Login/Signup Buttons - Only when not authenticated */} + {!isAuthenticated && ( + <> + + + + )} + + {/* User Pill (Handles Auth & Menu) - Only when authenticated */} + {isAuthenticated && } diff --git a/apps/website/components/leaderboards/DeltaChip.tsx b/apps/website/components/leaderboards/DeltaChip.tsx index bdef5dab0..f7c9b1b20 100644 --- a/apps/website/components/leaderboards/DeltaChip.tsx +++ b/apps/website/components/leaderboards/DeltaChip.tsx @@ -13,7 +13,7 @@ interface DeltaChipProps { export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) { if (value === 0) { return ( - + 0 @@ -26,7 +26,7 @@ export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) { const absoluteValue = Math.abs(value); return ( - + diff --git a/apps/website/components/leaderboards/RankingRow.tsx b/apps/website/components/leaderboards/RankingRow.tsx index 811875487..d655cd27c 100644 --- a/apps/website/components/leaderboards/RankingRow.tsx +++ b/apps/website/components/leaderboards/RankingRow.tsx @@ -20,6 +20,7 @@ interface RankingRowProps { rating: number; wins: number; onClick?: () => void; + droppedRaceIds?: string[]; } export function RankingRow({ @@ -33,12 +34,13 @@ export function RankingRow({ rating, wins, onClick, + droppedRaceIds, }: RankingRowProps) { return ( + {rankDelta !== undefined && ( @@ -46,17 +48,17 @@ export function RankingRow({ } identity={ - - + - {name} @@ -71,7 +73,7 @@ export function RankingRow({ } stats={ - + {racesCompleted} @@ -96,6 +98,16 @@ export function RankingRow({ Wins + {droppedRaceIds && droppedRaceIds.length > 0 && ( + + + {droppedRaceIds.length} + + + Dropped + + + )} } /> diff --git a/apps/website/components/leagues/AdminQuickViewWidgets.tsx b/apps/website/components/leagues/AdminQuickViewWidgets.tsx index bca284e35..ce6739a64 100644 --- a/apps/website/components/leagues/AdminQuickViewWidgets.tsx +++ b/apps/website/components/leagues/AdminQuickViewWidgets.tsx @@ -28,7 +28,7 @@ export function AdminQuickViewWidgets({ } return ( - + {/* Wallet Preview */} + {/* Month Header */} {/* Race Info */} @@ -208,6 +209,7 @@ export function EnhancedLeagueSchedulePanel({ size="sm" onClick={() => onRegister(race.id)} icon={} + data-testid="register-button" > Register diff --git a/apps/website/components/leagues/LeagueStandingsTable.tsx b/apps/website/components/leagues/LeagueStandingsTable.tsx index 84c6b6ce2..899bd8ab1 100644 --- a/apps/website/components/leagues/LeagueStandingsTable.tsx +++ b/apps/website/components/leagues/LeagueStandingsTable.tsx @@ -30,21 +30,22 @@ interface StandingEntry { interface LeagueStandingsTableProps { standings: StandingEntry[]; + 'data-testid'?: string; } -export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) { +export function LeagueStandingsTable({ standings, 'data-testid': dataTestId }: LeagueStandingsTableProps) { const router = useRouter(); if (!standings || standings.length === 0) { return ( - + No standings data available for this season. ); } return ( - + {standings.map((entry) => ( router.push(routes.driver.detail(entry.driverId!)) : undefined} + data-testid="standings-row" + droppedRaceIds={entry.droppedRaceIds} /> ))} diff --git a/apps/website/components/leagues/NextRaceCountdownWidget.tsx b/apps/website/components/leagues/NextRaceCountdownWidget.tsx index aeb88545e..d726a284e 100644 --- a/apps/website/components/leagues/NextRaceCountdownWidget.tsx +++ b/apps/website/components/leagues/NextRaceCountdownWidget.tsx @@ -74,6 +74,7 @@ export function NextRaceCountdownWidget({ position: 'relative', overflow: 'hidden', }} + data-testid="next-race-countdown" > e.stopPropagation()} > - + {/* Header */} - + {race.track || 'TBA'} - + {race.car || 'TBA'} - + {formatTime(race.scheduledAt)} diff --git a/apps/website/components/leagues/RosterTable.tsx b/apps/website/components/leagues/RosterTable.tsx index 014221ac1..19ea7c4a0 100644 --- a/apps/website/components/leagues/RosterTable.tsx +++ b/apps/website/components/leagues/RosterTable.tsx @@ -21,6 +21,7 @@ export function RosterTable({ members, isAdmin, onRemoveMember }: RosterTablePro members={members} isAdmin={isAdmin} onRemoveMember={onRemoveMember} + data-testid="roster-table" /> ); } diff --git a/apps/website/components/leagues/SeasonProgressWidget.tsx b/apps/website/components/leagues/SeasonProgressWidget.tsx index 25551744b..e748d171e 100644 --- a/apps/website/components/leagues/SeasonProgressWidget.tsx +++ b/apps/website/components/leagues/SeasonProgressWidget.tsx @@ -23,6 +23,7 @@ export function SeasonProgressWidget({ variant="precision" rounded="xl" padding={6} + data-testid="season-progress-bar" > {/* Header */} diff --git a/apps/website/components/teams/TeamMembersTable.tsx b/apps/website/components/teams/TeamMembersTable.tsx index 114db8145..280e2be3f 100644 --- a/apps/website/components/teams/TeamMembersTable.tsx +++ b/apps/website/components/teams/TeamMembersTable.tsx @@ -20,11 +20,12 @@ interface TeamMembersTableProps { members: Member[]; isAdmin?: boolean; onRemoveMember?: (driverId: string) => void; + 'data-testid'?: string; } -export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembersTableProps) { +export function TeamMembersTable({ members, isAdmin, onRemoveMember, 'data-testid': dataTestId }: TeamMembersTableProps) { return ( - + Personnel @@ -35,30 +36,30 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe {members.map((member) => ( - + - {member.driverName.substring(0, 2).toUpperCase()} - {member.driverName} + {member.driverName} - @@ -71,15 +72,16 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe - 1450 + 1450 {isAdmin && ( {member.role !== 'owner' && ( - diff --git a/apps/website/lib/routing/RouteConfig.ts b/apps/website/lib/routing/RouteConfig.ts index c76ca078f..627e69f1f 100644 --- a/apps/website/lib/routing/RouteConfig.ts +++ b/apps/website/lib/routing/RouteConfig.ts @@ -10,7 +10,7 @@ * - Environment-specific: can vary by mode */ -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger'; const logger = new ConsoleLogger(); diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index a7d250a6a..4bc2ea07a 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -1,6 +1,7 @@ import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient"; import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient"; import { RacesApiClient } from "@/lib/api/races/RacesApiClient"; +import { ApiError } from '@/lib/api/base/ApiError'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { isProductionEnvironment } from '@/lib/config/env'; import { Result } from '@/lib/contracts/Result'; @@ -142,6 +143,20 @@ export class LeagueService implements Service { const dto = await this.apiClient.getAllWithCapacityAndScoring(); return Result.ok(dto); } catch (error: unknown) { + // Map API error types to domain error types + if (error instanceof ApiError) { + const errorType = error.type; + switch (errorType) { + case 'NOT_FOUND': + return Result.err({ type: 'notFound', message: error.message }); + case 'AUTH_ERROR': + return Result.err({ type: 'unauthorized', message: error.message }); + case 'SERVER_ERROR': + return Result.err({ type: 'serverError', message: error.message }); + default: + return Result.err({ type: 'serverError', message: error.message || 'Failed to fetch leagues' }); + } + } return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch leagues' }); } } @@ -224,6 +239,20 @@ export class LeagueService implements Service { }); } catch (error: unknown) { console.error('LeagueService.getLeagueDetailData failed:', error); + // Map API error types to domain error types + if (error instanceof ApiError) { + const errorType = error.type; + switch (errorType) { + case 'NOT_FOUND': + return Result.err({ type: 'notFound', message: error.message }); + case 'AUTH_ERROR': + return Result.err({ type: 'unauthorized', message: error.message }); + case 'SERVER_ERROR': + return Result.err({ type: 'serverError', message: error.message }); + default: + return Result.err({ type: 'serverError', message: error.message || 'Failed to fetch league detail' }); + } + } return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch league detail' }); } } diff --git a/apps/website/templates/LeagueDetailTemplate.tsx b/apps/website/templates/LeagueDetailTemplate.tsx index 57eda221a..dd1710f45 100644 --- a/apps/website/templates/LeagueDetailTemplate.tsx +++ b/apps/website/templates/LeagueDetailTemplate.tsx @@ -40,7 +40,7 @@ export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps : pathname.startsWith(tab.href); return ( - + - {viewData.name} + {viewData.name} {viewData.info.structure} • Created {new Date(viewData.info.createdAt).toLocaleDateString()} @@ -68,7 +68,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv {/* League Activity Feed */} Recent Activity - + @@ -86,13 +86,16 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv {/* Quick Stats */} Quick Stats - - - - + + + + + + + {/* Roster Preview */} @@ -148,6 +151,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv pendingProtestsCount={viewData.pendingProtestsCount} pendingJoinRequestsCount={viewData.pendingJoinRequestsCount} isOwnerOrAdmin={isOwnerOrAdmin} + data-testid="admin-widgets" /> )} @@ -208,9 +212,9 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv ); } -function StatCard({ icon: Icon, label, value }: { icon: LucideIcon, label: string, value: string | number }) { +function StatCard({ icon: Icon, label, value, 'data-testid': dataTestId }: { icon: LucideIcon, label: string, value: string | number, 'data-testid'?: string }) { return ( - + diff --git a/apps/website/templates/LeagueScheduleTemplate.tsx b/apps/website/templates/LeagueScheduleTemplate.tsx index eb2a6b113..b1d714acb 100644 --- a/apps/website/templates/LeagueScheduleTemplate.tsx +++ b/apps/website/templates/LeagueScheduleTemplate.tsx @@ -116,6 +116,7 @@ export function LeagueScheduleTemplate({ size="sm" onClick={onCreateRace} icon={} + data-testid="admin-controls" > Add Race @@ -136,6 +137,10 @@ export function LeagueScheduleTemplate({ onRaceDetail={handleRaceDetail} onResultsClick={handleResultsClick} /> + + + + {selectedRace && ( } + data-testid="team-standings-toggle" > {showTeamStandings ? 'Show Driver Standings' : 'Show Team Standings'} @@ -85,7 +86,7 @@ export function LeagueStandingsTemplate({ {/* Championship Stats */} - + @@ -124,7 +125,10 @@ export function LeagueStandingsTemplate({ - + + + + ); } diff --git a/apps/website/templates/LeaguesTemplate.tsx b/apps/website/templates/LeaguesTemplate.tsx index 0f4cecf1c..e9f51ef40 100644 --- a/apps/website/templates/LeaguesTemplate.tsx +++ b/apps/website/templates/LeaguesTemplate.tsx @@ -9,6 +9,7 @@ import { Heading } from '@/ui/Heading'; import { Input } from '@/ui/Input'; import { Button } from '@/ui/Button'; import { Group } from '@/ui/Group'; +import { Box } from '@/ui/Box'; import { Container } from '@/ui/Container'; import { Stack } from '@/ui/Stack'; import { Surface } from '@/ui/Surface'; @@ -103,7 +104,7 @@ export function LeaguesTemplate({ Featured Leagues - + {viewData.leagues .filter(l => (l.usedDriverSlots ?? 0) > 20) @@ -123,9 +124,10 @@ export function LeaguesTemplate({ {/* Control Bar */} + ({ id: c.id, label: c.label, @@ -175,6 +177,7 @@ export function LeaguesTemplate({ )} + ); diff --git a/apps/website/ui/LeagueCard.tsx b/apps/website/ui/LeagueCard.tsx index 9d102a42d..657bfb37b 100644 --- a/apps/website/ui/LeagueCard.tsx +++ b/apps/website/ui/LeagueCard.tsx @@ -61,25 +61,26 @@ export const LeagueCard = ({ isFeatured }: LeagueCardProps) => { return ( - - - @@ -93,12 +94,12 @@ export const LeagueCard = ({ - - + {name} {championshipBadge} @@ -127,7 +128,7 @@ export const LeagueCard = ({ {nextRaceAt && ( - + Next: {new Date(nextRaceAt).toLocaleDateString()} {new Date(nextRaceAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} @@ -135,7 +136,7 @@ export const LeagueCard = ({ )} {activeDriversCount !== undefined && activeDriversCount > 0 && ( - + {activeDriversCount} Active Drivers @@ -153,34 +154,35 @@ export const LeagueCard = ({ {usedSlots} / {maxSlots} - {onQuickJoin && ( - )} {onFollow && ( -