integration tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 4m51s
Contract Testing / contract-snapshot (pull_request) Has been skipped

This commit is contained in:
2026-01-22 17:29:06 +01:00
parent f61ebda9b7
commit 597bb48248
68 changed files with 11832 additions and 3498 deletions

View File

@@ -0,0 +1,77 @@
/**
* Driver Rankings Query Port
*
* Defines the interface for querying driver rankings data.
* This is a read-only query with search, filter, and sort capabilities.
*/
/**
* Query input for driver rankings
*/
export interface DriverRankingsQuery {
/**
* Search term for filtering drivers by name (case-insensitive)
*/
search?: string;
/**
* Minimum rating filter
*/
minRating?: number;
/**
* Filter by team ID
*/
teamId?: string;
/**
* Sort field (default: rating)
*/
sortBy?: 'rating' | 'name' | 'rank' | 'raceCount';
/**
* Sort order (default: desc)
*/
sortOrder?: 'asc' | 'desc';
/**
* Page number (default: 1)
*/
page?: number;
/**
* Number of results per page (default: 20)
*/
limit?: number;
}
/**
* Driver entry for rankings
*/
export interface DriverRankingEntry {
rank: number;
id: string;
name: string;
rating: number;
teamId?: string;
teamName?: string;
raceCount: number;
}
/**
* Pagination metadata
*/
export interface PaginationMetadata {
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Driver rankings result
*/
export interface DriverRankingsResult {
drivers: DriverRankingEntry[];
pagination: PaginationMetadata;
}

View File

@@ -0,0 +1,54 @@
/**
* Global Leaderboards Query Port
*
* Defines the interface for querying global leaderboards data.
* This is a read-only query for retrieving top drivers and teams.
*/
/**
* Query input for global leaderboards
*/
export interface GlobalLeaderboardsQuery {
/**
* Maximum number of drivers to return (default: 10)
*/
driverLimit?: number;
/**
* Maximum number of teams to return (default: 10)
*/
teamLimit?: number;
}
/**
* Driver entry for global leaderboards
*/
export interface GlobalLeaderboardDriverEntry {
rank: number;
id: string;
name: string;
rating: number;
teamId?: string;
teamName?: string;
raceCount: number;
}
/**
* Team entry for global leaderboards
*/
export interface GlobalLeaderboardTeamEntry {
rank: number;
id: string;
name: string;
rating: number;
memberCount: number;
raceCount: number;
}
/**
* Global leaderboards result
*/
export interface GlobalLeaderboardsResult {
drivers: GlobalLeaderboardDriverEntry[];
teams: GlobalLeaderboardTeamEntry[];
}

View File

@@ -0,0 +1,69 @@
/**
* Leaderboards Event Publisher Port
*
* Defines the interface for publishing leaderboards-related events.
*/
/**
* Global leaderboards accessed event
*/
export interface GlobalLeaderboardsAccessedEvent {
type: 'global_leaderboards_accessed';
timestamp: Date;
}
/**
* Driver rankings accessed event
*/
export interface DriverRankingsAccessedEvent {
type: 'driver_rankings_accessed';
timestamp: Date;
}
/**
* Team rankings accessed event
*/
export interface TeamRankingsAccessedEvent {
type: 'team_rankings_accessed';
timestamp: Date;
}
/**
* Leaderboards error event
*/
export interface LeaderboardsErrorEvent {
type: 'leaderboards_error';
error: string;
timestamp: Date;
}
/**
* Leaderboards Event Publisher Interface
*
* Publishes events related to leaderboards operations.
*/
export interface LeaderboardsEventPublisher {
/**
* Publish a global leaderboards accessed event
* @param event - The event to publish
*/
publishGlobalLeaderboardsAccessed(event: GlobalLeaderboardsAccessedEvent): Promise<void>;
/**
* Publish a driver rankings accessed event
* @param event - The event to publish
*/
publishDriverRankingsAccessed(event: DriverRankingsAccessedEvent): Promise<void>;
/**
* Publish a team rankings accessed event
* @param event - The event to publish
*/
publishTeamRankingsAccessed(event: TeamRankingsAccessedEvent): Promise<void>;
/**
* Publish a leaderboards error event
* @param event - The event to publish
*/
publishLeaderboardsError(event: LeaderboardsErrorEvent): Promise<void>;
}

View File

@@ -0,0 +1,55 @@
/**
* Leaderboards Repository Port
*
* Defines the interface for accessing leaderboards-related data.
* This is a read-only repository for leaderboards data aggregation.
*/
/**
* Driver data for leaderboards
*/
export interface LeaderboardDriverData {
id: string;
name: string;
rating: number;
teamId?: string;
teamName?: string;
raceCount: number;
}
/**
* Team data for leaderboards
*/
export interface LeaderboardTeamData {
id: string;
name: string;
rating: number;
memberCount: number;
raceCount: number;
}
/**
* Leaderboards Repository Interface
*
* Provides access to all data needed for leaderboards.
*/
export interface LeaderboardsRepository {
/**
* Find all drivers for leaderboards
* @returns Array of driver data
*/
findAllDrivers(): Promise<LeaderboardDriverData[]>;
/**
* Find all teams for leaderboards
* @returns Array of team data
*/
findAllTeams(): Promise<LeaderboardTeamData[]>;
/**
* Find drivers by team ID
* @param teamId - The team ID
* @returns Array of driver data
*/
findDriversByTeamId(teamId: string): Promise<LeaderboardDriverData[]>;
}

View File

@@ -0,0 +1,76 @@
/**
* Team Rankings Query Port
*
* Defines the interface for querying team rankings data.
* This is a read-only query with search, filter, and sort capabilities.
*/
/**
* Query input for team rankings
*/
export interface TeamRankingsQuery {
/**
* Search term for filtering teams by name (case-insensitive)
*/
search?: string;
/**
* Minimum rating filter
*/
minRating?: number;
/**
* Minimum member count filter
*/
minMemberCount?: number;
/**
* Sort field (default: rating)
*/
sortBy?: 'rating' | 'name' | 'rank' | 'memberCount';
/**
* Sort order (default: desc)
*/
sortOrder?: 'asc' | 'desc';
/**
* Page number (default: 1)
*/
page?: number;
/**
* Number of results per page (default: 20)
*/
limit?: number;
}
/**
* Team entry for rankings
*/
export interface TeamRankingEntry {
rank: number;
id: string;
name: string;
rating: number;
memberCount: number;
raceCount: number;
}
/**
* Pagination metadata
*/
export interface PaginationMetadata {
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Team rankings result
*/
export interface TeamRankingsResult {
teams: TeamRankingEntry[];
pagination: PaginationMetadata;
}

View File

@@ -0,0 +1,163 @@
/**
* Get Driver Rankings Use Case
*
* Orchestrates the retrieval of driver rankings data.
* Aggregates data from repositories and returns drivers with search, filter, and sort capabilities.
*/
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
import {
DriverRankingsQuery,
DriverRankingsResult,
DriverRankingEntry,
PaginationMetadata,
} from '../ports/DriverRankingsQuery';
import { ValidationError } from '../../../shared/errors/ValidationError';
export interface GetDriverRankingsUseCasePorts {
leaderboardsRepository: LeaderboardsRepository;
eventPublisher: LeaderboardsEventPublisher;
}
export class GetDriverRankingsUseCase {
constructor(private readonly ports: GetDriverRankingsUseCasePorts) {}
async execute(query: DriverRankingsQuery = {}): Promise<DriverRankingsResult> {
try {
// Validate query parameters
this.validateQuery(query);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
// Fetch all drivers
const allDrivers = await this.ports.leaderboardsRepository.findAllDrivers();
// Apply search filter
let filteredDrivers = allDrivers;
if (query.search) {
const searchLower = query.search.toLowerCase();
filteredDrivers = filteredDrivers.filter((driver) =>
driver.name.toLowerCase().includes(searchLower),
);
}
// Apply rating filter
if (query.minRating !== undefined) {
filteredDrivers = filteredDrivers.filter(
(driver) => driver.rating >= query.minRating!,
);
}
// Apply team filter
if (query.teamId) {
filteredDrivers = filteredDrivers.filter(
(driver) => driver.teamId === query.teamId,
);
}
// Sort drivers
const sortBy = query.sortBy ?? 'rating';
const sortOrder = query.sortOrder ?? 'desc';
filteredDrivers.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'rating':
comparison = a.rating - b.rating;
break;
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'rank':
comparison = 0;
break;
case 'raceCount':
comparison = a.raceCount - b.raceCount;
break;
}
// If primary sort is equal, always use name ASC as secondary sort
if (comparison === 0 && sortBy !== 'name') {
comparison = a.name.localeCompare(b.name);
// Secondary sort should not be affected by sortOrder of primary field?
// Actually, usually secondary sort is always ASC or follows primary.
// Let's keep it simple: if primary is equal, use name ASC.
return comparison;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
// Calculate pagination
const total = filteredDrivers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = Math.min(startIndex + limit, total);
// Get paginated drivers
const paginatedDrivers = filteredDrivers.slice(startIndex, endIndex);
// Map to ranking entries with rank
const driverEntries: DriverRankingEntry[] = paginatedDrivers.map(
(driver, index): DriverRankingEntry => ({
rank: startIndex + index + 1,
id: driver.id,
name: driver.name,
rating: driver.rating,
...(driver.teamId !== undefined && { teamId: driver.teamId }),
...(driver.teamName !== undefined && { teamName: driver.teamName }),
raceCount: driver.raceCount,
}),
);
// Publish event
await this.ports.eventPublisher.publishDriverRankingsAccessed({
type: 'driver_rankings_accessed',
timestamp: new Date(),
});
return {
drivers: driverEntries,
pagination: {
total,
page,
limit,
totalPages,
},
};
} catch (error) {
// Publish error event
await this.ports.eventPublisher.publishLeaderboardsError({
type: 'leaderboards_error',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date(),
});
throw error;
}
}
private validateQuery(query: DriverRankingsQuery): void {
if (query.page !== undefined && query.page < 1) {
throw new ValidationError('Page must be a positive integer');
}
if (query.limit !== undefined && query.limit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (query.minRating !== undefined && query.minRating < 0) {
throw new ValidationError('Min rating must be a non-negative number');
}
if (query.sortBy && !['rating', 'name', 'rank', 'raceCount'].includes(query.sortBy)) {
throw new ValidationError('Invalid sort field');
}
if (query.sortOrder && !['asc', 'desc'].includes(query.sortOrder)) {
throw new ValidationError('Sort order must be "asc" or "desc"');
}
}
}

View File

@@ -0,0 +1,95 @@
/**
* Get Global Leaderboards Use Case
*
* Orchestrates the retrieval of global leaderboards data.
* Aggregates data from repositories and returns top drivers and teams.
*/
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
import {
GlobalLeaderboardsQuery,
GlobalLeaderboardsResult,
GlobalLeaderboardDriverEntry,
GlobalLeaderboardTeamEntry,
} from '../ports/GlobalLeaderboardsQuery';
export interface GetGlobalLeaderboardsUseCasePorts {
leaderboardsRepository: LeaderboardsRepository;
eventPublisher: LeaderboardsEventPublisher;
}
export class GetGlobalLeaderboardsUseCase {
constructor(private readonly ports: GetGlobalLeaderboardsUseCasePorts) {}
async execute(query: GlobalLeaderboardsQuery = {}): Promise<GlobalLeaderboardsResult> {
try {
const driverLimit = query.driverLimit ?? 10;
const teamLimit = query.teamLimit ?? 10;
// Fetch all drivers and teams in parallel
const [allDrivers, allTeams] = await Promise.all([
this.ports.leaderboardsRepository.findAllDrivers(),
this.ports.leaderboardsRepository.findAllTeams(),
]);
// Sort drivers by rating (highest first) and take top N
const topDrivers = allDrivers
.sort((a, b) => {
const ratingComparison = b.rating - a.rating;
if (ratingComparison === 0) {
return a.name.localeCompare(b.name);
}
return ratingComparison;
})
.slice(0, driverLimit)
.map((driver, index): GlobalLeaderboardDriverEntry => ({
rank: index + 1,
id: driver.id,
name: driver.name,
rating: driver.rating,
...(driver.teamId !== undefined && { teamId: driver.teamId }),
...(driver.teamName !== undefined && { teamName: driver.teamName }),
raceCount: driver.raceCount,
}));
// Sort teams by rating (highest first) and take top N
const topTeams = allTeams
.sort((a, b) => {
const ratingComparison = b.rating - a.rating;
if (ratingComparison === 0) {
return a.name.localeCompare(b.name);
}
return ratingComparison;
})
.slice(0, teamLimit)
.map((team, index): GlobalLeaderboardTeamEntry => ({
rank: index + 1,
id: team.id,
name: team.name,
rating: team.rating,
memberCount: team.memberCount,
raceCount: team.raceCount,
}));
// Publish event
await this.ports.eventPublisher.publishGlobalLeaderboardsAccessed({
type: 'global_leaderboards_accessed',
timestamp: new Date(),
});
return {
drivers: topDrivers,
teams: topTeams,
};
} catch (error) {
// Publish error event
await this.ports.eventPublisher.publishLeaderboardsError({
type: 'leaderboards_error',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date(),
});
throw error;
}
}
}

View File

@@ -0,0 +1,201 @@
/**
* Get Team Rankings Use Case
*
* Orchestrates the retrieval of team rankings data.
* Aggregates data from repositories and returns teams with search, filter, and sort capabilities.
*/
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
import {
TeamRankingsQuery,
TeamRankingsResult,
TeamRankingEntry,
PaginationMetadata,
} from '../ports/TeamRankingsQuery';
import { ValidationError } from '../../../shared/errors/ValidationError';
export interface GetTeamRankingsUseCasePorts {
leaderboardsRepository: LeaderboardsRepository;
eventPublisher: LeaderboardsEventPublisher;
}
export class GetTeamRankingsUseCase {
constructor(private readonly ports: GetTeamRankingsUseCasePorts) {}
async execute(query: TeamRankingsQuery = {}): Promise<TeamRankingsResult> {
try {
// Validate query parameters
this.validateQuery(query);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
// Fetch all teams and drivers for member count aggregation
const [allTeams, allDrivers] = await Promise.all([
this.ports.leaderboardsRepository.findAllTeams(),
this.ports.leaderboardsRepository.findAllDrivers(),
]);
// Count members from drivers
const driverCounts = new Map<string, number>();
allDrivers.forEach(driver => {
if (driver.teamId) {
driverCounts.set(driver.teamId, (driverCounts.get(driver.teamId) || 0) + 1);
}
});
// Map teams from repository
const teamsWithAggregatedData = allTeams.map(team => {
const countFromDrivers = driverCounts.get(team.id);
return {
...team,
// If drivers exist in repository for this team, use that count as source of truth.
// Otherwise, fall back to the memberCount property on the team itself.
memberCount: countFromDrivers !== undefined ? countFromDrivers : (team.memberCount || 0)
};
});
// Discover teams that only exist in the drivers repository
const discoveredTeams: any[] = [];
driverCounts.forEach((count, teamId) => {
if (!allTeams.some(t => t.id === teamId)) {
const driverWithTeam = allDrivers.find(d => d.teamId === teamId);
discoveredTeams.push({
id: teamId,
name: driverWithTeam?.teamName || `Team ${teamId}`,
rating: 0,
memberCount: count,
raceCount: 0
});
}
});
const finalTeams = [...teamsWithAggregatedData, ...discoveredTeams];
// Apply search filter
let filteredTeams = finalTeams;
if (query.search) {
const searchLower = query.search.toLowerCase();
filteredTeams = filteredTeams.filter((team) =>
team.name.toLowerCase().includes(searchLower),
);
}
// Apply rating filter
if (query.minRating !== undefined) {
filteredTeams = filteredTeams.filter(
(team) => team.rating >= query.minRating!,
);
}
// Apply member count filter
if (query.minMemberCount !== undefined) {
filteredTeams = filteredTeams.filter(
(team) => team.memberCount >= query.minMemberCount!,
);
}
// Sort teams
const sortBy = query.sortBy ?? 'rating';
const sortOrder = query.sortOrder ?? 'desc';
filteredTeams.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'rating':
comparison = a.rating - b.rating;
break;
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'rank':
comparison = 0;
break;
case 'memberCount':
comparison = a.memberCount - b.memberCount;
break;
}
// If primary sort is equal, always use name ASC as secondary sort
if (comparison === 0 && sortBy !== 'name') {
return a.name.localeCompare(b.name);
}
return sortOrder === 'asc' ? comparison : -comparison;
});
// Calculate pagination
const total = filteredTeams.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = Math.min(startIndex + limit, total);
// Get paginated teams
const paginatedTeams = filteredTeams.slice(startIndex, endIndex);
// Map to ranking entries with rank
const teamEntries: TeamRankingEntry[] = paginatedTeams.map(
(team, index): TeamRankingEntry => ({
rank: startIndex + index + 1,
id: team.id,
name: team.name,
rating: team.rating,
memberCount: team.memberCount,
raceCount: team.raceCount,
}),
);
// Publish event
await this.ports.eventPublisher.publishTeamRankingsAccessed({
type: 'team_rankings_accessed',
timestamp: new Date(),
});
return {
teams: teamEntries,
pagination: {
total,
page,
limit,
totalPages,
},
};
} catch (error) {
// Publish error event
await this.ports.eventPublisher.publishLeaderboardsError({
type: 'leaderboards_error',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date(),
});
throw error;
}
}
private validateQuery(query: TeamRankingsQuery): void {
if (query.page !== undefined && query.page < 1) {
throw new ValidationError('Page must be a positive integer');
}
if (query.limit !== undefined && query.limit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (query.minRating !== undefined && query.minRating < 0) {
throw new ValidationError('Min rating must be a non-negative number');
}
if (query.minMemberCount !== undefined && query.minMemberCount < 0) {
throw new ValidationError('Min member count must be a non-negative number');
}
if (query.sortBy && !['rating', 'name', 'rank', 'memberCount'].includes(query.sortBy)) {
throw new ValidationError('Invalid sort field');
}
if (query.sortOrder && !['asc', 'desc'].includes(query.sortOrder)) {
throw new ValidationError('Sort order must be "asc" or "desc"');
}
}
}