website refactor
This commit is contained in:
@@ -105,16 +105,22 @@ export class RacingMembershipFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spread remaining drivers across remaining leagues to create realistic overlap.
|
// Spread remaining drivers across remaining leagues to create realistic overlap.
|
||||||
|
// IMPORTANT: in postgres mode league ids are UUIDs, so never parse numeric suffixes from ids.
|
||||||
for (const driver of drivers) {
|
for (const driver of drivers) {
|
||||||
const driverId = driver.id.toString();
|
const driverId = driver.id.toString();
|
||||||
const driverNumber = Number(driverId.split('-')[1]);
|
|
||||||
|
|
||||||
for (const league of leagues) {
|
for (const league of leagues) {
|
||||||
const leagueId = league.id.toString();
|
const leagueId = league.id.toString();
|
||||||
if (leagueId === seedId('league-5', this.persistence)) continue;
|
if (leagueId === seedId('league-5', this.persistence)) continue;
|
||||||
if (emptyLeagueId && leagueId === emptyLeagueId) continue;
|
if (emptyLeagueId && leagueId === emptyLeagueId) continue;
|
||||||
|
|
||||||
if (driverNumber % 11 === 0 && leagueId === seedId('league-3', this.persistence)) {
|
// Deterministic membership distribution that works with UUID ids.
|
||||||
|
// Use stable hash of (driverId + leagueId) rather than parsing numeric suffixes.
|
||||||
|
const distKey = `${driverId}:${leagueId}`;
|
||||||
|
const dist = this.stableHash(distKey);
|
||||||
|
|
||||||
|
// Some inactive memberships for league-3 to exercise edge cases.
|
||||||
|
if (leagueId === seedId('league-3', this.persistence) && dist % 11 === 0) {
|
||||||
add({
|
add({
|
||||||
leagueId,
|
leagueId,
|
||||||
driverId,
|
driverId,
|
||||||
@@ -126,7 +132,7 @@ export class RacingMembershipFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sparse membership distribution (not every driver in every league)
|
// Sparse membership distribution (not every driver in every league)
|
||||||
if ((driverNumber + Number(leagueId.split('-')[1] ?? 0)) % 9 === 0) {
|
if (dist % 9 === 0) {
|
||||||
add({
|
add({
|
||||||
leagueId,
|
leagueId,
|
||||||
driverId,
|
driverId,
|
||||||
@@ -330,6 +336,15 @@ export class RacingMembershipFactory {
|
|||||||
return registrations;
|
return registrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stableHash(input: string): number {
|
||||||
|
// Simple deterministic string hash (non-crypto), stable across runs.
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash = (hash * 31 + input.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
private addDays(date: Date, days: number): Date {
|
private addDays(date: Date, days: number): Date {
|
||||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export class PointsTableJsonMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fromJson(serialized: SerializedPointsTable): PointsTable {
|
fromJson(serialized: SerializedPointsTable): PointsTable {
|
||||||
const record: Record<number, number> = {};
|
const record: Map<number, number> = new Map();
|
||||||
for (const [position, points] of Object.entries(serialized.pointsByPosition)) {
|
for (const [position, points] of Object.entries(serialized.pointsByPosition)) {
|
||||||
record[Number(position)] = points;
|
record.set(Number(position), points);
|
||||||
}
|
}
|
||||||
return new PointsTable(record);
|
return new PointsTable(record);
|
||||||
}
|
}
|
||||||
|
|||||||
253
apps/api/src/domain/league/LeagueConfig.integration.test.ts
Normal file
253
apps/api/src/domain/league/LeagueConfig.integration.test.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AuthenticationGuard } from '../auth/AuthenticationGuard';
|
||||||
|
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
|
||||||
|
import type { AuthorizationService } from '../auth/AuthorizationService';
|
||||||
|
import { LeagueModule } from './LeagueModule';
|
||||||
|
|
||||||
|
import { requestContextMiddleware } from '@adapters/http/RequestContext';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DRIVER_REPOSITORY_TOKEN,
|
||||||
|
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||||
|
LEAGUE_REPOSITORY_TOKEN,
|
||||||
|
SEASON_REPOSITORY_TOKEN,
|
||||||
|
LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
|
||||||
|
} from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||||
|
|
||||||
|
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||||
|
import { League } from '@core/racing/domain/entities/League';
|
||||||
|
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
||||||
|
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||||
|
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||||
|
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||||
|
|
||||||
|
describe('League Config API - Integration', () => {
|
||||||
|
let app: import("@nestjs/common").INestApplication;
|
||||||
|
|
||||||
|
const sessionPort: { getCurrentSession: () => Promise<null | { token: string; user: { id: string } }> } = {
|
||||||
|
getCurrentSession: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const authorizationService: Pick<AuthorizationService, 'getRolesForUser'> = {
|
||||||
|
getRolesForUser: vi.fn(() => []),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seedLeagueWithConfig(params: {
|
||||||
|
leagueId: string;
|
||||||
|
ownerId: string;
|
||||||
|
seasonId: string;
|
||||||
|
scoringConfigId: string;
|
||||||
|
gameId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const leagueRepo = app.get(LEAGUE_REPOSITORY_TOKEN);
|
||||||
|
const driverRepo = app.get(DRIVER_REPOSITORY_TOKEN);
|
||||||
|
const membershipRepo = app.get(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN);
|
||||||
|
const seasonRepo = app.get(SEASON_REPOSITORY_TOKEN);
|
||||||
|
const scoringRepo = app.get(LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN);
|
||||||
|
|
||||||
|
// Game is seeded by default, no need to create
|
||||||
|
|
||||||
|
// Create league
|
||||||
|
await leagueRepo.create(
|
||||||
|
League.create({
|
||||||
|
id: params.leagueId,
|
||||||
|
name: 'Test League',
|
||||||
|
description: 'Test league',
|
||||||
|
ownerId: params.ownerId,
|
||||||
|
settings: { visibility: 'ranked' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create owner driver
|
||||||
|
await driverRepo.create(
|
||||||
|
Driver.create({
|
||||||
|
id: params.ownerId,
|
||||||
|
iracingId: '2001',
|
||||||
|
name: 'Owner Driver',
|
||||||
|
country: 'DE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create membership
|
||||||
|
await membershipRepo.saveMembership(
|
||||||
|
LeagueMembership.create({
|
||||||
|
leagueId: params.leagueId,
|
||||||
|
driverId: params.ownerId,
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create season
|
||||||
|
await seasonRepo.create(
|
||||||
|
Season.create({
|
||||||
|
id: params.seasonId,
|
||||||
|
leagueId: params.leagueId,
|
||||||
|
gameId: params.gameId,
|
||||||
|
name: 'Test Season',
|
||||||
|
startDate: new Date('2025-01-01'),
|
||||||
|
endDate: new Date('2025-12-31'),
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create scoring config
|
||||||
|
await scoringRepo.save(
|
||||||
|
LeagueScoringConfig.create({
|
||||||
|
id: params.scoringConfigId,
|
||||||
|
seasonId: params.seasonId,
|
||||||
|
scoringPresetId: 'f1-2024',
|
||||||
|
championships: [
|
||||||
|
{
|
||||||
|
id: 'championship-1',
|
||||||
|
name: 'Main Championship',
|
||||||
|
type: 'driver',
|
||||||
|
sessionTypes: ['main'],
|
||||||
|
pointsTableBySessionType: {
|
||||||
|
practice: new PointsTable({}),
|
||||||
|
qualifying: new PointsTable({}),
|
||||||
|
q1: new PointsTable({}),
|
||||||
|
q2: new PointsTable({}),
|
||||||
|
q3: new PointsTable({}),
|
||||||
|
sprint: new PointsTable({}),
|
||||||
|
main: new PointsTable({ 1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1 }),
|
||||||
|
timeTrial: new PointsTable({}),
|
||||||
|
},
|
||||||
|
dropScorePolicy: { strategy: 'none' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [LeagueModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
|
||||||
|
// Required for getActorFromRequestContext() used by requireLeagueAdminOrOwner().
|
||||||
|
app.use(requestContextMiddleware as never);
|
||||||
|
|
||||||
|
// Test-only auth injection: emulate an authenticated session by setting request.user.
|
||||||
|
app.use((req: unknown, _res: unknown, next: unknown) => {
|
||||||
|
const r = req as { headers: Record<string, string>, user?: { userId: string } };
|
||||||
|
const userId = r.headers['x-test-user-id'];
|
||||||
|
if (typeof userId === 'string' && userId.length > 0) {
|
||||||
|
r.user = { userId };
|
||||||
|
}
|
||||||
|
(next as () => void)();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reflector = new Reflector();
|
||||||
|
app.useGlobalGuards(
|
||||||
|
new AuthenticationGuard(sessionPort as never),
|
||||||
|
new AuthorizationGuard(reflector, authorizationService as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return league config with all dependencies injected', async () => {
|
||||||
|
const leagueId = 'league-1';
|
||||||
|
const ownerId = 'owner-1';
|
||||||
|
const seasonId = 'season-1';
|
||||||
|
const scoringConfigId = 'scoring-1';
|
||||||
|
const gameId = 'game-1';
|
||||||
|
|
||||||
|
await seedLeagueWithConfig({ leagueId, ownerId, seasonId, scoringConfigId, gameId });
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/leagues/${leagueId}/config`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Verify the response contains expected data
|
||||||
|
expect(response.body).toBeDefined();
|
||||||
|
expect(response.body.leagueId).toBeDefined();
|
||||||
|
expect(response.body.leagueId.value).toBe(leagueId);
|
||||||
|
expect(response.body.basics).toBeDefined();
|
||||||
|
expect(response.body.basics.name.value).toBe('Test League');
|
||||||
|
expect(response.body.basics.visibility).toBe('ranked');
|
||||||
|
expect(response.body.scoring).toBeDefined();
|
||||||
|
expect(response.body.scoring.type).toBe('f1-2024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent league', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/leagues/non-existent-league/config')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle league with no season or scoring config', async () => {
|
||||||
|
const leagueId = 'league-2';
|
||||||
|
const ownerId = 'owner-2';
|
||||||
|
|
||||||
|
const leagueRepo = app.get(LEAGUE_REPOSITORY_TOKEN);
|
||||||
|
const driverRepo = app.get(DRIVER_REPOSITORY_TOKEN);
|
||||||
|
const membershipRepo = app.get(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN);
|
||||||
|
|
||||||
|
// Create league without season or scoring config
|
||||||
|
await leagueRepo.create(
|
||||||
|
League.create({
|
||||||
|
id: leagueId,
|
||||||
|
name: 'League Without Config',
|
||||||
|
description: 'Test league',
|
||||||
|
ownerId: ownerId,
|
||||||
|
settings: { visibility: 'unranked' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await driverRepo.create(
|
||||||
|
Driver.create({
|
||||||
|
id: ownerId,
|
||||||
|
iracingId: '2002',
|
||||||
|
name: 'Owner Driver 2',
|
||||||
|
country: 'US',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await membershipRepo.saveMembership(
|
||||||
|
LeagueMembership.create({
|
||||||
|
leagueId: leagueId,
|
||||||
|
driverId: ownerId,
|
||||||
|
role: 'owner',
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/leagues/${leagueId}/config`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Should still return basic league info
|
||||||
|
expect(response.body).toBeDefined();
|
||||||
|
expect(response.body.leagueId).toBeDefined();
|
||||||
|
expect(response.body.leagueId.value).toBe(leagueId);
|
||||||
|
expect(response.body.basics).toBeDefined();
|
||||||
|
expect(response.body.basics.name.value).toBe('League Without Config');
|
||||||
|
expect(response.body.basics.visibility).toBe('unranked');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -192,6 +192,7 @@ export class LeagueController {
|
|||||||
return this.leagueService.getLeagueOwnerSummary(query);
|
return this.leagueService.getLeagueOwnerSummary(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Get(':leagueId/config')
|
@Get(':leagueId/config')
|
||||||
@ApiOperation({ summary: 'Get league full configuration' })
|
@ApiOperation({ summary: 'Get league full configuration' })
|
||||||
@ApiResponse({ status: 200, description: 'League configuration form model', type: LeagueConfigFormModelDTO })
|
@ApiResponse({ status: 200, description: 'League configuration form model', type: LeagueConfigFormModelDTO })
|
||||||
|
|||||||
70
apps/api/src/domain/league/LeagueProviders.test.ts
Normal file
70
apps/api/src/domain/league/LeagueProviders.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { LeagueModule } from './LeagueModule';
|
||||||
|
import {
|
||||||
|
GET_LEAGUE_FULL_CONFIG_USE_CASE,
|
||||||
|
LEAGUE_REPOSITORY_TOKEN,
|
||||||
|
SEASON_REPOSITORY_TOKEN,
|
||||||
|
LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
|
||||||
|
GAME_REPOSITORY_TOKEN,
|
||||||
|
} from './LeagueProviders';
|
||||||
|
|
||||||
|
import { GetLeagueFullConfigUseCase } from '@core/racing/application/use-cases/GetLeagueFullConfigUseCase';
|
||||||
|
import type { LeagueRepository } from '@core/racing/domain/repositories/LeagueRepository';
|
||||||
|
import type { SeasonRepository } from '@core/racing/domain/repositories/SeasonRepository';
|
||||||
|
import type { LeagueScoringConfigRepository } from '@core/racing/domain/repositories/LeagueScoringConfigRepository';
|
||||||
|
import type { GameRepository } from '@core/racing/domain/repositories/GameRepository';
|
||||||
|
|
||||||
|
describe('LeagueProviders - Dependency Injection', () => {
|
||||||
|
let module: import('@nestjs/testing').TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [LeagueModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject GetLeagueFullConfigUseCase with all required dependencies', () => {
|
||||||
|
// Get the use case instance
|
||||||
|
const useCase = module.get<GetLeagueFullConfigUseCase>(GET_LEAGUE_FULL_CONFIG_USE_CASE);
|
||||||
|
|
||||||
|
// Verify the use case is defined
|
||||||
|
expect(useCase).toBeDefined();
|
||||||
|
expect(useCase).toBeInstanceOf(GetLeagueFullConfigUseCase);
|
||||||
|
|
||||||
|
// Get the repositories to verify they exist
|
||||||
|
const leagueRepo = module.get<LeagueRepository>(LEAGUE_REPOSITORY_TOKEN);
|
||||||
|
const seasonRepo = module.get<SeasonRepository>(SEASON_REPOSITORY_TOKEN);
|
||||||
|
const scoringRepo = module.get<LeagueScoringConfigRepository>(LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN);
|
||||||
|
const gameRepo = module.get<GameRepository>(GAME_REPOSITORY_TOKEN);
|
||||||
|
|
||||||
|
// Verify all repositories are defined
|
||||||
|
expect(leagueRepo).toBeDefined();
|
||||||
|
expect(seasonRepo).toBeDefined();
|
||||||
|
expect(scoringRepo).toBeDefined();
|
||||||
|
expect(gameRepo).toBeDefined();
|
||||||
|
|
||||||
|
// Verify the use case has the repositories injected
|
||||||
|
// We can't directly access private properties, but we can verify the use case is functional
|
||||||
|
// by checking that it has the execute method
|
||||||
|
expect(typeof useCase.execute).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have the correct token for GetLeagueFullConfigUseCase', () => {
|
||||||
|
expect(GET_LEAGUE_FULL_CONFIG_USE_CASE).toBe('GetLeagueFullConfigUseCase');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required repository tokens defined', () => {
|
||||||
|
expect(LEAGUE_REPOSITORY_TOKEN).toBe('ILeagueRepository');
|
||||||
|
expect(SEASON_REPOSITORY_TOKEN).toBe('ISeasonRepository');
|
||||||
|
expect(LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN).toBe('ILeagueScoringConfigRepository');
|
||||||
|
expect(GAME_REPOSITORY_TOKEN).toBe('IGameRepository');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -251,7 +251,19 @@ export const LeagueProviders: Provider[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GET_LEAGUE_FULL_CONFIG_USE_CASE,
|
provide: GET_LEAGUE_FULL_CONFIG_USE_CASE,
|
||||||
useClass: GetLeagueFullConfigUseCase,
|
useFactory: (
|
||||||
|
leagueRepo: LeagueRepository,
|
||||||
|
seasonRepo: SeasonRepository,
|
||||||
|
scoringRepo: import('@core/racing/domain/repositories/LeagueScoringConfigRepository').LeagueScoringConfigRepository,
|
||||||
|
gameRepo: import('@core/racing/domain/repositories/GameRepository').GameRepository,
|
||||||
|
) =>
|
||||||
|
new GetLeagueFullConfigUseCase(leagueRepo, seasonRepo, scoringRepo, gameRepo),
|
||||||
|
inject: [
|
||||||
|
LEAGUE_REPOSITORY_TOKEN,
|
||||||
|
SEASON_REPOSITORY_TOKEN,
|
||||||
|
LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
|
||||||
|
GAME_REPOSITORY_TOKEN,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: CREATE_LEAGUE_WITH_SEASON_AND_SCORING_USE_CASE,
|
provide: CREATE_LEAGUE_WITH_SEASON_AND_SCORING_USE_CASE,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class LeagueConfigPresenter implements UseCaseOutputPort<GetLeagueFullCon
|
|||||||
|
|
||||||
present(result: GetLeagueFullConfigResult): void {
|
present(result: GetLeagueFullConfigResult): void {
|
||||||
const dto = result.config as unknown as {
|
const dto = result.config as unknown as {
|
||||||
league: { id: string; name: string; description: string; settings: { pointsSystem: string } };
|
league: { id: string; name: string; description: string; settings: { pointsSystem: string; visibility: string } };
|
||||||
activeSeason?: {
|
activeSeason?: {
|
||||||
stewardingConfig?: {
|
stewardingConfig?: {
|
||||||
decisionMode: string;
|
decisionMode: string;
|
||||||
@@ -39,7 +39,7 @@ export class LeagueConfigPresenter implements UseCaseOutputPort<GetLeagueFullCon
|
|||||||
const schedule = dto.activeSeason?.schedule;
|
const schedule = dto.activeSeason?.schedule;
|
||||||
const scoringConfig = dto.scoringConfig;
|
const scoringConfig = dto.scoringConfig;
|
||||||
|
|
||||||
const visibility: 'public' | 'private' = 'public';
|
const visibility: 'public' | 'private' = settings.visibility === 'ranked' ? 'public' : 'private';
|
||||||
|
|
||||||
const championships = scoringConfig?.championships ?? [];
|
const championships = scoringConfig?.championships ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ export class LeagueDetailViewDataBuilder {
|
|||||||
|
|
||||||
// Calculate info data
|
// Calculate info data
|
||||||
const membersCount = Array.isArray(memberships.members) ? memberships.members.length : 0;
|
const membersCount = Array.isArray(memberships.members) ? memberships.members.length : 0;
|
||||||
const completedRacesCount = races.filter(r => {
|
|
||||||
const status = (r as any).status;
|
// League overview wants total races, not just completed.
|
||||||
return status === 'completed' || status === 'past';
|
// (In seed/demo data many races are `status: running`, which should still count.)
|
||||||
}).length;
|
const racesCount = races.length;
|
||||||
|
|
||||||
// Compute real avgSOF from races
|
// Compute real avgSOF from races
|
||||||
const racesWithSOF = races.filter(r => {
|
const racesWithSOF = races.filter(r => {
|
||||||
const sof = (r as any).strengthOfField;
|
const sof = (r as any).strengthOfField;
|
||||||
@@ -48,12 +48,25 @@ export class LeagueDetailViewDataBuilder {
|
|||||||
const avgSOF = racesWithSOF.length > 0
|
const avgSOF = racesWithSOF.length > 0
|
||||||
? Math.round(racesWithSOF.reduce((sum, r) => sum + ((r as any).strengthOfField || 0), 0) / racesWithSOF.length)
|
? Math.round(racesWithSOF.reduce((sum, r) => sum + ((r as any).strengthOfField || 0), 0) / racesWithSOF.length)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
const race0 = races.length > 0 ? races[0] : null;
|
||||||
|
console.info(
|
||||||
|
'[LeagueDetailViewDataBuilder] leagueId=%s members=%d races=%d racesWithSOF=%d avgSOF=%s race0=%o',
|
||||||
|
league.id,
|
||||||
|
membersCount,
|
||||||
|
racesCount,
|
||||||
|
racesWithSOF.length,
|
||||||
|
String(avgSOF),
|
||||||
|
race0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const info: LeagueInfoData = {
|
const info: LeagueInfoData = {
|
||||||
name: league.name,
|
name: league.name,
|
||||||
description: league.description || '',
|
description: league.description || '',
|
||||||
membersCount,
|
membersCount,
|
||||||
racesCount: completedRacesCount,
|
racesCount,
|
||||||
avgSOF,
|
avgSOF,
|
||||||
structure: `Solo • ${league.settings?.maxDrivers ?? 32} max`,
|
structure: `Solo • ${league.settings?.maxDrivers ?? 32} max`,
|
||||||
scoring: scoringConfig?.scoringPresetId || 'Standard',
|
scoring: scoringConfig?.scoringPresetId || 'Standard',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ describe('DashboardService', () => {
|
|||||||
let service: DashboardService;
|
let service: DashboardService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
process.env.API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
mockApiClient = {
|
mockApiClient = {
|
||||||
getDashboardOverview: vi.fn(),
|
getDashboardOverview: vi.fn(),
|
||||||
getAnalyticsMetrics: vi.fn(),
|
getAnalyticsMetrics: vi.fn(),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe('LandingService', () => {
|
|||||||
let service: LandingService;
|
let service: LandingService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
process.env.API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
mockRacesApi = {
|
mockRacesApi = {
|
||||||
getPageData: vi.fn(),
|
getPageData: vi.fn(),
|
||||||
} as unknown as Mocked<RacesApiClient>;
|
} as unknown as Mocked<RacesApiClient>;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, Mocked, beforeEach } from 'vitest';
|
||||||
import { LeagueService } from './LeagueService';
|
import { LeagueService } from './LeagueService';
|
||||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||||
|
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||||
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
|
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
|
||||||
import type { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
|
import type { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
|
||||||
import type { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueMemberOutputDTO';
|
import type { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueMemberOutputDTO';
|
||||||
|
|
||||||
describe('LeagueService', () => {
|
describe('LeagueService', () => {
|
||||||
let mockApiClient: Mocked<LeaguesApiClient>;
|
let mockApiClient: Mocked<LeaguesApiClient>;
|
||||||
|
let mockRacesApiClient: Mocked<RacesApiClient>;
|
||||||
let service: LeagueService;
|
let service: LeagueService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
process.env.API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001';
|
||||||
mockApiClient = {
|
mockApiClient = {
|
||||||
getAllWithCapacity: vi.fn(),
|
getAllWithCapacity: vi.fn(),
|
||||||
getAllWithCapacityAndScoring: vi.fn(),
|
getAllWithCapacityAndScoring: vi.fn(),
|
||||||
@@ -17,12 +20,20 @@ describe('LeagueService', () => {
|
|||||||
getTotal: vi.fn(),
|
getTotal: vi.fn(),
|
||||||
getSchedule: vi.fn(),
|
getSchedule: vi.fn(),
|
||||||
getMemberships: vi.fn(),
|
getMemberships: vi.fn(),
|
||||||
|
getLeagueConfig: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
removeRosterMember: vi.fn(),
|
removeRosterMember: vi.fn(),
|
||||||
updateRosterMemberRole: vi.fn(),
|
updateRosterMemberRole: vi.fn(),
|
||||||
} as unknown as Mocked<LeaguesApiClient>;
|
} as unknown as Mocked<LeaguesApiClient>;
|
||||||
|
|
||||||
|
mockRacesApiClient = {
|
||||||
|
getPageData: vi.fn(),
|
||||||
|
} as unknown as Mocked<RacesApiClient>;
|
||||||
|
|
||||||
|
mockApiClient.getLeagueConfig.mockResolvedValue({ form: null } as any);
|
||||||
|
|
||||||
service = new LeagueService(mockApiClient);
|
service = new LeagueService(mockApiClient);
|
||||||
|
(service as any).racesApiClient = mockRacesApiClient;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAllLeagues', () => {
|
describe('getAllLeagues', () => {
|
||||||
@@ -145,6 +156,43 @@ describe('LeagueService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getLeagueDetailData', () => {
|
||||||
|
it('should use races page-data to enrich races with status/strengthOfField', async () => {
|
||||||
|
const leagueId = 'league-123';
|
||||||
|
const league = { id: leagueId, name: 'League One', createdAt: '2024-01-01T00:00:00Z' } as any;
|
||||||
|
|
||||||
|
mockApiClient.getAllWithCapacityAndScoring.mockResolvedValue({ totalCount: 1, leagues: [league] } as any);
|
||||||
|
mockApiClient.getMemberships.mockResolvedValue({ members: [] } as any);
|
||||||
|
mockRacesApiClient.getPageData.mockResolvedValue({
|
||||||
|
races: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
track: 'Monza',
|
||||||
|
car: 'GT3',
|
||||||
|
scheduledAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
status: 'running',
|
||||||
|
leagueId,
|
||||||
|
leagueName: 'League One',
|
||||||
|
strengthOfField: 2500,
|
||||||
|
isUpcoming: false,
|
||||||
|
isLive: true,
|
||||||
|
isPast: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await service.getLeagueDetailData(leagueId);
|
||||||
|
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const dto = result.unwrap();
|
||||||
|
expect(dto.races).toHaveLength(1);
|
||||||
|
expect((dto.races[0] as any).status).toBe('running');
|
||||||
|
expect((dto.races[0] as any).strengthOfField).toBe(2500);
|
||||||
|
expect(dto.races[0]!.name).toBe('Monza - GT3');
|
||||||
|
expect(dto.races[0]!.date).toBe('2026-01-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getLeagueMemberships', () => {
|
describe('getLeagueMemberships', () => {
|
||||||
it('should call apiClient.getMemberships and return DTO', async () => {
|
it('should call apiClient.getMemberships and return DTO', async () => {
|
||||||
const leagueId = 'league-123';
|
const leagueId = 'league-123';
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface LeagueDetailData {
|
|||||||
*/
|
*/
|
||||||
@injectable()
|
@injectable()
|
||||||
export class LeagueService implements Service {
|
export class LeagueService implements Service {
|
||||||
|
private readonly baseUrl: string;
|
||||||
private apiClient: LeaguesApiClient;
|
private apiClient: LeaguesApiClient;
|
||||||
private driversApiClient: DriversApiClient;
|
private driversApiClient: DriversApiClient;
|
||||||
private sponsorsApiClient: SponsorsApiClient;
|
private sponsorsApiClient: SponsorsApiClient;
|
||||||
@@ -65,6 +66,7 @@ export class LeagueService implements Service {
|
|||||||
|
|
||||||
constructor(@unmanaged() apiClient?: LeaguesApiClient) {
|
constructor(@unmanaged() apiClient?: LeaguesApiClient) {
|
||||||
const baseUrl = getWebsiteApiBaseUrl();
|
const baseUrl = getWebsiteApiBaseUrl();
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
const logger = new ConsoleLogger();
|
const logger = new ConsoleLogger();
|
||||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||||
showUserNotifications: false,
|
showUserNotifications: false,
|
||||||
@@ -148,11 +150,27 @@ export class LeagueService implements Service {
|
|||||||
|
|
||||||
async getLeagueDetailData(leagueId: string): Promise<Result<LeagueDetailData, DomainError>> {
|
async getLeagueDetailData(leagueId: string): Promise<Result<LeagueDetailData, DomainError>> {
|
||||||
try {
|
try {
|
||||||
const [apiDto, memberships, racesResponse] = await Promise.all([
|
const [apiDto, memberships, racesPageData] = await Promise.all([
|
||||||
this.apiClient.getAllWithCapacityAndScoring(),
|
this.apiClient.getAllWithCapacityAndScoring(),
|
||||||
this.apiClient.getMemberships(leagueId),
|
this.apiClient.getMemberships(leagueId),
|
||||||
this.apiClient.getRaces(leagueId),
|
this.racesApiClient.getPageData(leagueId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
const membershipCount = Array.isArray(memberships?.members) ? memberships.members.length : 0;
|
||||||
|
const racesCount = Array.isArray(racesPageData?.races) ? racesPageData.races.length : 0;
|
||||||
|
const race0 = racesCount > 0 ? racesPageData.races[0] : null;
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
'[LeagueService.getLeagueDetailData] baseUrl=%s leagueId=%s memberships=%d races=%d race0=%o',
|
||||||
|
this.baseUrl,
|
||||||
|
leagueId,
|
||||||
|
membershipCount,
|
||||||
|
racesCount,
|
||||||
|
race0,
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
if (!apiDto || !apiDto.leagues) {
|
if (!apiDto || !apiDto.leagues) {
|
||||||
return Result.err({ type: 'notFound', message: 'Leagues not found' });
|
return Result.err({ type: 'notFound', message: 'Leagues not found' });
|
||||||
@@ -189,12 +207,21 @@ export class LeagueService implements Service {
|
|||||||
console.warn('Failed to fetch league scoring config', e);
|
console.warn('Failed to fetch league scoring config', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const races: RaceDTO[] = (racesPageData.races || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: `${r.track} - ${r.car}`,
|
||||||
|
date: r.scheduledAt,
|
||||||
|
leagueName: r.leagueName,
|
||||||
|
status: r.status,
|
||||||
|
strengthOfField: r.strengthOfField,
|
||||||
|
})) as unknown as RaceDTO[];
|
||||||
|
|
||||||
return Result.ok({
|
return Result.ok({
|
||||||
league,
|
league,
|
||||||
owner,
|
owner,
|
||||||
scoringConfig,
|
scoringConfig,
|
||||||
memberships,
|
memberships,
|
||||||
races: racesResponse.races,
|
races,
|
||||||
sponsors: [], // Sponsors integration can be added here
|
sponsors: [], // Sponsors integration can be added here
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ describe('SponsorService', () => {
|
|||||||
let mockApiClientInstance: any;
|
let mockApiClientInstance: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
process.env.API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
service = new SponsorService();
|
service = new SponsorService();
|
||||||
// @ts-ignore - accessing private property for testing
|
// @ts-ignore - accessing private property for testing
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export class GetLeagueFullConfigUseCase {
|
|||||||
const { leagueId } = input;
|
const { leagueId } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Getting league with ID:', leagueId);
|
||||||
const league = await this.leagueRepository.findById(leagueId);
|
const league = await this.leagueRepository.findById(leagueId);
|
||||||
|
console.log('League result:', league);
|
||||||
if (!league) {
|
if (!league) {
|
||||||
return Result.err({
|
return Result.err({
|
||||||
code: 'LEAGUE_NOT_FOUND',
|
code: 'LEAGUE_NOT_FOUND',
|
||||||
@@ -46,20 +48,40 @@ export class GetLeagueFullConfigUseCase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('League settings:', JSON.stringify(league.settings, null, 2));
|
||||||
|
|
||||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||||
|
console.log('Seasons found:', seasons?.length);
|
||||||
const activeSeason =
|
const activeSeason =
|
||||||
seasons && seasons.length > 0
|
seasons && seasons.length > 0
|
||||||
? seasons.find((s) => s.status.isActive()) ?? seasons[0]
|
? seasons.find((s) => s.status.isActive()) ?? seasons[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
console.log('Active season:', activeSeason?.id);
|
||||||
|
|
||||||
const scoringConfig = await (async () => {
|
const scoringConfig = await (async () => {
|
||||||
if (!activeSeason) return null;
|
if (!activeSeason) return null;
|
||||||
return (await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id)) ?? null;
|
console.log('Getting scoring config for season:', activeSeason.id);
|
||||||
|
try {
|
||||||
|
const result = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
|
||||||
|
console.log('Scoring config result:', result);
|
||||||
|
return result ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting scoring config for season:', activeSeason.id, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const game = await (async () => {
|
const game = await (async () => {
|
||||||
if (!activeSeason || !activeSeason.gameId) return null;
|
if (!activeSeason || !activeSeason.gameId) return null;
|
||||||
return (await this.gameRepository.findById(activeSeason.gameId)) ?? null;
|
console.log('Getting game for game ID:', activeSeason.gameId);
|
||||||
|
try {
|
||||||
|
const result = await this.gameRepository.findById(activeSeason.gameId);
|
||||||
|
console.log('Game result:', result);
|
||||||
|
return result ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting game for game ID:', activeSeason.gameId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const config: LeagueFullConfig = {
|
const config: LeagueFullConfig = {
|
||||||
@@ -80,6 +102,9 @@ export class GetLeagueFullConfigUseCase {
|
|||||||
? error.message
|
? error.message
|
||||||
: 'Failed to load league full configuration';
|
: 'Failed to load league full configuration';
|
||||||
|
|
||||||
|
console.error('GetLeagueFullConfigUseCase error:', error);
|
||||||
|
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack trace');
|
||||||
|
|
||||||
return Result.err({
|
return Result.err({
|
||||||
code: 'REPOSITORY_ERROR',
|
code: 'REPOSITORY_ERROR',
|
||||||
details: { message },
|
details: { message },
|
||||||
|
|||||||
Reference in New Issue
Block a user