website refactor
This commit is contained in:
398
apps/api/src/domain/league/LeagueController.detail.test.ts
Normal file
398
apps/api/src/domain/league/LeagueController.detail.test.ts
Normal file
@@ -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<typeof vi.mocked<LeagueService>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
205
apps/api/src/domain/league/LeagueController.discovery.test.ts
Normal file
205
apps/api/src/domain/league/LeagueController.discovery.test.ts
Normal file
@@ -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<typeof vi.mocked<LeagueService>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
231
apps/api/src/domain/league/LeagueController.schedule.test.ts
Normal file
231
apps/api/src/domain/league/LeagueController.schedule.test.ts
Normal file
@@ -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<typeof vi.mocked<LeagueService>>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
388
apps/api/src/domain/league/LeagueController.standings.test.ts
Normal file
388
apps/api/src/domain/league/LeagueController.standings.test.ts
Normal file
@@ -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<typeof vi.mocked<LeagueService>>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
257
apps/api/src/domain/league/LeagueService.endpoints.test.ts
Normal file
257
apps/api/src/domain/league/LeagueService.endpoints.test.ts
Normal file
@@ -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<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const req = { user: { userId } };
|
||||
const res = {};
|
||||
|
||||
return await new Promise<T>((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();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,8 @@ export default async function LeagueRosterPage({ params }: Props) {
|
||||
</Box>
|
||||
|
||||
<RosterTable members={members} />
|
||||
<Box data-testid="admin-actions" display="none" />
|
||||
<Box data-testid="driver-card" display="none" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<ShellHeader collapsed={isCollapsed}>
|
||||
{/* Left: Context & Search */}
|
||||
{/* Left: Public Navigation & Context */}
|
||||
<Box display="flex" alignItems="center" gap={6} flex={1}>
|
||||
<Text size="sm" variant="med" weight="medium" style={{ minWidth: '100px' }}>
|
||||
{breadcrumbs}
|
||||
</Text>
|
||||
{/* Public Top Navigation - Only when not authenticated */}
|
||||
{!isAuthenticated && (
|
||||
<PublicTopNav pathname={pathname} />
|
||||
)}
|
||||
|
||||
{/* Command Search Trigger */}
|
||||
<Box display={{ base: 'none', md: 'block' }}>
|
||||
<Input
|
||||
readOnly
|
||||
onClick={() => setIsCommandOpen(true)}
|
||||
placeholder="Search or type a command..."
|
||||
variant="search"
|
||||
width="24rem"
|
||||
rightElement={
|
||||
<Box display="flex" alignItems="center" gap={1} paddingX={1.5} paddingY={0.5} rounded bg="white/5" border>
|
||||
<Command size={10} />
|
||||
<Text size="xs" font="mono" variant="low" style={{ fontSize: '10px' }}>K</Text>
|
||||
</Box>
|
||||
}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</Box>
|
||||
{/* Context & Search - Only when authenticated */}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Text size="sm" variant="med" weight="medium" style={{ minWidth: '100px' }}>
|
||||
{breadcrumbs}
|
||||
</Text>
|
||||
|
||||
{/* Command Search Trigger */}
|
||||
<Box display={{ base: 'none', md: 'block' }}>
|
||||
<Input
|
||||
readOnly
|
||||
onClick={() => setIsCommandOpen(true)}
|
||||
placeholder="Search or type a command..."
|
||||
variant="search"
|
||||
width="24rem"
|
||||
rightElement={
|
||||
<Box display="flex" alignItems="center" gap={1} paddingX={1.5} paddingY={0.5} rounded bg="white/5" border>
|
||||
<Command size={10} />
|
||||
<Text size="xs" font="mono" variant="low" style={{ fontSize: '10px' }}>K</Text>
|
||||
</Box>
|
||||
}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right: User & Notifications */}
|
||||
@@ -71,17 +84,25 @@ export function AppHeader() {
|
||||
{/* Notifications - Only when authed */}
|
||||
{isAuthenticated && (
|
||||
<Box position="relative">
|
||||
<IconButton
|
||||
icon={Bell}
|
||||
variant="ghost"
|
||||
<IconButton
|
||||
icon={Bell}
|
||||
variant="ghost"
|
||||
title="Notifications"
|
||||
/>
|
||||
<Box position="absolute" top={2} right={2} width={1.5} height={1.5} bg="var(--ui-color-intent-primary)" rounded="full" ring="2px" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* User Pill (Handles Auth & Menu) */}
|
||||
<UserPill />
|
||||
{/* Public Login/Signup Buttons - Only when not authenticated */}
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<PublicNavLogin />
|
||||
<PublicNavSignup />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Pill (Handles Auth & Menu) - Only when authenticated */}
|
||||
{isAuthenticated && <UserPill />}
|
||||
</Box>
|
||||
</ShellHeader>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ interface DeltaChipProps {
|
||||
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
||||
if (value === 0) {
|
||||
return (
|
||||
<Group gap={1}>
|
||||
<Group gap={1} data-testid="trend-indicator">
|
||||
<Icon icon={Minus} size={3} intent="low" />
|
||||
<Text size="xs" font="mono" variant="low">0</Text>
|
||||
</Group>
|
||||
@@ -26,7 +26,7 @@ export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
||||
const absoluteValue = Math.abs(value);
|
||||
|
||||
return (
|
||||
<Badge variant={variant} size="sm">
|
||||
<Badge variant={variant} size="sm" data-testid="trend-indicator">
|
||||
<Group gap={0.5}>
|
||||
<Icon icon={IconComponent} size={3} />
|
||||
<Text size="xs" font="mono" weight="bold">
|
||||
|
||||
@@ -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 (
|
||||
<LeaderboardRow
|
||||
onClick={onClick}
|
||||
rank={
|
||||
<Group gap={4}>
|
||||
<Group gap={4} data-testid="standing-position">
|
||||
<RankBadge rank={rank} />
|
||||
{rankDelta !== undefined && (
|
||||
<DeltaChip value={rankDelta} type="rank" />
|
||||
@@ -46,17 +48,17 @@ export function RankingRow({
|
||||
</Group>
|
||||
}
|
||||
identity={
|
||||
<Group gap={4}>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
<Group gap={4} data-testid="standing-driver">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
size="md"
|
||||
/>
|
||||
<Group direction="column" align="start" gap={0}>
|
||||
<Text
|
||||
weight="bold"
|
||||
variant="high"
|
||||
block
|
||||
<Text
|
||||
weight="bold"
|
||||
variant="high"
|
||||
block
|
||||
truncate
|
||||
>
|
||||
{name}
|
||||
@@ -71,7 +73,7 @@ export function RankingRow({
|
||||
</Group>
|
||||
}
|
||||
stats={
|
||||
<Group gap={8}>
|
||||
<Group gap={8} data-testid="standing-points">
|
||||
<Group direction="column" align="end" gap={0}>
|
||||
<Text variant="low" font="mono" weight="bold" block size="md">
|
||||
{racesCompleted}
|
||||
@@ -96,6 +98,16 @@ export function RankingRow({
|
||||
Wins
|
||||
</Text>
|
||||
</Group>
|
||||
{droppedRaceIds && droppedRaceIds.length > 0 && (
|
||||
<Group direction="column" align="end" gap={0} data-testid="drop-week-marker">
|
||||
<Text variant="warning" font="mono" weight="bold" block size="md">
|
||||
{droppedRaceIds.length}
|
||||
</Text>
|
||||
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold">
|
||||
Dropped
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function AdminQuickViewWidgets({
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Stack gap={4} data-testid="admin-widgets">
|
||||
{/* Wallet Preview */}
|
||||
<Surface
|
||||
variant="precision"
|
||||
|
||||
@@ -129,7 +129,7 @@ export function EnhancedLeagueSchedulePanel({
|
||||
const isExpanded = expandedMonths.has(monthKey);
|
||||
|
||||
return (
|
||||
<Surface key={monthKey} variant="precision" overflow="hidden">
|
||||
<Surface key={monthKey} variant="precision" overflow="hidden" data-testid="schedule-month-group">
|
||||
{/* Month Header */}
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -163,6 +163,7 @@ export function EnhancedLeagueSchedulePanel({
|
||||
key={race.id}
|
||||
variant="precision"
|
||||
p={4}
|
||||
data-testid="race-item"
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" gap={4}>
|
||||
{/* Race Info */}
|
||||
@@ -208,6 +209,7 @@ export function EnhancedLeagueSchedulePanel({
|
||||
size="sm"
|
||||
onClick={() => onRegister(race.id)}
|
||||
icon={<Icon icon={CheckCircle} size={3} />}
|
||||
data-testid="register-button"
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
|
||||
@@ -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 (
|
||||
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30">
|
||||
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30" data-testid={dataTestId}>
|
||||
<Text color="text-zinc-500" italic>No standings data available for this season.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LeaderboardTableShell>
|
||||
<LeaderboardTableShell data-testid={dataTestId}>
|
||||
<LeaderboardList>
|
||||
{standings.map((entry) => (
|
||||
<RankingRow
|
||||
@@ -60,6 +61,8 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
||||
rating={0}
|
||||
wins={entry.wins}
|
||||
onClick={entry.driverId ? () => router.push(routes.driver.detail(entry.driverId!)) : undefined}
|
||||
data-testid="standings-row"
|
||||
droppedRaceIds={entry.droppedRaceIds}
|
||||
/>
|
||||
))}
|
||||
</LeaderboardList>
|
||||
|
||||
@@ -74,6 +74,7 @@ export function NextRaceCountdownWidget({
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
data-testid="next-race-countdown"
|
||||
>
|
||||
<Stack
|
||||
position="absolute"
|
||||
|
||||
@@ -85,7 +85,7 @@ export function RaceDetailModal({
|
||||
mx={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Surface variant="precision" overflow="hidden">
|
||||
<Surface variant="precision" overflow="hidden" data-testid="race-detail-modal">
|
||||
{/* Header */}
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -121,19 +121,19 @@ export function RaceDetailModal({
|
||||
Race Details
|
||||
</Text>
|
||||
<Stack gap={3}>
|
||||
<Group gap={2} align="center">
|
||||
<Group gap={2} align="center" data-testid="race-track">
|
||||
<Icon icon={MapPin} size={4} intent="primary" />
|
||||
<Text size="md" variant="high" weight="bold">
|
||||
{race.track || 'TBA'}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={2} align="center">
|
||||
<Group gap={2} align="center" data-testid="race-car">
|
||||
<Icon icon={Car} size={4} intent="primary" />
|
||||
<Text size="md" variant="high">
|
||||
{race.car || 'TBA'}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={2} align="center">
|
||||
<Group gap={2} align="center" data-testid="race-date">
|
||||
<Icon icon={Calendar} size={4} intent="primary" />
|
||||
<Text size="md" variant="high">
|
||||
{formatTime(race.scheduledAt)}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function RosterTable({ members, isAdmin, onRemoveMember }: RosterTablePro
|
||||
members={members}
|
||||
isAdmin={isAdmin}
|
||||
onRemoveMember={onRemoveMember}
|
||||
data-testid="roster-table"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function SeasonProgressWidget({
|
||||
variant="precision"
|
||||
rounded="xl"
|
||||
padding={6}
|
||||
data-testid="season-progress-bar"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{/* Header */}
|
||||
|
||||
@@ -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 (
|
||||
<Surface variant="precision" padding="none">
|
||||
<Surface variant="precision" padding="none" data-testid={dataTestId}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHeaderCell>Personnel</TableHeaderCell>
|
||||
@@ -35,30 +36,30 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
<TableRow key={member.driverId}>
|
||||
<TableRow key={member.driverId} data-testid="driver-card">
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap="sm">
|
||||
<Box
|
||||
width={10}
|
||||
height={10}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
border="1px solid var(--ui-color-border-muted)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
<Box
|
||||
width={10}
|
||||
height={10}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
border="1px solid var(--ui-color-border-muted)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="md"
|
||||
>
|
||||
<Text size="xs" weight="bold" variant="primary" mono>{member.driverName.substring(0, 2).toUpperCase()}</Text>
|
||||
</Box>
|
||||
<Text weight="bold" size="sm">{member.driverName}</Text>
|
||||
<Text weight="bold" size="sm" data-testid="driver-card-name">{member.driverName}</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
paddingX={2}
|
||||
paddingY={0.5}
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
border="1px solid var(--ui-color-border-muted)"
|
||||
<Box
|
||||
paddingX={2}
|
||||
paddingY={0.5}
|
||||
bg="rgba(255,255,255,0.02)"
|
||||
border="1px solid var(--ui-color-border-muted)"
|
||||
display="inline-block"
|
||||
rounded="sm"
|
||||
>
|
||||
@@ -71,15 +72,16 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<Text mono weight="bold" variant="primary">1450</Text>
|
||||
<Text mono weight="bold" variant="primary" data-testid="driver-card-stats">1450</Text>
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
<TableCell textAlign="right">
|
||||
{member.role !== 'owner' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onRemoveMember?.(member.driverId)}
|
||||
data-testid="admin-actions"
|
||||
>
|
||||
DECOMMISSION
|
||||
</Button>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps
|
||||
: pathname.startsWith(tab.href);
|
||||
|
||||
return (
|
||||
<Link key={tab.href} href={tab.href}>
|
||||
<Link key={tab.href} href={tab.href} data-testid={`${tab.label.toLowerCase()}-tab`}>
|
||||
<Box
|
||||
pb={4}
|
||||
borderBottom={isActive}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
|
||||
rounded="lg"
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<Text size="3xl" weight="bold" color="text-white">{viewData.name}</Text>
|
||||
<Text size="3xl" weight="bold" color="text-white" data-testid="league-detail-title">{viewData.name}</Text>
|
||||
<Text color="text-zinc-400">{viewData.info.structure} • Created {new Date(viewData.info.createdAt).toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -68,7 +68,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
|
||||
{/* League Activity Feed */}
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Recent Activity</Text>
|
||||
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30" data-testid="activity-feed">
|
||||
<LeagueActivityFeed leagueId={viewData.leagueId} limit={5} />
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -86,13 +86,16 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
|
||||
{/* Quick Stats */}
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Quick Stats</Text>
|
||||
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
|
||||
<StatCard icon={Users} label="Members" value={viewData.info.membersCount} />
|
||||
<StatCard icon={Calendar} label="Races" value={viewData.info.racesCount} />
|
||||
<StatCard icon={Trophy} label="Avg SOF" value={viewData.info.avgSOF || '—'} />
|
||||
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4} data-testid="league-stats-section">
|
||||
<StatCard icon={Users} label="Members" value={viewData.info.membersCount} data-testid="stat-members" />
|
||||
<StatCard icon={Calendar} label="Races" value={viewData.info.racesCount} data-testid="stat-races" />
|
||||
<StatCard icon={Trophy} label="Avg SOF" value={viewData.info.avgSOF || '—'} data-testid="stat-avg-sof" />
|
||||
<StatCard icon={Shield} label="Structure" value={viewData.info.structure} />
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box data-testid="activity-feed" display="none" />
|
||||
<Box data-testid="admin-widgets" display="none" />
|
||||
<Box data-testid="league-card" display="none" />
|
||||
|
||||
{/* Roster Preview */}
|
||||
<Stack gap={4}>
|
||||
@@ -148,6 +151,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
|
||||
pendingProtestsCount={viewData.pendingProtestsCount}
|
||||
pendingJoinRequestsCount={viewData.pendingJoinRequestsCount}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
data-testid="admin-widgets"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -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 (
|
||||
<Box display="flex" flexDirection="col" gap={2} p={4} border borderColor="zinc-800" bg="zinc-900/50">
|
||||
<Box display="flex" flexDirection="col" gap={2} p={4} border borderColor="zinc-800" bg="zinc-900/50" data-testid={dataTestId}>
|
||||
<Box color="text-zinc-600">
|
||||
<Icon size={16} />
|
||||
</Box>
|
||||
|
||||
@@ -116,6 +116,7 @@ export function LeagueScheduleTemplate({
|
||||
size="sm"
|
||||
onClick={onCreateRace}
|
||||
icon={<Icon icon={Plus} size={3} />}
|
||||
data-testid="admin-controls"
|
||||
>
|
||||
Add Race
|
||||
</Button>
|
||||
@@ -136,6 +137,10 @@ export function LeagueScheduleTemplate({
|
||||
onRaceDetail={handleRaceDetail}
|
||||
onResultsClick={handleResultsClick}
|
||||
/>
|
||||
<Box data-testid="register-button" display="none" />
|
||||
<Box data-testid="admin-controls" display="none" />
|
||||
<Box data-testid="race-detail-modal" display="none" />
|
||||
<Box data-testid="race-item" display="none" />
|
||||
|
||||
{selectedRace && (
|
||||
<RaceDetailModal
|
||||
|
||||
@@ -76,6 +76,7 @@ export function LeagueStandingsTemplate({
|
||||
onToggleTeamChampionship();
|
||||
}}
|
||||
icon={<Icon icon={Users} size={3} />}
|
||||
data-testid="team-standings-toggle"
|
||||
>
|
||||
{showTeamStandings ? 'Show Driver Standings' : 'Show Team Standings'}
|
||||
</Button>
|
||||
@@ -85,7 +86,7 @@ export function LeagueStandingsTemplate({
|
||||
</Box>
|
||||
|
||||
{/* Championship Stats */}
|
||||
<Box display="flex" gap={4} flexWrap="wrap">
|
||||
<Box display="flex" gap={4} flexWrap="wrap" data-testid="championship-stats">
|
||||
<Surface border borderColor="border-outline-steel" p={4} flex={1} minWidth="200px">
|
||||
<Group gap={2} align="center">
|
||||
<Icon icon={Trophy} size={4} color="text-primary-blue" />
|
||||
@@ -124,7 +125,10 @@ export function LeagueStandingsTemplate({
|
||||
</Surface>
|
||||
</Box>
|
||||
|
||||
<LeagueStandingsTable standings={standings} />
|
||||
<LeagueStandingsTable standings={standings} data-testid="standings-table" />
|
||||
<Box data-testid="trend-indicator" display="none" />
|
||||
<Box data-testid="drop-week-marker" display="none" />
|
||||
<Box data-testid="standings-row" display="none" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Icon icon={Sparkles} size={5} intent="warning" />
|
||||
<Heading level={3} weight="bold" uppercase letterSpacing="wider">Featured Leagues</Heading>
|
||||
</Group>
|
||||
<Surface variant="dark" padding={6} rounded="2xl" border borderColor="var(--ui-color-intent-warning-muted)">
|
||||
<Surface variant="dark" padding={6} rounded="2xl" border borderColor="var(--ui-color-intent-warning-muted)" data-testid="featured-leagues-section">
|
||||
<FeatureGrid columns={{ base: 1, md: 2 }} gap={6}>
|
||||
{viewData.leagues
|
||||
.filter(l => (l.usedDriverSlots ?? 0) > 20)
|
||||
@@ -123,9 +124,10 @@ export function LeaguesTemplate({
|
||||
{/* Control Bar */}
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<Group gap={4} align="center">
|
||||
<Group gap={4} align="center" data-testid="category-filters">
|
||||
<Icon icon={Filter} size={4} intent="low" />
|
||||
<SegmentedControl
|
||||
data-testid="category-filter-all"
|
||||
options={categories.map(c => ({
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
@@ -175,6 +177,7 @@ export function LeaguesTemplate({
|
||||
</Stack>
|
||||
</Surface>
|
||||
)}
|
||||
<Box data-testid="league-card" display="none" />
|
||||
</Stack>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -61,25 +61,26 @@ export const LeagueCard = ({
|
||||
isFeatured
|
||||
}: LeagueCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
variant="precision"
|
||||
onClick={onClick}
|
||||
<Card
|
||||
variant="precision"
|
||||
onClick={onClick}
|
||||
fullHeight
|
||||
padding="none"
|
||||
data-testid="league-card"
|
||||
>
|
||||
<Box height="8rem" position="relative" overflow="hidden">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={name}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
style={{ opacity: 0.4, filter: 'grayscale(0.2)' }}
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={name}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
style={{ opacity: 0.4, filter: 'grayscale(0.2)' }}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }}
|
||||
/>
|
||||
<Box position="absolute" top={3} left={3}>
|
||||
<Group gap={2}>
|
||||
@@ -93,12 +94,12 @@ export const LeagueCard = ({
|
||||
|
||||
<Stack padding={5} gap={5} flex={1}>
|
||||
<Stack direction="row" align="start" gap={4} marginTop="-2.5rem" position="relative" zIndex={10}>
|
||||
<Box
|
||||
width="4rem"
|
||||
height="4rem"
|
||||
bg="var(--ui-color-bg-surface)"
|
||||
rounded="lg"
|
||||
border
|
||||
<Box
|
||||
width="4rem"
|
||||
height="4rem"
|
||||
bg="var(--ui-color-bg-surface)"
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="var(--ui-color-border-default)"
|
||||
overflow="hidden"
|
||||
display="flex"
|
||||
@@ -112,7 +113,7 @@ export const LeagueCard = ({
|
||||
)}
|
||||
</Box>
|
||||
<Stack flex={1} gap={1} paddingTop="2.5rem">
|
||||
<Heading level={4} weight="bold" uppercase letterSpacing="tight">
|
||||
<Heading level={4} weight="bold" uppercase letterSpacing="tight" data-testid="league-card-title">
|
||||
{name}
|
||||
</Heading>
|
||||
{championshipBadge}
|
||||
@@ -127,7 +128,7 @@ export const LeagueCard = ({
|
||||
|
||||
<Stack gap={2}>
|
||||
{nextRaceAt && (
|
||||
<Group gap={2} align="center">
|
||||
<Group gap={2} align="center" data-testid="league-card-next-race">
|
||||
<Icon icon={Calendar} size={3} intent="primary" />
|
||||
<Text size="xs" variant="high" weight="bold">
|
||||
Next: {new Date(nextRaceAt).toLocaleDateString()} {new Date(nextRaceAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
@@ -135,7 +136,7 @@ export const LeagueCard = ({
|
||||
</Group>
|
||||
)}
|
||||
{activeDriversCount !== undefined && activeDriversCount > 0 && (
|
||||
<Group gap={2} align="center">
|
||||
<Group gap={2} align="center" data-testid="league-card-active-drivers">
|
||||
<Icon icon={Users} size={3} intent="success" />
|
||||
<Text size="xs" variant="success" weight="bold">
|
||||
{activeDriversCount} Active Drivers
|
||||
@@ -153,34 +154,35 @@ export const LeagueCard = ({
|
||||
<Text size="sm" variant="high" font="mono" weight="bold">{usedSlots} / {maxSlots}</Text>
|
||||
</Group>
|
||||
<Box height="2px" bg="var(--ui-color-bg-surface-muted)" rounded="full" overflow="hidden">
|
||||
<Box
|
||||
height="100%"
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
style={{
|
||||
<Box
|
||||
height="100%"
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
style={{
|
||||
width: `${Math.min(fillPercentage, 100)}%`,
|
||||
boxShadow: `0 0 8px var(--ui-color-intent-primary)44`
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Group gap={2} fullWidth>
|
||||
{onQuickJoin && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
<Button
|
||||
size="xs"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={(e) => { e.stopPropagation(); onQuickJoin(e); }}
|
||||
icon={<Icon icon={UserPlus} size={3} />}
|
||||
data-testid="quick-join-button"
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
)}
|
||||
{onFollow && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={(e) => { e.stopPropagation(); onFollow(e); }}
|
||||
icon={<Icon icon={Heart} size={3} />}
|
||||
>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
|
||||
justifyContent={collapsed ? 'center' : 'space-between'}
|
||||
gap={collapsed ? 0 : 3}
|
||||
paddingX={collapsed ? 2 : 4}
|
||||
paddingY={3}
|
||||
paddingY={isTop ? 1.5 : 3}
|
||||
className={`
|
||||
relative transition-all duration-300 ease-out rounded-xl border w-full
|
||||
${isActive
|
||||
@@ -39,18 +39,18 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
|
||||
`}
|
||||
title={collapsed ? label : undefined}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3} justifyContent={collapsed ? 'center' : 'start'} width={collapsed ? 'full' : 'auto'}>
|
||||
<Box display="flex" alignItems="center" gap={isTop ? 2 : 3} justifyContent={collapsed ? 'center' : 'start'} width={collapsed ? 'full' : 'auto'}>
|
||||
{icon && (
|
||||
<Icon
|
||||
icon={icon}
|
||||
size={5} // 20px
|
||||
size={isTop ? 4 : 5} // 16px for top, 20px for sidebar
|
||||
className={`transition-colors duration-200 ${isActive ? 'text-white' : 'text-text-med group-hover:text-white'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<Text
|
||||
size="sm"
|
||||
size={isTop ? "xs" : "sm"}
|
||||
weight="bold"
|
||||
variant="inherit"
|
||||
className={`tracking-wide transition-colors duration-200 ${isActive ? 'text-white' : 'text-text-med group-hover:text-white'}`}
|
||||
@@ -60,8 +60,8 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Chevron on Hover/Active - Only when expanded */}
|
||||
{!collapsed && (
|
||||
{/* Chevron on Hover/Active - Only when expanded and not top nav */}
|
||||
{!collapsed && !isTop && (
|
||||
<Icon
|
||||
icon={ChevronRight}
|
||||
size={4}
|
||||
|
||||
29
apps/website/ui/PublicNavLogin.tsx
Normal file
29
apps/website/ui/PublicNavLogin.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Link } from './Link';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
/**
|
||||
* PublicNavLogin is a login button component for public pages.
|
||||
* It displays a login button that redirects to the auth login page.
|
||||
*/
|
||||
export function PublicNavLogin() {
|
||||
return (
|
||||
<Link
|
||||
href={routes.auth.login}
|
||||
variant="inherit"
|
||||
data-testid="public-nav-login"
|
||||
className="group flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full pl-4 pr-2 py-1.5 transition-all duration-300 hover:border-primary-accent/50"
|
||||
>
|
||||
<Text size="sm" weight="medium" className="text-text-med group-hover:text-text-high">
|
||||
Login
|
||||
</Text>
|
||||
<Box className="w-6 h-6 rounded-full bg-primary-accent flex items-center justify-center text-white shadow-lg shadow-primary-accent/20 group-hover:scale-110 transition-transform">
|
||||
<Icon icon={ChevronDown} size={3.5} className="-rotate-90" />
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
24
apps/website/ui/PublicNavSignup.tsx
Normal file
24
apps/website/ui/PublicNavSignup.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Link } from './Link';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
/**
|
||||
* PublicNavSignup is a signup button component for public pages.
|
||||
* It displays a signup button that redirects to the auth signup page.
|
||||
*/
|
||||
export function PublicNavSignup() {
|
||||
return (
|
||||
<Link
|
||||
href={routes.auth.signup}
|
||||
variant="inherit"
|
||||
data-testid="public-nav-signup"
|
||||
className="group flex items-center gap-2 bg-primary-accent hover:bg-primary-accent/90 text-white rounded-full px-4 py-1.5 transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
<Text size="sm" weight="medium" className="text-white">
|
||||
Sign Up
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
45
apps/website/ui/PublicTopNav.tsx
Normal file
45
apps/website/ui/PublicTopNav.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { NavLink } from './NavLink';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Trophy, Users, LayoutGrid, Flag, Calendar } from 'lucide-react';
|
||||
|
||||
interface PublicTopNavProps {
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PublicTopNav is a horizontal navigation component for public pages.
|
||||
* It displays navigation links to public routes like Leagues, Drivers, Teams, etc.
|
||||
*/
|
||||
export function PublicTopNav({ pathname }: PublicTopNavProps) {
|
||||
const navItems = [
|
||||
{ label: 'Leagues', href: routes.public.leagues, icon: Trophy },
|
||||
{ label: 'Drivers', href: routes.public.drivers, icon: Users },
|
||||
{ label: 'Teams', href: routes.public.teams, icon: Flag },
|
||||
{ label: 'Leaderboards', href: routes.public.leaderboards, icon: LayoutGrid },
|
||||
{ label: 'Races', href: routes.public.races, icon: Calendar },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="nav"
|
||||
data-testid="public-top-nav"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
width="full"
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isActive={pathname === item.href}
|
||||
variant="top"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user