services refactor

This commit is contained in:
2025-12-17 22:17:02 +01:00
parent 26f7a2b6aa
commit 055a7f67b5
93 changed files with 7434 additions and 659 deletions

View File

@@ -4,6 +4,8 @@ import type {
RecordPageViewOutputDto,
RecordEngagementInputDto,
RecordEngagementOutputDto,
AnalyticsDashboardDto,
AnalyticsMetricsDto,
} from '../../dtos';
/**
@@ -21,4 +23,14 @@ export class AnalyticsApiClient extends BaseApiClient {
recordEngagement(input: RecordEngagementInputDto): Promise<RecordEngagementOutputDto> {
return this.post<RecordEngagementOutputDto>('/analytics/engagement', input);
}
/** Get analytics dashboard data */
getDashboardData(): Promise<AnalyticsDashboardDto> {
return this.get<AnalyticsDashboardDto>('/analytics/dashboard');
}
/** Get analytics metrics */
getAnalyticsMetrics(): Promise<AnalyticsMetricsDto> {
return this.get<AnalyticsMetricsDto>('/analytics/metrics');
}
}

View File

@@ -4,6 +4,7 @@ import type {
CompleteOnboardingInputDto,
CompleteOnboardingOutputDto,
DriverDto,
DriverRegistrationStatusDto,
} from '../../dtos';
/**
@@ -26,4 +27,9 @@ export class DriversApiClient extends BaseApiClient {
getCurrent(): Promise<DriverDto | null> {
return this.get<DriverDto | null>('/drivers/current');
}
/** Get driver registration status for a specific race */
getRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusDto> {
return this.get<DriverRegistrationStatusDto>(`/drivers/${driverId}/races/${raceId}/registration-status`);
}
}

View File

@@ -2,6 +2,13 @@ import { BaseApiClient } from '../base/BaseApiClient';
import type {
RequestAvatarGenerationInputDto,
RequestAvatarGenerationOutputDto,
UploadMediaInputDto,
UploadMediaOutputDto,
GetMediaOutputDto,
DeleteMediaOutputDto,
GetAvatarOutputDto,
UpdateAvatarInputDto,
UpdateAvatarOutputDto,
} from '../../dtos';
/**
@@ -10,8 +17,39 @@ import type {
* Handles all media-related API operations.
*/
export class MediaApiClient extends BaseApiClient {
/** Upload media file */
uploadMedia(input: UploadMediaInputDto): Promise<UploadMediaOutputDto> {
const formData = new FormData();
formData.append('file', input.file);
formData.append('type', input.type);
if (input.category) {
formData.append('category', input.category);
}
return this.post<UploadMediaOutputDto>('/media/upload', formData);
}
/** Get media by ID */
getMedia(mediaId: string): Promise<GetMediaOutputDto> {
return this.get<GetMediaOutputDto>(`/media/${mediaId}`);
}
/** Delete media by ID */
deleteMedia(mediaId: string): Promise<DeleteMediaOutputDto> {
return this.delete<DeleteMediaOutputDto>(`/media/${mediaId}`);
}
/** Request avatar generation */
requestAvatarGeneration(input: RequestAvatarGenerationInputDto): Promise<RequestAvatarGenerationOutputDto> {
return this.post<RequestAvatarGenerationOutputDto>('/media/avatar/generate', input);
}
/** Get avatar for driver */
getAvatar(driverId: string): Promise<GetAvatarOutputDto> {
return this.get<GetAvatarOutputDto>(`/media/avatar/${driverId}`);
}
/** Update avatar for driver */
updateAvatar(input: UpdateAvatarInputDto): Promise<UpdateAvatarOutputDto> {
return this.put<UpdateAvatarOutputDto>(`/media/avatar/${input.driverId}`, { avatarUrl: input.avatarUrl });
}
}

View File

@@ -6,6 +6,11 @@ import type {
GetMembershipFeesOutputDto,
GetPrizesOutputDto,
GetWalletOutputDto,
ProcessWalletTransactionInputDto,
ProcessWalletTransactionOutputDto,
UpdateMemberPaymentInputDto,
UpdateMemberPaymentOutputDto,
GetWalletTransactionsOutputDto,
} from '../../dtos';
/**

View File

@@ -0,0 +1,6 @@
export interface AnalyticsDashboardDto {
totalUsers: number;
activeUsers: number;
totalRaces: number;
totalLeagues: number;
}

View File

@@ -0,0 +1,6 @@
export interface AnalyticsMetricsDto {
pageViews: number;
uniqueVisitors: number;
averageSessionDuration: number;
bounceRate: number;
}

View File

@@ -0,0 +1,8 @@
/**
* Delete media output data transfer object
* Output from deleting media
*/
export interface DeleteMediaOutputDto {
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Get avatar output data transfer object
* Output from getting avatar information
*/
export interface GetAvatarOutputDto {
driverId: string;
avatarUrl?: string;
hasAvatar: boolean;
}

View File

@@ -0,0 +1,12 @@
/**
* Get media output data transfer object
* Output from getting media information
*/
export interface GetMediaOutputDto {
id: string;
url: string;
type: 'image' | 'video' | 'document';
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
uploadedAt: string;
size?: number;
}

View File

@@ -0,0 +1,8 @@
/**
* Update avatar input data transfer object
* Input for updating driver avatar
*/
export interface UpdateAvatarInputDto {
driverId: string;
avatarUrl: string;
}

View File

@@ -0,0 +1,8 @@
/**
* Update avatar output data transfer object
* Output from updating avatar
*/
export interface UpdateAvatarOutputDto {
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Upload media input data transfer object
* Input for uploading media files
*/
export interface UploadMediaInputDto {
file: File;
type: 'image' | 'video' | 'document';
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
}

View File

@@ -0,0 +1,10 @@
/**
* Upload media output data transfer object
* Output from media upload operation
*/
export interface UploadMediaOutputDto {
success: boolean;
mediaId?: string;
url?: string;
error?: string;
}

View File

@@ -1,113 +0,0 @@
// Common DTOs
export type { DriverDto } from './DriverDto';
export type { PenaltyDataDto } from './PenaltyDataDto';
export type { PenaltyTypeDto } from './PenaltyTypeDto';
export type { ProtestDto } from './ProtestDto';
export type { StandingEntryDto } from './StandingEntryDto';
// Analytics DTOs
export type { RecordEngagementInputDto } from './RecordEngagementInputDto';
export type { RecordEngagementOutputDto } from './RecordEngagementOutputDto';
export type { RecordPageViewInputDto } from './RecordPageViewInputDto';
export type { RecordPageViewOutputDto } from './RecordPageViewOutputDto';
// Auth DTOs
export type { LoginParamsDto } from './LoginParamsDto';
export type { SessionDataDto } from './SessionDataDto';
export type { SignupParamsDto } from './SignupParamsDto';
// League DTOs
export type { AllLeaguesWithCapacityDto } from './AllLeaguesWithCapacityDto';
export type { CreateLeagueInputDto } from './CreateLeagueInputDto';
export type { CreateLeagueOutputDto } from './CreateLeagueOutputDto';
export type { LeagueAdminDto } from './LeagueAdminDto';
export type { LeagueAdminPermissionsDto } from './LeagueAdminPermissionsDto';
export type { LeagueAdminProtestsDto } from './LeagueAdminProtestsDto';
export type { LeagueConfigFormModelDto } from './LeagueConfigFormModelDto';
export type { LeagueJoinRequestDto } from './LeagueJoinRequestDto';
export type { LeagueMembershipsDto } from './LeagueMembershipsDto';
export type { LeagueMemberDto } from './LeagueMemberDto';
export type { LeagueOwnerSummaryDto } from './LeagueOwnerSummaryDto';
export type { LeagueScheduleDto } from './LeagueScheduleDto';
export type { LeagueSeasonSummaryDto } from './LeagueSeasonSummaryDto';
export type { LeagueStandingsDto } from './LeagueStandingsDto';
export type { LeagueStatsDto } from './LeagueStatsDto';
export type { LeagueSummaryDto } from './LeagueSummaryDto';
// Race DTOs
export type { AllRacesPageDto } from './AllRacesPageDto';
export type { ImportRaceResultsInputDto } from './ImportRaceResultsInputDto';
export type { ImportRaceResultsSummaryDto } from './ImportRaceResultsSummaryDto';
export type { RaceDetailDto } from './RaceDetailDto';
export type { RaceDetailEntryDto } from './RaceDetailEntryDto';
export type { RaceDetailLeagueDto } from './RaceDetailLeagueDto';
export type { RaceDetailRaceDto } from './RaceDetailRaceDto';
export type { RaceDetailRegistrationDto } from './RaceDetailRegistrationDto';
export type { RaceDetailUserResultDto } from './RaceDetailUserResultDto';
export type { RaceListItemDto } from './RaceListItemDto';
export type { RacePenaltiesDto } from './RacePenaltiesDto';
export type { RacePenaltyDto } from './RacePenaltyDto';
export type { RaceProtestsDto } from './RaceProtestsDto';
export type { RaceProtestDto } from './RaceProtestDto';
export type { RaceResultDto } from './RaceResultDto';
export type { RaceResultRowDto } from './RaceResultRowDto';
export type { RaceResultsDetailDto } from './RaceResultsDetailDto';
export type { RaceStatsDto } from './RaceStatsDto';
export type { RaceWithSOFDto } from './RaceWithSOFDto';
export type { RacesPageDataDto } from './RacesPageDataDto';
export type { RacesPageDataRaceDto } from './RacesPageDataRaceDto';
export type { RegisterForRaceInputDto } from './RegisterForRaceInputDto';
export type { ScheduledRaceDto } from './ScheduledRaceDto';
export type { WithdrawFromRaceInputDto } from './WithdrawFromRaceInputDto';
// Driver DTOs
export type { CompleteOnboardingInputDto } from './CompleteOnboardingInputDto';
export type { CompleteOnboardingOutputDto } from './CompleteOnboardingOutputDto';
export type { DriverLeaderboardItemDto } from './DriverLeaderboardItemDto';
export type { DriverRegistrationStatusDto } from './DriverRegistrationStatusDto';
export type { DriverRowDto } from './DriverRowDto';
export type { DriverStatsDto } from './DriverStatsDto';
export type { DriverTeamDto } from './DriverTeamDto';
export type { DriversLeaderboardDto } from './DriversLeaderboardDto';
// Team DTOs
export type { AllTeamsDto } from './AllTeamsDto';
export type { CreateTeamInputDto } from './CreateTeamInputDto';
export type { CreateTeamOutputDto } from './CreateTeamOutputDto';
export type { TeamDetailsDto } from './TeamDetailsDto';
export type { TeamJoinRequestItemDto } from './TeamJoinRequestItemDto';
export type { TeamJoinRequestsDto } from './TeamJoinRequestsDto';
export type { TeamMemberDto } from './TeamMemberDto';
export type { TeamMembersDto } from './TeamMembersDto';
export type { TeamSummaryDto } from './TeamSummaryDto';
export type { UpdateTeamInputDto } from './UpdateTeamInputDto';
export type { UpdateTeamOutputDto } from './UpdateTeamOutputDto';
// Sponsor DTOs
export type { CreateSponsorInputDto } from './CreateSponsorInputDto';
export type { CreateSponsorOutputDto } from './CreateSponsorOutputDto';
export type { GetEntitySponsorshipPricingResultDto } from './GetEntitySponsorshipPricingResultDto';
export type { GetSponsorsOutputDto } from './GetSponsorsOutputDto';
export type { SponsorDashboardDto } from './SponsorDashboardDto';
export type { SponsorDto } from './SponsorDto';
export type { SponsorshipDetailDto } from './SponsorshipDetailDto';
export type { SponsorSponsorshipsDto } from './SponsorSponsorshipsDto';
// Media DTOs
export type { RequestAvatarGenerationInputDto } from './RequestAvatarGenerationInputDto';
export type { RequestAvatarGenerationOutputDto } from './RequestAvatarGenerationOutputDto';
// Payments DTOs
export type { CreatePaymentInputDto } from './CreatePaymentInputDto';
export type { CreatePaymentOutputDto } from './CreatePaymentOutputDto';
export type { GetMembershipFeesOutputDto } from './GetMembershipFeesOutputDto';
export type { GetPaymentsOutputDto } from './GetPaymentsOutputDto';
export type { GetPrizesOutputDto } from './GetPrizesOutputDto';
export type { GetWalletOutputDto } from './GetWalletOutputDto';
export type { ImportResultRowDto } from './ImportResultRowDto';
export type { MemberPaymentDto } from './MemberPaymentDto';
export type { MembershipFeeDto } from './MembershipFeeDto';
export type { PaymentDto } from './PaymentDto';
export type { PrizeDto } from './PrizeDto';
export type { WalletDto } from './WalletDto';
export type { WalletTransactionDto } from './WalletTransactionDto';

View File

@@ -0,0 +1,8 @@
import { AnalyticsDashboardDto } from '../dtos';
import { AnalyticsDashboardViewModel } from '../view-models';
export class AnalyticsDashboardPresenter {
present(dto: AnalyticsDashboardDto): AnalyticsDashboardViewModel {
return new AnalyticsDashboardViewModel(dto);
}
}

View File

@@ -0,0 +1,8 @@
import { AnalyticsMetricsDto } from '../dtos';
import { AnalyticsMetricsViewModel } from '../view-models';
export class AnalyticsMetricsPresenter {
present(dto: AnalyticsMetricsDto): AnalyticsMetricsViewModel {
return new AnalyticsMetricsViewModel(dto);
}
}

View File

@@ -0,0 +1,39 @@
import type {
GetAvatarOutputDto,
RequestAvatarGenerationOutputDto,
UpdateAvatarOutputDto
} from '../dtos';
import type {
AvatarViewModel,
RequestAvatarGenerationViewModel,
UpdateAvatarViewModel
} from '../view-models';
/**
* Avatar Presenter
* Transforms avatar DTOs to ViewModels
*/
export class AvatarPresenter {
presentAvatar(dto: GetAvatarOutputDto): AvatarViewModel {
return {
driverId: dto.driverId,
avatarUrl: dto.avatarUrl,
hasAvatar: dto.hasAvatar,
};
}
presentRequestGeneration(dto: RequestAvatarGenerationOutputDto): RequestAvatarGenerationViewModel {
return {
success: dto.success,
avatarUrl: dto.avatarUrl,
error: dto.error,
};
}
presentUpdate(dto: UpdateAvatarOutputDto): UpdateAvatarViewModel {
return {
success: dto.success,
error: dto.error,
};
}
}

View File

@@ -0,0 +1,15 @@
import type { CompleteOnboardingOutputDto } from '../dtos';
import type { CompleteOnboardingViewModel } from '../view-models/CompleteOnboardingViewModel';
/**
* Complete Onboarding Presenter
* Transforms CompleteOnboardingOutputDto to CompleteOnboardingViewModel
*/
export class CompleteOnboardingPresenter {
present(dto: CompleteOnboardingOutputDto): CompleteOnboardingViewModel {
return {
driverId: dto.driverId,
success: dto.success,
};
}
}

View File

@@ -0,0 +1,18 @@
import type { DriverDto } from '../dtos';
import type { DriverViewModel } from '../view-models/DriverViewModel';
/**
* Driver Presenter
* Transforms DriverDto to DriverViewModel
*/
export class DriverPresenter {
present(dto: DriverDto): DriverViewModel {
return {
id: dto.id,
name: dto.name,
avatarUrl: dto.avatarUrl,
iracingId: dto.iracingId,
rating: dto.rating,
};
}
}

View File

@@ -1,6 +1,19 @@
import { DriverRegistrationStatusDto } from '../dtos';
import { DriverRegistrationStatusViewModel } from '../view-models';
import type { DriverRegistrationStatusDto } from '../dtos';
import type { DriverRegistrationStatusViewModel } from '../view-models';
import { DriverRegistrationStatusViewModel as ViewModel } from '../view-models';
/**
* Driver Registration Status Presenter
* Transforms DriverRegistrationStatusDto to DriverRegistrationStatusViewModel
*/
export class DriverRegistrationStatusPresenter {
present(dto: DriverRegistrationStatusDto): DriverRegistrationStatusViewModel {
return new ViewModel(dto);
}
}
// Legacy functional export for backward compatibility
export const presentDriverRegistrationStatus = (dto: DriverRegistrationStatusDto): DriverRegistrationStatusViewModel => {
return new DriverRegistrationStatusViewModel(dto);
const presenter = new DriverRegistrationStatusPresenter();
return presenter.present(dto);
};

View File

@@ -1,6 +1,18 @@
import { DriversLeaderboardDto, DriverLeaderboardItemDto } from '../dtos';
import { DriverLeaderboardViewModel } from '../view-models';
import type { DriversLeaderboardDto } from '../dtos';
import type { DriverLeaderboardViewModel } from '../view-models';
export const presentDriversLeaderboard = (dto: DriversLeaderboardDto, previousDrivers?: DriverLeaderboardItemDto[]): DriverLeaderboardViewModel => {
return new DriverLeaderboardViewModel(dto, previousDrivers);
/**
* Drivers Leaderboard Presenter
* Transforms DriversLeaderboardDto to DriverLeaderboardViewModel
*/
export class DriversLeaderboardPresenter {
present(dto: DriversLeaderboardDto, previousDrivers?: any): DriverLeaderboardViewModel {
return new DriverLeaderboardViewModel(dto as any, previousDrivers);
}
}
// Legacy functional export for backward compatibility
export const presentDriversLeaderboard = (dto: DriversLeaderboardDto, previousDrivers?: any): DriverLeaderboardViewModel => {
const presenter = new DriversLeaderboardPresenter();
return presenter.present(dto, previousDrivers);
};

View File

@@ -1,17 +1,21 @@
import type {
IImportRaceResultsPresenter,
ImportRaceResultsSummaryViewModel,
} from '@core/racing/application/presenters/IImportRaceResultsPresenter';
import type { ImportRaceResultsSummaryDto } from '../dtos/ImportRaceResultsSummaryDto';
export class ImportRaceResultsPresenter implements IImportRaceResultsPresenter {
private viewModel: ImportRaceResultsSummaryViewModel | null = null;
export interface ImportRaceResultsSummaryViewModel {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
errors?: string[];
}
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel {
this.viewModel = viewModel;
return this.viewModel;
}
getViewModel(): ImportRaceResultsSummaryViewModel | null {
return this.viewModel;
export class ImportRaceResultsPresenter {
present(dto: ImportRaceResultsSummaryDto): ImportRaceResultsSummaryViewModel {
return {
success: dto.success,
raceId: dto.raceId,
driversProcessed: dto.driversProcessed,
resultsRecorded: dto.resultsRecorded,
errors: dto.errors,
};
}
}

View File

@@ -0,0 +1,16 @@
import type { LeagueMembershipsDto } from '../dtos';
import { LeagueMemberViewModel } from '../view-models';
/**
* League Members Presenter
*
* Transforms league memberships DTO to view models for the UI.
*/
export class LeagueMembersPresenter {
/**
* Present league memberships with current user context
*/
present(dto: LeagueMembershipsDto, currentUserId: string): LeagueMemberViewModel[] {
return dto.members.map(member => new LeagueMemberViewModel(member, currentUserId));
}
}

View File

@@ -1,6 +1,18 @@
import { LeagueStandingsDto, StandingEntryDto } from '../dtos';
import type { LeagueStandingsDto, StandingEntryDto } from '../dtos';
import { LeagueStandingsViewModel } from '../view-models';
/**
* League Standings Presenter
* Transforms LeagueStandingsDto to LeagueStandingsViewModel
*/
export class LeagueStandingsPresenter {
present(dto: LeagueStandingsDto, currentUserId: string, previousStandings?: StandingEntryDto[]): LeagueStandingsViewModel {
return new LeagueStandingsViewModel(dto, currentUserId, previousStandings);
}
}
// Legacy functional export for backward compatibility
export const presentLeagueStandings = (dto: LeagueStandingsDto, currentUserId: string, previousStandings?: StandingEntryDto[]): LeagueStandingsViewModel => {
return new LeagueStandingsViewModel(dto, currentUserId, previousStandings);
const presenter = new LeagueStandingsPresenter();
return presenter.present(dto, currentUserId, previousStandings);
};

View File

@@ -1,10 +1,21 @@
import { LeagueSummaryDto } from '../dtos';
import type { AllLeaguesWithCapacityDto } from '../dtos';
import { LeagueSummaryViewModel } from '../view-models';
export const presentLeagueSummary = (dto: LeagueSummaryDto): LeagueSummaryViewModel => {
/**
* League Summary Presenter
* Transforms AllLeaguesWithCapacityDto to array of LeagueSummaryViewModel
*/
export class LeagueSummaryPresenter {
present(dto: AllLeaguesWithCapacityDto): LeagueSummaryViewModel[] {
return dto.leagues.map(league => new LeagueSummaryViewModel(league));
}
}
// Legacy functional exports for backward compatibility
export const presentLeagueSummary = (dto: any): LeagueSummaryViewModel => {
return new LeagueSummaryViewModel(dto);
};
export const presentLeagueSummaries = (dtos: LeagueSummaryDto[]): LeagueSummaryViewModel[] => {
export const presentLeagueSummaries = (dtos: any[]): LeagueSummaryViewModel[] => {
return dtos.map(presentLeagueSummary);
};

View File

@@ -0,0 +1,35 @@
import type { GetMediaOutputDto, UploadMediaOutputDto, DeleteMediaOutputDto } from '../dtos';
import type { MediaViewModel, UploadMediaViewModel, DeleteMediaViewModel } from '../view-models';
/**
* Media Presenter
* Transforms media DTOs to ViewModels
*/
export class MediaPresenter {
presentMedia(dto: GetMediaOutputDto): MediaViewModel {
return {
id: dto.id,
url: dto.url,
type: dto.type,
category: dto.category,
uploadedAt: new Date(dto.uploadedAt),
size: dto.size,
};
}
presentUpload(dto: UploadMediaOutputDto): UploadMediaViewModel {
return {
success: dto.success,
mediaId: dto.mediaId,
url: dto.url,
error: dto.error,
};
}
presentDelete(dto: DeleteMediaOutputDto): DeleteMediaViewModel {
return {
success: dto.success,
error: dto.error,
};
}
}

View File

@@ -0,0 +1,17 @@
import type { GetPaymentsOutputDto } from '../dtos';
import { PaymentViewModel } from '../view-models';
import { presentPayment } from './PaymentPresenter';
/**
* Payment List Presenter
*
* Transforms payment list DTOs into ViewModels for UI consumption.
*/
export class PaymentListPresenter {
/**
* Transform payment list DTO to ViewModels
*/
present(dto: GetPaymentsOutputDto): PaymentViewModel[] {
return dto.payments.map(payment => presentPayment(payment));
}
}

View File

@@ -1,25 +1,17 @@
import type {
IRaceWithSOFPresenter,
RaceWithSOFResultDTO,
RaceWithSOFViewModel,
} from '@core/racing/application/presenters/IRaceWithSOFPresenter';
import type { RaceWithSOFDto } from '../dtos/RaceWithSOFDto';
export class RaceWithSOFPresenter implements IRaceWithSOFPresenter {
present(dto: RaceWithSOFResultDTO): RaceWithSOFViewModel {
export interface RaceWithSOFViewModel {
id: string;
track: string;
strengthOfField: number | null;
}
export class RaceWithSOFPresenter {
present(dto: RaceWithSOFDto): RaceWithSOFViewModel {
return {
id: dto.raceId,
leagueId: dto.leagueId,
scheduledAt: dto.scheduledAt.toISOString(),
id: dto.id,
track: dto.track,
trackId: dto.trackId,
car: dto.car,
carId: dto.carId,
sessionType: dto.sessionType,
status: dto.status,
strengthOfField: dto.strengthOfField,
registeredCount: dto.registeredCount,
maxParticipants: dto.maxParticipants,
participantCount: dto.participantCount,
};
}
}

View File

@@ -0,0 +1,13 @@
import type { SessionDataDto } from '../dtos';
import { SessionViewModel } from '../view-models';
/**
* Session Presenter
* Transforms session DTOs to ViewModels
*/
export class SessionPresenter {
presentSession(dto: SessionDataDto | null): SessionViewModel | null {
if (!dto) return null;
return new SessionViewModel(dto);
}
}

View File

@@ -1,25 +1,13 @@
import type {
ISponsorDashboardPresenter,
SponsorDashboardViewModel,
} from '@core/racing/application/presenters/ISponsorDashboardPresenter';
import type { SponsorDashboardDTO } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
import type { SponsorDashboardDto } from '../dtos';
import { SponsorDashboardViewModel } from '../view-models/SponsorDashboardViewModel';
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
private viewModel: SponsorDashboardViewModel = null;
reset(): void {
this.viewModel = null;
}
present(data: SponsorDashboardDTO | null): void {
this.viewModel = data;
}
getViewModel(): SponsorDashboardViewModel {
return this.viewModel;
}
getData(): SponsorDashboardDTO | null {
return this.viewModel;
/**
* Sponsor Dashboard Presenter
*
* Transforms sponsor dashboard DTOs into view models.
*/
export class SponsorDashboardPresenter {
present(dto: SponsorDashboardDto): SponsorDashboardViewModel {
return new SponsorDashboardViewModel(dto);
}
}

View File

@@ -0,0 +1,13 @@
import type { GetSponsorsOutputDto } from '../dtos';
import { SponsorViewModel } from '../view-models';
/**
* Sponsor List Presenter
*
* Transforms sponsor list DTOs into view models.
*/
export class SponsorListPresenter {
present(dto: GetSponsorsOutputDto): SponsorViewModel[] {
return dto.sponsors.map(sponsor => new SponsorViewModel(sponsor));
}
}

View File

@@ -1,6 +1,13 @@
import { SponsorDto } from '../dtos';
import type { SponsorDto } from '../dtos';
import { SponsorViewModel } from '../view-models';
export const presentSponsor = (dto: SponsorDto): SponsorViewModel => {
return new SponsorViewModel(dto);
};
/**
* Sponsor Presenter
*
* Transforms sponsor DTOs into view models.
*/
export class SponsorPresenter {
present(dto: SponsorDto): SponsorViewModel {
return new SponsorViewModel(dto);
}
}

View File

@@ -1,25 +1,13 @@
import type {
ISponsorSponsorshipsPresenter,
SponsorSponsorshipsViewModel,
} from '@core/racing/application/presenters/ISponsorSponsorshipsPresenter';
import type { SponsorSponsorshipsDTO } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
import type { SponsorSponsorshipsDto } from '../dtos';
import { SponsorSponsorshipsViewModel } from '../view-models/SponsorSponsorshipsViewModel';
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
private viewModel: SponsorSponsorshipsViewModel = null;
reset(): void {
this.viewModel = null;
}
present(data: SponsorSponsorshipsDTO | null): void {
this.viewModel = data;
}
getViewModel(): SponsorSponsorshipsViewModel {
return this.viewModel;
}
getData(): SponsorSponsorshipsDTO | null {
return this.viewModel;
/**
* Sponsor Sponsorships Presenter
*
* Transforms sponsor sponsorships DTOs into view models.
*/
export class SponsorSponsorshipsPresenter {
present(dto: SponsorSponsorshipsDto): SponsorSponsorshipsViewModel {
return new SponsorSponsorshipsViewModel(dto);
}
}

View File

@@ -0,0 +1,13 @@
import type { GetEntitySponsorshipPricingResultDto } from '../dtos';
import { SponsorshipPricingViewModel } from '../view-models/SponsorshipPricingViewModel';
/**
* Sponsorship Pricing Presenter
*
* Transforms sponsorship pricing DTOs into view models.
*/
export class SponsorshipPricingPresenter {
present(dto: GetEntitySponsorshipPricingResultDto): SponsorshipPricingViewModel {
return new SponsorshipPricingViewModel(dto);
}
}

View File

@@ -1,6 +1,12 @@
import { TeamDetailsDto, TeamMemberDto } from '../dtos';
import type { TeamDetailsDto } from '../dtos';
import { TeamDetailsViewModel } from '../view-models';
export const presentTeamDetails = (dto: TeamDetailsDto, currentUserId: string): TeamDetailsViewModel => {
return new TeamDetailsViewModel(dto, currentUserId);
};
/**
* Team Details Presenter
* Transforms TeamDetailsDto to TeamDetailsViewModel
*/
export class TeamDetailsPresenter {
present(dto: TeamDetailsDto, currentUserId: string): TeamDetailsViewModel {
return new TeamDetailsViewModel(dto, currentUserId);
}
}

View File

@@ -1,6 +1,18 @@
import { TeamJoinRequestItemDto } from '../dtos';
import type { TeamJoinRequestItemDto } from '../dtos';
import { TeamJoinRequestViewModel } from '../view-models';
/**
* Team Join Request Presenter
* Transforms TeamJoinRequestItemDto to TeamJoinRequestViewModel
*/
export class TeamJoinRequestPresenter {
present(dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean): TeamJoinRequestViewModel {
return new TeamJoinRequestViewModel(dto, currentUserId, isOwner);
}
}
// Backward compatibility export (deprecated)
export const presentTeamJoinRequest = (dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean): TeamJoinRequestViewModel => {
return new TeamJoinRequestViewModel(dto, currentUserId, isOwner);
const presenter = new TeamJoinRequestPresenter();
return presenter.present(dto, currentUserId, isOwner);
};

View File

@@ -0,0 +1,12 @@
import type { AllTeamsDto } from '../dtos';
import { TeamSummaryViewModel } from '../view-models';
/**
* Team List Presenter
* Transforms AllTeamsDto to array of TeamSummaryViewModel
*/
export class TeamListPresenter {
present(dto: AllTeamsDto): TeamSummaryViewModel[] {
return dto.teams.map(team => new TeamSummaryViewModel(team));
}
}

View File

@@ -1,41 +1,12 @@
import type {
ITeamMembersPresenter,
TeamMemberViewModel,
TeamMembersViewModel,
TeamMembersResultDTO,
} from '@core/racing/application/presenters/ITeamMembersPresenter';
import type { TeamMembersDto } from '../dtos';
import { TeamMemberViewModel } from '../view-models';
export class TeamMembersPresenter implements ITeamMembersPresenter {
private viewModel: TeamMembersViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: TeamMembersResultDTO): void {
const members: TeamMemberViewModel[] = input.memberships.map((membership) => ({
driverId: membership.driverId,
driverName: input.driverNames[membership.driverId] ?? 'Unknown Driver',
role: membership.role === 'driver' ? 'member' : membership.role,
joinedAt: membership.joinedAt.toISOString(),
isActive: membership.status === 'active',
avatarUrl: input.avatarUrls[membership.driverId] ?? '',
}));
const ownerCount = members.filter((m) => m.role === 'owner').length;
const managerCount = members.filter((m) => m.role === 'manager').length;
const memberCount = members.filter((m) => m.role === 'member').length;
this.viewModel = {
members,
totalCount: members.length,
ownerCount,
managerCount,
memberCount,
};
}
getViewModel(): TeamMembersViewModel | null {
return this.viewModel;
/**
* Team Members Presenter
* Transforms TeamMembersDto to array of TeamMemberViewModel
*/
export class TeamMembersPresenter {
present(dto: TeamMembersDto, currentUserId: string, teamOwnerId: string): TeamMemberViewModel[] {
return dto.members.map(member => new TeamMemberViewModel(member, currentUserId, teamOwnerId));
}
}

View File

@@ -1,37 +0,0 @@
// Analytics Presenters
// Auth Presenters
// Driver Presenters
export { presentDriversLeaderboard } from './DriversLeaderboardPresenter';
export { presentDriverRegistrationStatus } from './DriverRegistrationStatusPresenter';
// League Presenters
export { presentLeagueMember } from './LeagueMemberPresenter';
export { presentLeagueStandings } from './LeagueStandingsPresenter';
export { presentLeagueSummaries, presentLeagueSummary } from './LeagueSummaryPresenter';
// Payments Presenters
export { presentMembershipFee } from './MembershipFeePresenter';
export { presentPayment } from './PaymentPresenter';
export { presentPrize } from './PrizePresenter';
export { presentWallet } from './WalletPresenter';
export { presentWalletTransaction } from './WalletTransactionPresenter';
// Race Presenters
export { presentRaceDetail } from './RaceDetailPresenter';
export { presentRaceListItem } from './RaceListItemPresenter';
export { presentRaceResult } from './RaceResultsPresenter';
export { presentRaceResultsDetail, RaceResultsDetailPresenter } from './RaceResultsDetailPresenter';
export { RaceWithSOFPresenter } from './RaceWithSOFPresenter';
export { ImportRaceResultsPresenter } from './ImportRaceResultsPresenter';
// Sponsor Presenters
export { presentSponsor } from './SponsorPresenter';
export { presentSponsorshipDetail } from './SponsorshipDetailPresenter';
// Team Presenters
export { presentTeamDetails } from './TeamDetailsPresenter';
export { presentTeamJoinRequest } from './TeamJoinRequestPresenter';
export { presentTeamMember } from './TeamMemberPresenter';
export { presentTeamSummary } from './TeamSummaryPresenter';

View File

@@ -0,0 +1,517 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ServiceFactory } from './ServiceFactory';
// Mock API clients
vi.mock('../api/races/RacesApiClient', () => ({
RacesApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/drivers/DriversApiClient', () => ({
DriversApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/teams/TeamsApiClient', () => ({
TeamsApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/leagues/LeaguesApiClient', () => ({
LeaguesApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/sponsors/SponsorsApiClient', () => ({
SponsorsApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/payments/PaymentsApiClient', () => ({
PaymentsApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/auth/AuthApiClient', () => ({
AuthApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/analytics/AnalyticsApiClient', () => ({
AnalyticsApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
vi.mock('../api/media/MediaApiClient', () => ({
MediaApiClient: class {
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
},
}));
// Mock presenters
vi.mock('../presenters/RaceDetailPresenter', () => ({
RaceDetailPresenter: class {},
}));
vi.mock('../presenters/RaceResultsDetailPresenter', () => ({
RaceResultsDetailPresenter: class {},
}));
vi.mock('../presenters/RaceWithSOFPresenter', () => ({
RaceWithSOFPresenter: class {},
}));
vi.mock('../presenters/ImportRaceResultsPresenter', () => ({
ImportRaceResultsPresenter: class {},
}));
vi.mock('../presenters/DriversLeaderboardPresenter', () => ({
DriversLeaderboardPresenter: class {},
}));
vi.mock('../presenters/DriverPresenter', () => ({
DriverPresenter: class {},
}));
vi.mock('../presenters/CompleteOnboardingPresenter', () => ({
CompleteOnboardingPresenter: class {},
}));
vi.mock('../presenters/DriverRegistrationStatusPresenter', () => ({
DriverRegistrationStatusPresenter: class {},
}));
vi.mock('../presenters/TeamDetailsPresenter', () => ({
TeamDetailsPresenter: class {},
}));
vi.mock('../presenters/TeamListPresenter', () => ({
TeamListPresenter: class {},
}));
vi.mock('../presenters/TeamMembersPresenter', () => ({
TeamMembersPresenter: class {},
}));
vi.mock('../presenters/TeamJoinRequestPresenter', () => ({
TeamJoinRequestPresenter: class {},
}));
vi.mock('../presenters/LeagueSummaryPresenter', () => ({
LeagueSummaryPresenter: class {},
}));
vi.mock('../presenters/LeagueStandingsPresenter', () => ({
LeagueStandingsPresenter: class {},
}));
vi.mock('../presenters/LeagueMembersPresenter', () => ({
LeagueMembersPresenter: class {},
}));
vi.mock('../presenters/SponsorListPresenter', () => ({
SponsorListPresenter: class {},
}));
vi.mock('../presenters/SponsorDashboardPresenter', () => ({
SponsorDashboardPresenter: class {},
}));
vi.mock('../presenters/SponsorSponsorshipsPresenter', () => ({
SponsorSponsorshipsPresenter: class {},
}));
vi.mock('../presenters/SponsorshipPricingPresenter', () => ({
SponsorshipPricingPresenter: class {},
}));
vi.mock('../presenters/PaymentListPresenter', () => ({
PaymentListPresenter: class {},
}));
vi.mock('../presenters/AnalyticsDashboardPresenter', () => ({
AnalyticsDashboardPresenter: class {},
}));
vi.mock('../presenters/AnalyticsMetricsPresenter', () => ({
AnalyticsMetricsPresenter: class {},
}));
vi.mock('../presenters/MediaPresenter', () => ({
MediaPresenter: class {},
}));
vi.mock('../presenters/AvatarPresenter', () => ({
AvatarPresenter: class {},
}));
vi.mock('../presenters/SessionPresenter', () => ({
SessionPresenter: class {},
}));
vi.mock('../presenters/AnalyticsDashboardPresenter', () => ({
AnalyticsDashboardPresenter: class {},
}));
vi.mock('../presenters/AnalyticsMetricsPresenter', () => ({
AnalyticsMetricsPresenter: class {},
}));
vi.mock('../presenters/MediaPresenter', () => ({
MediaPresenter: class {},
}));
vi.mock('../presenters/AvatarPresenter', () => ({
AvatarPresenter: class {},
}));
vi.mock('../presenters/SessionPresenter', () => ({
SessionPresenter: class {},
}));
vi.mock('../presenters', () => ({
presentPayment: vi.fn(),
presentMembershipFee: vi.fn(),
presentPrize: vi.fn(),
presentWallet: vi.fn(),
}));
// Mock services
vi.mock('./races/RaceService', () => ({
RaceService: class {
constructor(...args: any[]) {
return { type: 'RaceService', args };
}
},
}));
vi.mock('./races/RaceResultsService', () => ({
RaceResultsService: class {
constructor(...args: any[]) {
return { type: 'RaceResultsService', args };
}
},
}));
vi.mock('./drivers/DriverService', () => ({
DriverService: class {
constructor(...args: any[]) {
return { type: 'DriverService', args };
}
},
}));
vi.mock('./drivers/DriverRegistrationService', () => ({
DriverRegistrationService: class {
constructor(...args: any[]) {
return { type: 'DriverRegistrationService', args };
}
},
}));
vi.mock('./teams/TeamService', () => ({
TeamService: class {
constructor(...args: any[]) {
return { type: 'TeamService', args };
}
},
}));
vi.mock('./teams/TeamJoinService', () => ({
TeamJoinService: class {
constructor(...args: any[]) {
return { type: 'TeamJoinService', args };
}
},
}));
vi.mock('./leagues/LeagueService', () => ({
LeagueService: class {
constructor(...args: any[]) {
return { type: 'LeagueService', args };
}
},
}));
vi.mock('./leagues/LeagueMembershipService', () => ({
LeagueMembershipService: class {
constructor(...args: any[]) {
return { type: 'LeagueMembershipService', args };
}
},
}));
vi.mock('./sponsors/SponsorService', () => ({
SponsorService: class {
constructor(...args: any[]) {
return { type: 'SponsorService', args };
}
},
}));
vi.mock('./sponsors/SponsorshipService', () => ({
SponsorshipService: class {
constructor(...args: any[]) {
return { type: 'SponsorshipService', args };
}
},
}));
vi.mock('./payments/PaymentService', () => ({
PaymentService: class {
constructor(...args: any[]) {
return { type: 'PaymentService', args };
}
},
}));
vi.mock('./analytics/AnalyticsService', () => ({
AnalyticsService: class {
constructor(...args: any[]) {
return { type: 'AnalyticsService', args };
}
},
}));
vi.mock('./analytics/DashboardService', () => ({
DashboardService: class {
constructor(...args: any[]) {
return { type: 'DashboardService', args };
}
},
}));
vi.mock('./media/MediaService', () => ({
MediaService: class {
constructor(...args: any[]) {
return { type: 'MediaService', args };
}
},
}));
vi.mock('./media/AvatarService', () => ({
AvatarService: class {
constructor(...args: any[]) {
return { type: 'AvatarService', args };
}
},
}));
vi.mock('./payments/WalletService', () => ({
WalletService: class {
constructor(...args: any[]) {
return { type: 'WalletService', args };
}
},
}));
vi.mock('./payments/MembershipFeeService', () => ({
MembershipFeeService: class {
constructor(...args: any[]) {
return { type: 'MembershipFeeService', args };
}
},
}));
vi.mock('./auth/AuthService', () => ({
AuthService: class {
constructor(...args: any[]) {
return { type: 'AuthService', args };
}
},
}));
vi.mock('./auth/SessionService', () => ({
SessionService: class {
constructor(...args: any[]) {
return { type: 'SessionService', args };
}
},
}));
describe('ServiceFactory', () => {
let factory: ServiceFactory;
beforeEach(() => {
vi.clearAllMocks();
factory = new ServiceFactory('http://test-api.com');
});
it('should create RaceService with correct dependencies', () => {
const service = factory.createRaceService();
expect(service).toBeDefined();
expect(service.type).toBe('RaceService');
expect(service.args).toHaveLength(2);
});
it('should create RaceResultsService with correct dependencies', () => {
const service = factory.createRaceResultsService();
expect(service).toBeDefined();
expect(service.type).toBe('RaceResultsService');
expect(service.args).toHaveLength(4);
});
it('should create DriverService with correct dependencies', () => {
const service = factory.createDriverService();
expect(service).toBeDefined();
expect(service.type).toBe('DriverService');
expect(service.args).toHaveLength(4);
});
it('should create DriverRegistrationService with correct dependencies', () => {
const service = factory.createDriverRegistrationService();
expect(service).toBeDefined();
expect(service.type).toBe('DriverRegistrationService');
expect(service.args).toHaveLength(2);
});
it('should create TeamService with correct dependencies', () => {
const service = factory.createTeamService();
expect(service).toBeDefined();
expect(service.type).toBe('TeamService');
expect(service.args).toHaveLength(4);
});
it('should create TeamJoinService with correct dependencies', () => {
const service = factory.createTeamJoinService();
expect(service).toBeDefined();
expect(service.type).toBe('TeamJoinService');
expect(service.args).toHaveLength(2);
});
it('should create LeagueService with correct dependencies', () => {
const service = factory.createLeagueService();
expect(service).toBeDefined();
expect(service.type).toBe('LeagueService');
expect(service.args).toHaveLength(3);
});
it('should create LeagueMembershipService with correct dependencies', () => {
const service = factory.createLeagueMembershipService();
expect(service).toBeDefined();
expect(service.type).toBe('LeagueMembershipService');
expect(service.args).toHaveLength(2);
});
it('should create SponsorService with correct dependencies', () => {
const service = factory.createSponsorService();
expect(service).toBeDefined();
expect(service.type).toBe('SponsorService');
expect(service.args).toHaveLength(4);
});
it('should create SponsorshipService with correct dependencies', () => {
const service = factory.createSponsorshipService();
expect(service).toBeDefined();
expect(service.type).toBe('SponsorshipService');
expect(service.args).toHaveLength(3);
});
it('should create PaymentService with correct dependencies', () => {
const service = factory.createPaymentService();
expect(service).toBeDefined();
expect(service.type).toBe('PaymentService');
expect(service.args).toHaveLength(6);
});
it('should create AnalyticsService with correct dependencies', () => {
const service = factory.createAnalyticsService();
expect(service).toBeDefined();
expect(service.type).toBe('AnalyticsService');
expect(service.args).toHaveLength(1);
});
it('should create DashboardService with correct dependencies', () => {
const service = factory.createDashboardService();
expect(service).toBeDefined();
expect(service.type).toBe('DashboardService');
expect(service.args).toHaveLength(3);
});
it('should create MediaService with correct dependencies', () => {
const service = factory.createMediaService();
expect(service).toBeDefined();
expect(service.type).toBe('MediaService');
expect(service.args).toHaveLength(2);
});
it('should create AvatarService with correct dependencies', () => {
const service = factory.createAvatarService();
expect(service).toBeDefined();
expect(service.type).toBe('AvatarService');
expect(service.args).toHaveLength(2);
});
it('should create WalletService with correct dependencies', () => {
const service = factory.createWalletService();
expect(service).toBeDefined();
expect(service.type).toBe('WalletService');
expect(service.args).toHaveLength(1);
});
it('should create MembershipFeeService with correct dependencies', () => {
const service = factory.createMembershipFeeService();
expect(service).toBeDefined();
expect(service.type).toBe('MembershipFeeService');
expect(service.args).toHaveLength(1);
});
it('should create AuthService with correct dependencies', () => {
const service = factory.createAuthService();
expect(service).toBeDefined();
expect(service.type).toBe('AuthService');
expect(service.args).toHaveLength(1);
});
it('should create SessionService with correct dependencies', () => {
const service = factory.createSessionService();
expect(service).toBeDefined();
expect(service.type).toBe('SessionService');
expect(service.args).toHaveLength(2);
});
});

View File

@@ -0,0 +1,346 @@
import { RacesApiClient } from '../api/races/RacesApiClient';
import { DriversApiClient } from '../api/drivers/DriversApiClient';
import { TeamsApiClient } from '../api/teams/TeamsApiClient';
import { LeaguesApiClient } from '../api/leagues/LeaguesApiClient';
import { SponsorsApiClient } from '../api/sponsors/SponsorsApiClient';
import { PaymentsApiClient } from '../api/payments/PaymentsApiClient';
import { AuthApiClient } from '../api/auth/AuthApiClient';
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
import { MediaApiClient } from '../api/media/MediaApiClient';
// Services
import { RaceService } from './races/RaceService';
import { RaceResultsService } from './races/RaceResultsService';
import { DriverService } from './drivers/DriverService';
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
import { TeamService } from './teams/TeamService';
import { TeamJoinService } from './teams/TeamJoinService';
import { LeagueService } from './leagues/LeagueService';
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
import { SponsorService } from './sponsors/SponsorService';
import { SponsorshipService } from './sponsors/SponsorshipService';
import { PaymentService } from './payments/PaymentService';
import { AnalyticsService } from './analytics/AnalyticsService';
import { DashboardService } from './analytics/DashboardService';
import { MediaService } from './media/MediaService';
import { AvatarService } from './media/AvatarService';
import { WalletService } from './payments/WalletService';
import { MembershipFeeService } from './payments/MembershipFeeService';
import { AuthService } from './auth/AuthService';
import { SessionService } from './auth/SessionService';
// Presenters
import { RaceDetailPresenter } from '../presenters/RaceDetailPresenter';
import { RaceResultsDetailPresenter } from '../presenters/RaceResultsDetailPresenter';
import { RaceWithSOFPresenter } from '../presenters/RaceWithSOFPresenter';
import { ImportRaceResultsPresenter } from '../presenters/ImportRaceResultsPresenter';
import { DriversLeaderboardPresenter } from '../presenters/DriversLeaderboardPresenter';
import { DriverPresenter } from '../presenters/DriverPresenter';
import { CompleteOnboardingPresenter } from '../presenters/CompleteOnboardingPresenter';
import { DriverRegistrationStatusPresenter } from '../presenters/DriverRegistrationStatusPresenter';
import { TeamDetailsPresenter } from '../presenters/TeamDetailsPresenter';
import { TeamListPresenter } from '../presenters/TeamListPresenter';
import { TeamMembersPresenter } from '../presenters/TeamMembersPresenter';
import { TeamJoinRequestPresenter } from '../presenters/TeamJoinRequestPresenter';
import { LeagueSummaryPresenter } from '../presenters/LeagueSummaryPresenter';
import { LeagueStandingsPresenter } from '../presenters/LeagueStandingsPresenter';
import { LeagueMembersPresenter } from '../presenters/LeagueMembersPresenter';
import { SponsorListPresenter } from '../presenters/SponsorListPresenter';
import { SponsorDashboardPresenter } from '../presenters/SponsorDashboardPresenter';
import { SponsorSponsorshipsPresenter } from '../presenters/SponsorSponsorshipsPresenter';
import { SponsorshipPricingPresenter } from '../presenters/SponsorshipPricingPresenter';
import { PaymentListPresenter } from '../presenters/PaymentListPresenter';
import { presentPayment } from '../presenters/PaymentPresenter';
import { presentMembershipFee } from '../presenters/MembershipFeePresenter';
import { presentPrize } from '../presenters/PrizePresenter';
import { presentWallet } from '../presenters/WalletPresenter';
import { AnalyticsDashboardPresenter } from '../presenters/AnalyticsDashboardPresenter';
import { AnalyticsMetricsPresenter } from '../presenters/AnalyticsMetricsPresenter';
import { MediaPresenter } from '../presenters/MediaPresenter';
import { AvatarPresenter } from '../presenters/AvatarPresenter';
import { SessionPresenter } from '../presenters/SessionPresenter';
/**
* ServiceFactory - Composition root for all services
*
* Centralizes service creation and dependency injection wiring.
* Each factory method creates fresh instances with proper dependencies.
*/
export class ServiceFactory {
private readonly apiClients: {
races: RacesApiClient;
drivers: DriversApiClient;
teams: TeamsApiClient;
leagues: LeaguesApiClient;
sponsors: SponsorsApiClient;
payments: PaymentsApiClient;
auth: AuthApiClient;
analytics: AnalyticsApiClient;
media: MediaApiClient;
};
private readonly presenters: {
raceDetail: RaceDetailPresenter;
raceResultsDetail: RaceResultsDetailPresenter;
raceWithSOF: RaceWithSOFPresenter;
importRaceResults: ImportRaceResultsPresenter;
driversLeaderboard: DriversLeaderboardPresenter;
driver: DriverPresenter;
completeOnboarding: CompleteOnboardingPresenter;
driverRegistrationStatus: DriverRegistrationStatusPresenter;
teamDetails: TeamDetailsPresenter;
teamList: TeamListPresenter;
teamMembers: TeamMembersPresenter;
teamJoinRequest: TeamJoinRequestPresenter;
leagueSummary: LeagueSummaryPresenter;
leagueStandings: LeagueStandingsPresenter;
leagueMembers: LeagueMembersPresenter;
sponsorList: SponsorListPresenter;
sponsorDashboard: SponsorDashboardPresenter;
sponsorSponsorships: SponsorSponsorshipsPresenter;
sponsorshipPricing: SponsorshipPricingPresenter;
paymentList: PaymentListPresenter;
analyticsDashboard: AnalyticsDashboardPresenter;
analyticsMetrics: AnalyticsMetricsPresenter;
media: MediaPresenter;
avatar: AvatarPresenter;
session: SessionPresenter;
};
constructor(baseUrl: string) {
// Initialize API clients
this.apiClients = {
races: new RacesApiClient(baseUrl),
drivers: new DriversApiClient(baseUrl),
teams: new TeamsApiClient(baseUrl),
leagues: new LeaguesApiClient(baseUrl),
sponsors: new SponsorsApiClient(baseUrl),
payments: new PaymentsApiClient(baseUrl),
auth: new AuthApiClient(baseUrl),
analytics: new AnalyticsApiClient(baseUrl),
media: new MediaApiClient(baseUrl),
};
// Initialize presenters
this.presenters = {
raceDetail: new RaceDetailPresenter(),
raceResultsDetail: new RaceResultsDetailPresenter(),
raceWithSOF: new RaceWithSOFPresenter(),
importRaceResults: new ImportRaceResultsPresenter(),
driversLeaderboard: new DriversLeaderboardPresenter(),
driver: new DriverPresenter(),
completeOnboarding: new CompleteOnboardingPresenter(),
driverRegistrationStatus: new DriverRegistrationStatusPresenter(),
teamDetails: new TeamDetailsPresenter(),
teamList: new TeamListPresenter(),
teamMembers: new TeamMembersPresenter(),
teamJoinRequest: new TeamJoinRequestPresenter(),
leagueSummary: new LeagueSummaryPresenter(),
leagueStandings: new LeagueStandingsPresenter(),
leagueMembers: new LeagueMembersPresenter(),
sponsorList: new SponsorListPresenter(),
sponsorDashboard: new SponsorDashboardPresenter(),
sponsorSponsorships: new SponsorSponsorshipsPresenter(),
sponsorshipPricing: new SponsorshipPricingPresenter(),
paymentList: new PaymentListPresenter(),
analyticsDashboard: new AnalyticsDashboardPresenter(),
analyticsMetrics: new AnalyticsMetricsPresenter(),
media: new MediaPresenter(),
avatar: new AvatarPresenter(),
session: new SessionPresenter(),
};
}
/**
* Create RaceService instance
*/
createRaceService(): RaceService {
return new RaceService(
this.apiClients.races,
this.presenters.raceDetail
);
}
/**
* Create RaceResultsService instance
*/
createRaceResultsService(): RaceResultsService {
return new RaceResultsService(
this.apiClients.races,
this.presenters.raceResultsDetail,
this.presenters.raceWithSOF,
this.presenters.importRaceResults
);
}
/**
* Create DriverService instance
*/
createDriverService(): DriverService {
return new DriverService(
this.apiClients.drivers,
this.presenters.driversLeaderboard,
this.presenters.driver,
this.presenters.completeOnboarding
);
}
/**
* Create DriverRegistrationService instance
*/
createDriverRegistrationService(): DriverRegistrationService {
return new DriverRegistrationService(
this.apiClients.drivers,
this.presenters.driverRegistrationStatus
);
}
/**
* Create TeamService instance
*/
createTeamService(): TeamService {
return new TeamService(
this.apiClients.teams,
this.presenters.teamList,
this.presenters.teamDetails,
this.presenters.teamMembers
);
}
/**
* Create TeamJoinService instance
*/
createTeamJoinService(): TeamJoinService {
return new TeamJoinService(
this.apiClients.teams,
this.presenters.teamJoinRequest
);
}
/**
* Create LeagueService instance
*/
createLeagueService(): LeagueService {
return new LeagueService(
this.apiClients.leagues,
this.presenters.leagueSummary,
this.presenters.leagueStandings
);
}
/**
* Create LeagueMembershipService instance
*/
createLeagueMembershipService(): LeagueMembershipService {
return new LeagueMembershipService(
this.apiClients.leagues,
this.presenters.leagueMembers
);
}
/**
* Create SponsorService instance
*/
createSponsorService(): SponsorService {
return new SponsorService(
this.apiClients.sponsors,
this.presenters.sponsorList,
this.presenters.sponsorDashboard,
this.presenters.sponsorSponsorships
);
}
/**
* Create SponsorshipService instance
*/
createSponsorshipService(): SponsorshipService {
return new SponsorshipService(
this.apiClients.sponsors,
this.presenters.sponsorshipPricing,
this.presenters.sponsorSponsorships
);
}
/**
* Create PaymentService instance
*/
createPaymentService(): PaymentService {
return new PaymentService(
this.apiClients.payments,
this.presenters.paymentList,
presentPayment,
presentMembershipFee,
presentPrize,
presentWallet
);
}
/**
* Create AnalyticsService instance
*/
createAnalyticsService(): AnalyticsService {
return new AnalyticsService(this.apiClients.analytics);
}
/**
* Create DashboardService instance
*/
createDashboardService(): DashboardService {
return new DashboardService(
this.apiClients.analytics,
this.presenters.analyticsDashboard,
this.presenters.analyticsMetrics
);
}
/**
* Create MediaService instance
*/
createMediaService(): MediaService {
return new MediaService(
this.apiClients.media,
this.presenters.media
);
}
/**
* Create AvatarService instance
*/
createAvatarService(): AvatarService {
return new AvatarService(
this.apiClients.media,
this.presenters.avatar
);
}
/**
* Create WalletService instance
*/
createWalletService(): WalletService {
return new WalletService(this.apiClients.payments);
}
/**
* Create MembershipFeeService instance
*/
createMembershipFeeService(): MembershipFeeService {
return new MembershipFeeService(this.apiClients.payments);
}
/**
* Create AuthService instance
*/
createAuthService(): AuthService {
return new AuthService(this.apiClients.auth);
}
/**
* Create SessionService instance
*/
createSessionService(): SessionService {
return new SessionService(
this.apiClients.auth,
this.presenters.session
);
}
}

View File

@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AnalyticsService } from './AnalyticsService';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import type { RecordPageViewInputDto, RecordPageViewOutputDto, RecordEngagementInputDto, RecordEngagementOutputDto } from '../../dtos';
describe('AnalyticsService', () => {
let mockApiClient: AnalyticsApiClient;
let service: AnalyticsService;
beforeEach(() => {
mockApiClient = {
recordPageView: vi.fn(),
recordEngagement: vi.fn(),
getDashboardData: vi.fn(),
getAnalyticsMetrics: vi.fn(),
} as unknown as AnalyticsApiClient;
service = new AnalyticsService(mockApiClient);
});
describe('recordPageView', () => {
it('should record page view via API client', async () => {
// Arrange
const input: RecordPageViewInputDto = {
page: '/dashboard',
timestamp: '2025-12-17T20:00:00Z',
userId: 'user-1',
};
const expectedOutput: RecordPageViewOutputDto = {
success: true,
};
vi.mocked(mockApiClient.recordPageView).mockResolvedValue(expectedOutput);
// Act
const result = await service.recordPageView(input);
// Assert
expect(mockApiClient.recordPageView).toHaveBeenCalledWith(input);
expect(mockApiClient.recordPageView).toHaveBeenCalledTimes(1);
expect(result).toBe(expectedOutput);
});
it('should propagate API client errors', async () => {
// Arrange
const input: RecordPageViewInputDto = {
page: '/dashboard',
timestamp: '2025-12-17T20:00:00Z',
userId: 'user-1',
};
const error = new Error('API Error: Failed to record page view');
vi.mocked(mockApiClient.recordPageView).mockRejectedValue(error);
// Act & Assert
await expect(service.recordPageView(input)).rejects.toThrow(
'API Error: Failed to record page view'
);
expect(mockApiClient.recordPageView).toHaveBeenCalledWith(input);
expect(mockApiClient.recordPageView).toHaveBeenCalledTimes(1);
});
it('should handle different input parameters', async () => {
// Arrange
const input: RecordPageViewInputDto = {
page: '/races',
timestamp: '2025-12-18T10:30:00Z',
userId: 'user-2',
};
const expectedOutput: RecordPageViewOutputDto = {
success: true,
};
vi.mocked(mockApiClient.recordPageView).mockResolvedValue(expectedOutput);
// Act
const result = await service.recordPageView(input);
// Assert
expect(mockApiClient.recordPageView).toHaveBeenCalledWith(input);
expect(result).toBe(expectedOutput);
});
});
describe('recordEngagement', () => {
it('should record engagement event via API client', async () => {
// Arrange
const input: RecordEngagementInputDto = {
event: 'button_click',
element: 'register-race-btn',
page: '/races',
timestamp: '2025-12-17T20:00:00Z',
userId: 'user-1',
};
const expectedOutput: RecordEngagementOutputDto = {
success: true,
};
vi.mocked(mockApiClient.recordEngagement).mockResolvedValue(expectedOutput);
// Act
const result = await service.recordEngagement(input);
// Assert
expect(mockApiClient.recordEngagement).toHaveBeenCalledWith(input);
expect(mockApiClient.recordEngagement).toHaveBeenCalledTimes(1);
expect(result).toBe(expectedOutput);
});
it('should propagate API client errors', async () => {
// Arrange
const input: RecordEngagementInputDto = {
event: 'form_submit',
element: 'contact-form',
page: '/contact',
timestamp: '2025-12-17T20:00:00Z',
userId: 'user-1',
};
const error = new Error('API Error: Failed to record engagement');
vi.mocked(mockApiClient.recordEngagement).mockRejectedValue(error);
// Act & Assert
await expect(service.recordEngagement(input)).rejects.toThrow(
'API Error: Failed to record engagement'
);
expect(mockApiClient.recordEngagement).toHaveBeenCalledWith(input);
expect(mockApiClient.recordEngagement).toHaveBeenCalledTimes(1);
});
it('should handle different engagement types', async () => {
// Arrange
const input: RecordEngagementInputDto = {
event: 'scroll',
element: 'race-list',
page: '/races',
timestamp: '2025-12-18T10:30:00Z',
userId: 'user-2',
};
const expectedOutput: RecordEngagementOutputDto = {
success: true,
};
vi.mocked(mockApiClient.recordEngagement).mockResolvedValue(expectedOutput);
// Act
const result = await service.recordEngagement(input);
// Assert
expect(mockApiClient.recordEngagement).toHaveBeenCalledWith(input);
expect(result).toBe(expectedOutput);
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient', () => {
// This test verifies the constructor signature
expect(() => {
new AnalyticsService(mockApiClient);
}).not.toThrow();
});
it('should use injected apiClient', async () => {
// Arrange
const customApiClient = {
recordPageView: vi.fn().mockResolvedValue({ success: true }),
recordEngagement: vi.fn().mockResolvedValue({ success: true }),
getDashboardData: vi.fn(),
getAnalyticsMetrics: vi.fn(),
} as unknown as AnalyticsApiClient;
const customService = new AnalyticsService(customApiClient);
const input: RecordPageViewInputDto = {
page: '/test',
timestamp: '2025-12-17T20:00:00Z',
userId: 'user-1',
};
// Act
await customService.recordPageView(input);
// Assert
expect(customApiClient.recordPageView).toHaveBeenCalledWith(input);
});
});
});

View File

@@ -1,9 +1,36 @@
import { api as api } from '../../api';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import type { RecordPageViewInputDto, RecordPageViewOutputDto, RecordEngagementInputDto, RecordEngagementOutputDto } from '../../dtos';
export async function recordPageView(input: any): Promise<any> {
return await api.analytics.recordPageView(input);
}
/**
* Analytics Service
*
* Orchestrates analytics operations by coordinating API calls.
* All dependencies are injected via constructor.
*/
export class AnalyticsService {
constructor(
private readonly apiClient: AnalyticsApiClient
) {}
export async function recordEngagement(input: any): Promise<any> {
return await api.analytics.recordEngagement(input);
/**
* Record a page view
*/
async recordPageView(input: RecordPageViewInputDto): Promise<RecordPageViewOutputDto> {
try {
return await this.apiClient.recordPageView(input);
} catch (error) {
throw error;
}
}
/**
* Record an engagement event
*/
async recordEngagement(input: RecordEngagementInputDto): Promise<RecordEngagementOutputDto> {
try {
return await this.apiClient.recordEngagement(input);
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DashboardService } from './DashboardService';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { AnalyticsDashboardPresenter } from '../../presenters/AnalyticsDashboardPresenter';
import { AnalyticsMetricsPresenter } from '../../presenters/AnalyticsMetricsPresenter';
import type { AnalyticsDashboardDto, AnalyticsMetricsDto } from '../../dtos';
import { AnalyticsDashboardViewModel, AnalyticsMetricsViewModel } from '../../view-models';
describe('DashboardService', () => {
let mockApiClient: AnalyticsApiClient;
let mockDashboardPresenter: AnalyticsDashboardPresenter;
let mockMetricsPresenter: AnalyticsMetricsPresenter;
let service: DashboardService;
beforeEach(() => {
mockApiClient = {
recordPageView: vi.fn(),
recordEngagement: vi.fn(),
getDashboardData: vi.fn(),
getAnalyticsMetrics: vi.fn(),
} as unknown as AnalyticsApiClient;
mockDashboardPresenter = {
present: vi.fn(),
} as unknown as AnalyticsDashboardPresenter;
mockMetricsPresenter = {
present: vi.fn(),
} as unknown as AnalyticsMetricsPresenter;
service = new DashboardService(mockApiClient, mockDashboardPresenter, mockMetricsPresenter);
});
describe('getDashboardData', () => {
it('should fetch dashboard data from API and transform via presenter', async () => {
// Arrange
const mockDto: AnalyticsDashboardDto = {
totalUsers: 1000,
activeUsers: 750,
totalRaces: 50,
totalLeagues: 25,
};
const mockViewModel: AnalyticsDashboardViewModel = new AnalyticsDashboardViewModel(mockDto);
vi.mocked(mockApiClient.getDashboardData).mockResolvedValue(mockDto);
vi.mocked(mockDashboardPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getDashboardData();
// Assert
expect(mockApiClient.getDashboardData).toHaveBeenCalledTimes(1);
expect(mockDashboardPresenter.present).toHaveBeenCalledWith(mockDto);
expect(mockDashboardPresenter.present).toHaveBeenCalledTimes(1);
expect(result).toBe(mockViewModel);
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Failed to fetch dashboard data');
vi.mocked(mockApiClient.getDashboardData).mockRejectedValue(error);
// Act & Assert
await expect(service.getDashboardData()).rejects.toThrow(
'API Error: Failed to fetch dashboard data'
);
expect(mockApiClient.getDashboardData).toHaveBeenCalledTimes(1);
expect(mockDashboardPresenter.present).not.toHaveBeenCalled();
});
it('should propagate presenter errors', async () => {
// Arrange
const mockDto: AnalyticsDashboardDto = {
totalUsers: 500,
activeUsers: 300,
totalRaces: 20,
totalLeagues: 10,
};
const error = new Error('Presenter Error: Invalid DTO structure');
vi.mocked(mockApiClient.getDashboardData).mockResolvedValue(mockDto);
vi.mocked(mockDashboardPresenter.present).mockImplementation(() => {
throw error;
});
// Act & Assert
await expect(service.getDashboardData()).rejects.toThrow(
'Presenter Error: Invalid DTO structure'
);
expect(mockApiClient.getDashboardData).toHaveBeenCalledTimes(1);
expect(mockDashboardPresenter.present).toHaveBeenCalledWith(mockDto);
});
});
describe('getAnalyticsMetrics', () => {
it('should fetch analytics metrics from API and transform via presenter', async () => {
// Arrange
const mockDto: AnalyticsMetricsDto = {
pageViews: 5000,
uniqueVisitors: 1200,
averageSessionDuration: 180,
bounceRate: 35.5,
};
const mockViewModel: AnalyticsMetricsViewModel = new AnalyticsMetricsViewModel(mockDto);
vi.mocked(mockApiClient.getAnalyticsMetrics).mockResolvedValue(mockDto);
vi.mocked(mockMetricsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getAnalyticsMetrics();
// Assert
expect(mockApiClient.getAnalyticsMetrics).toHaveBeenCalledTimes(1);
expect(mockMetricsPresenter.present).toHaveBeenCalledWith(mockDto);
expect(mockMetricsPresenter.present).toHaveBeenCalledTimes(1);
expect(result).toBe(mockViewModel);
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Failed to fetch analytics metrics');
vi.mocked(mockApiClient.getAnalyticsMetrics).mockRejectedValue(error);
// Act & Assert
await expect(service.getAnalyticsMetrics()).rejects.toThrow(
'API Error: Failed to fetch analytics metrics'
);
expect(mockApiClient.getAnalyticsMetrics).toHaveBeenCalledTimes(1);
expect(mockMetricsPresenter.present).not.toHaveBeenCalled();
});
it('should propagate presenter errors', async () => {
// Arrange
const mockDto: AnalyticsMetricsDto = {
pageViews: 2500,
uniqueVisitors: 600,
averageSessionDuration: 120,
bounceRate: 45.2,
};
const error = new Error('Presenter Error: Invalid metrics data');
vi.mocked(mockApiClient.getAnalyticsMetrics).mockResolvedValue(mockDto);
vi.mocked(mockMetricsPresenter.present).mockImplementation(() => {
throw error;
});
// Act & Assert
await expect(service.getAnalyticsMetrics()).rejects.toThrow(
'Presenter Error: Invalid metrics data'
);
expect(mockApiClient.getAnalyticsMetrics).toHaveBeenCalledTimes(1);
expect(mockMetricsPresenter.present).toHaveBeenCalledWith(mockDto);
});
});
describe('getDashboardOverview', () => {
it('should delegate to getDashboardData for backward compatibility', async () => {
// Arrange
const mockDto: AnalyticsDashboardDto = {
totalUsers: 800,
activeUsers: 600,
totalRaces: 40,
totalLeagues: 20,
};
const mockViewModel: AnalyticsDashboardViewModel = new AnalyticsDashboardViewModel(mockDto);
vi.mocked(mockApiClient.getDashboardData).mockResolvedValue(mockDto);
vi.mocked(mockDashboardPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getDashboardOverview();
// Assert
expect(mockApiClient.getDashboardData).toHaveBeenCalledTimes(1);
expect(mockDashboardPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toBe(mockViewModel);
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient, dashboardPresenter, and metricsPresenter', () => {
// This test verifies the constructor signature
expect(() => {
new DashboardService(mockApiClient, mockDashboardPresenter, mockMetricsPresenter);
}).not.toThrow();
});
it('should use injected dependencies', async () => {
// Arrange
const customApiClient = {
recordPageView: vi.fn(),
recordEngagement: vi.fn(),
getDashboardData: vi.fn().mockResolvedValue({
totalUsers: 100,
activeUsers: 80,
totalRaces: 5,
totalLeagues: 3,
}),
getAnalyticsMetrics: vi.fn(),
} as unknown as AnalyticsApiClient;
const customDashboardPresenter = {
present: vi.fn().mockReturnValue({} as AnalyticsDashboardViewModel),
} as unknown as AnalyticsDashboardPresenter;
const customMetricsPresenter = {
present: vi.fn(),
} as unknown as AnalyticsMetricsPresenter;
const customService = new DashboardService(customApiClient, customDashboardPresenter, customMetricsPresenter);
// Act
await customService.getDashboardData();
// Assert
expect(customApiClient.getDashboardData).toHaveBeenCalledTimes(1);
expect(customDashboardPresenter.present).toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,50 @@
import { api as api } from '../../api';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { AnalyticsDashboardPresenter } from '../../presenters/AnalyticsDashboardPresenter';
import { AnalyticsMetricsPresenter } from '../../presenters/AnalyticsMetricsPresenter';
import type { AnalyticsDashboardViewModel, AnalyticsMetricsViewModel } from '../../view-models';
export async function getDashboardOverview(): Promise<any> {
// TODO: aggregate data
return {};
/**
* Dashboard Service
*
* Orchestrates dashboard operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class DashboardService {
constructor(
private readonly apiClient: AnalyticsApiClient,
private readonly analyticsDashboardPresenter: AnalyticsDashboardPresenter,
private readonly analyticsMetricsPresenter: AnalyticsMetricsPresenter
) {}
/**
* Get dashboard data with presentation transformation
*/
async getDashboardData(): Promise<AnalyticsDashboardViewModel> {
try {
const dto = await this.apiClient.getDashboardData();
return this.analyticsDashboardPresenter.present(dto);
} catch (error) {
throw error;
}
}
/**
* Get analytics metrics with presentation transformation
*/
async getAnalyticsMetrics(): Promise<AnalyticsMetricsViewModel> {
try {
const dto = await this.apiClient.getAnalyticsMetrics();
return this.analyticsMetricsPresenter.present(dto);
} catch (error) {
throw error;
}
}
/**
* Get dashboard overview (legacy method for backward compatibility)
* TODO: Remove when no longer needed
*/
async getDashboardOverview(): Promise<AnalyticsDashboardViewModel> {
return this.getDashboardData();
}
}

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthService } from './AuthService';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import type { LoginParamsDto, SignupParamsDto, SessionDataDto } from '../../dtos';
describe('AuthService', () => {
let mockApiClient: AuthApiClient;
let service: AuthService;
beforeEach(() => {
mockApiClient = {
signup: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
getSession: vi.fn(),
getIracingAuthUrl: vi.fn(),
} as unknown as AuthApiClient;
service = new AuthService(mockApiClient);
});
describe('signup', () => {
it('should sign up user via API client', async () => {
// Arrange
const params: SignupParamsDto = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
};
const expectedSession: SessionDataDto = {
userId: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
isAuthenticated: true,
};
vi.mocked(mockApiClient.signup).mockResolvedValue(expectedSession);
// Act
const result = await service.signup(params);
// Assert
expect(mockApiClient.signup).toHaveBeenCalledWith(params);
expect(mockApiClient.signup).toHaveBeenCalledTimes(1);
expect(result).toBe(expectedSession);
});
it('should propagate API client errors', async () => {
// Arrange
const params: SignupParamsDto = {
email: 'test@example.com',
password: 'password123',
};
const error = new Error('API Error: Failed to sign up');
vi.mocked(mockApiClient.signup).mockRejectedValue(error);
// Act & Assert
await expect(service.signup(params)).rejects.toThrow(
'API Error: Failed to sign up'
);
expect(mockApiClient.signup).toHaveBeenCalledWith(params);
expect(mockApiClient.signup).toHaveBeenCalledTimes(1);
});
});
describe('login', () => {
it('should log in user via API client', async () => {
// Arrange
const params: LoginParamsDto = {
email: 'test@example.com',
password: 'password123',
};
const expectedSession: SessionDataDto = {
userId: 'user-1',
email: 'test@example.com',
isAuthenticated: true,
};
vi.mocked(mockApiClient.login).mockResolvedValue(expectedSession);
// Act
const result = await service.login(params);
// Assert
expect(mockApiClient.login).toHaveBeenCalledWith(params);
expect(mockApiClient.login).toHaveBeenCalledTimes(1);
expect(result).toBe(expectedSession);
});
it('should propagate API client errors', async () => {
// Arrange
const params: LoginParamsDto = {
email: 'test@example.com',
password: 'password123',
};
const error = new Error('API Error: Invalid credentials');
vi.mocked(mockApiClient.login).mockRejectedValue(error);
// Act & Assert
await expect(service.login(params)).rejects.toThrow(
'API Error: Invalid credentials'
);
expect(mockApiClient.login).toHaveBeenCalledWith(params);
expect(mockApiClient.login).toHaveBeenCalledTimes(1);
});
});
describe('logout', () => {
it('should log out user via API client', async () => {
// Arrange
vi.mocked(mockApiClient.logout).mockResolvedValue(undefined);
// Act
await service.logout();
// Assert
expect(mockApiClient.logout).toHaveBeenCalledTimes(1);
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Failed to logout');
vi.mocked(mockApiClient.logout).mockRejectedValue(error);
// Act & Assert
await expect(service.logout()).rejects.toThrow(
'API Error: Failed to logout'
);
expect(mockApiClient.logout).toHaveBeenCalledTimes(1);
});
});
describe('getIracingAuthUrl', () => {
it('should get iRacing auth URL via API client', () => {
// Arrange
const returnTo = '/dashboard';
const expectedUrl = 'http://localhost:3001/auth/iracing/start?returnTo=%2Fdashboard';
vi.mocked(mockApiClient.getIracingAuthUrl).mockReturnValue(expectedUrl);
// Act
const result = service.getIracingAuthUrl(returnTo);
// Assert
expect(mockApiClient.getIracingAuthUrl).toHaveBeenCalledWith(returnTo);
expect(mockApiClient.getIracingAuthUrl).toHaveBeenCalledTimes(1);
expect(result).toBe(expectedUrl);
});
it('should handle undefined returnTo', () => {
// Arrange
const expectedUrl = 'http://localhost:3001/auth/iracing/start';
vi.mocked(mockApiClient.getIracingAuthUrl).mockReturnValue(expectedUrl);
// Act
const result = service.getIracingAuthUrl();
// Assert
expect(mockApiClient.getIracingAuthUrl).toHaveBeenCalledWith(undefined);
expect(result).toBe(expectedUrl);
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient', () => {
// This test verifies the constructor signature
expect(() => {
new AuthService(mockApiClient);
}).not.toThrow();
});
it('should use injected apiClient', async () => {
// Arrange
const customApiClient = {
signup: vi.fn().mockResolvedValue({ userId: 'user-1', email: 'test@example.com', isAuthenticated: true }),
login: vi.fn(),
logout: vi.fn(),
getSession: vi.fn(),
getIracingAuthUrl: vi.fn(),
} as unknown as AuthApiClient;
const customService = new AuthService(customApiClient);
const params: SignupParamsDto = {
email: 'test@example.com',
password: 'password123',
};
// Act
await customService.signup(params);
// Assert
expect(customApiClient.signup).toHaveBeenCalledWith(params);
});
});
});

View File

@@ -1,17 +1,54 @@
import { api as api } from '../../api';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import type { LoginParamsDto, SignupParamsDto, SessionDataDto } from '../../dtos';
export async function signup(params: any): Promise<any> {
return await api.auth.signup(params);
}
/**
* Auth Service
*
* Orchestrates authentication operations by coordinating API calls.
* All dependencies are injected via constructor.
*/
export class AuthService {
constructor(
private readonly apiClient: AuthApiClient
) {}
export async function login(params: any): Promise<any> {
return await api.auth.login(params);
}
/**
* Sign up a new user
*/
async signup(params: SignupParamsDto): Promise<SessionDataDto> {
try {
return await this.apiClient.signup(params);
} catch (error) {
throw error;
}
}
export async function logout(): Promise<void> {
await api.auth.logout();
}
/**
* Log in an existing user
*/
async login(params: LoginParamsDto): Promise<SessionDataDto> {
try {
return await this.apiClient.login(params);
} catch (error) {
throw error;
}
}
export function getIracingAuthUrl(returnTo?: string): string {
return api.auth.getIracingAuthUrl(returnTo);
/**
* Log out the current user
*/
async logout(): Promise<void> {
try {
await this.apiClient.logout();
} catch (error) {
throw error;
}
}
/**
* Get iRacing authentication URL
*/
getIracingAuthUrl(returnTo?: string): string {
return this.apiClient.getIracingAuthUrl(returnTo);
}
}

View File

@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionService } from './SessionService';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionPresenter } from '../../presenters/SessionPresenter';
import { SessionViewModel } from '../../view-models';
import type { SessionDataDto } from '../../dtos';
describe('SessionService', () => {
let mockApiClient: AuthApiClient;
let mockPresenter: SessionPresenter;
let service: SessionService;
beforeEach(() => {
mockApiClient = {
getSession: vi.fn(),
signup: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
getIracingAuthUrl: vi.fn(),
} as unknown as AuthApiClient;
mockPresenter = {
presentSession: vi.fn(),
} as unknown as SessionPresenter;
service = new SessionService(mockApiClient, mockPresenter);
});
describe('getSession', () => {
it('should get session via API client and present it', async () => {
// Arrange
const dto: SessionDataDto = {
userId: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
driverId: 'driver-1',
isAuthenticated: true,
};
const expectedViewModel = new SessionViewModel(dto);
vi.mocked(mockApiClient.getSession).mockResolvedValue(dto);
vi.mocked(mockPresenter.presentSession).mockReturnValue(expectedViewModel);
// Act
const result = await service.getSession();
// Assert
expect(mockApiClient.getSession).toHaveBeenCalledTimes(1);
expect(mockPresenter.presentSession).toHaveBeenCalledWith(dto);
expect(mockPresenter.presentSession).toHaveBeenCalledTimes(1);
expect(result).toBe(expectedViewModel);
});
it('should return null when session is null', async () => {
// Arrange
vi.mocked(mockApiClient.getSession).mockResolvedValue(null);
vi.mocked(mockPresenter.presentSession).mockReturnValue(null);
// Act
const result = await service.getSession();
// Assert
expect(mockApiClient.getSession).toHaveBeenCalledTimes(1);
expect(mockPresenter.presentSession).toHaveBeenCalledWith(null);
expect(result).toBeNull();
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Failed to get session');
vi.mocked(mockApiClient.getSession).mockRejectedValue(error);
// Act & Assert
await expect(service.getSession()).rejects.toThrow(
'API Error: Failed to get session'
);
expect(mockApiClient.getSession).toHaveBeenCalledTimes(1);
expect(mockPresenter.presentSession).not.toHaveBeenCalled();
});
it('should handle different session data', async () => {
// Arrange
const dto: SessionDataDto = {
userId: 'user-2',
email: 'another@example.com',
isAuthenticated: false,
};
const expectedViewModel = new SessionViewModel(dto);
vi.mocked(mockApiClient.getSession).mockResolvedValue(dto);
vi.mocked(mockPresenter.presentSession).mockReturnValue(expectedViewModel);
// Act
const result = await service.getSession();
// Assert
expect(mockApiClient.getSession).toHaveBeenCalledTimes(1);
expect(mockPresenter.presentSession).toHaveBeenCalledWith(dto);
expect(result).toBe(expectedViewModel);
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient and presenter', () => {
// This test verifies the constructor signature
expect(() => {
new SessionService(mockApiClient, mockPresenter);
}).not.toThrow();
});
it('should use injected dependencies', async () => {
// Arrange
const customApiClient = {
getSession: vi.fn().mockResolvedValue({ userId: 'user-1', email: 'test@example.com', isAuthenticated: true }),
signup: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
getIracingAuthUrl: vi.fn(),
} as unknown as AuthApiClient;
const customPresenter = {
presentSession: vi.fn().mockReturnValue(new SessionViewModel({ userId: 'user-1', email: 'test@example.com', isAuthenticated: true })),
} as unknown as SessionPresenter;
const customService = new SessionService(customApiClient, customPresenter);
// Act
await customService.getSession();
// Assert
expect(customApiClient.getSession).toHaveBeenCalledTimes(1);
expect(customPresenter.presentSession).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,9 +1,28 @@
import { api as api } from '../../api';
import { SessionViewModel } from '../../view-models';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionPresenter } from '../../presenters/SessionPresenter';
import type { SessionViewModel } from '../../view-models';
export async function getSession(): Promise<SessionViewModel | null> {
const dto = await api.auth.getSession();
if (!dto) return null;
// TODO: presenter
return dto as any;
/**
* Session Service
*
* Orchestrates session operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class SessionService {
constructor(
private readonly apiClient: AuthApiClient,
private readonly presenter: SessionPresenter
) {}
/**
* Get current user session with presentation transformation
*/
async getSession(): Promise<SessionViewModel | null> {
try {
const dto = await this.apiClient.getSession();
return this.presenter.presentSession(dto);
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,292 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DriverRegistrationService } from './DriverRegistrationService';
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
import type { DriverRegistrationStatusPresenter } from '../../presenters/DriverRegistrationStatusPresenter';
import type { DriverRegistrationStatusDto } from '../../dtos';
import type { DriverRegistrationStatusViewModel } from '../../view-models';
describe('DriverRegistrationService', () => {
let service: DriverRegistrationService;
let mockApiClient: DriversApiClient;
let mockStatusPresenter: DriverRegistrationStatusPresenter;
beforeEach(() => {
mockApiClient = {
getRegistrationStatus: vi.fn(),
} as unknown as DriversApiClient;
mockStatusPresenter = {
present: vi.fn(),
} as unknown as DriverRegistrationStatusPresenter;
service = new DriverRegistrationService(
mockApiClient,
mockStatusPresenter
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(DriverRegistrationService);
});
});
describe('getDriverRegistrationStatus', () => {
it('should fetch registration status from API and transform via presenter', async () => {
// Arrange
const driverId = 'driver-123';
const raceId = 'race-456';
const mockDto: DriverRegistrationStatusDto = {
isRegistered: true,
raceId: 'race-456',
driverId: 'driver-123',
};
const mockViewModel = {
isRegistered: true,
raceId: 'race-456',
driverId: 'driver-123',
statusMessage: 'Registered for this race',
statusColor: 'green',
statusBadgeVariant: 'success',
registrationButtonText: 'Withdraw',
canRegister: false,
} as DriverRegistrationStatusViewModel;
vi.mocked(mockApiClient.getRegistrationStatus).mockResolvedValue(mockDto);
vi.mocked(mockStatusPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getDriverRegistrationStatus(driverId, raceId);
// Assert
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledWith(driverId, raceId);
expect(mockStatusPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle unregistered driver status', async () => {
// Arrange
const driverId = 'driver-789';
const raceId = 'race-101';
const mockDto: DriverRegistrationStatusDto = {
isRegistered: false,
raceId: 'race-101',
driverId: 'driver-789',
};
const mockViewModel = {
isRegistered: false,
raceId: 'race-101',
driverId: 'driver-789',
statusMessage: 'Not registered',
statusColor: 'red',
statusBadgeVariant: 'warning',
registrationButtonText: 'Register',
canRegister: true,
} as DriverRegistrationStatusViewModel;
vi.mocked(mockApiClient.getRegistrationStatus).mockResolvedValue(mockDto);
vi.mocked(mockStatusPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getDriverRegistrationStatus(driverId, raceId);
// Assert
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledWith(driverId, raceId);
expect(mockStatusPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.canRegister).toBe(true);
});
it('should propagate errors from API client', async () => {
// Arrange
const driverId = 'driver-123';
const raceId = 'race-456';
const error = new Error('Failed to fetch registration status');
vi.mocked(mockApiClient.getRegistrationStatus).mockRejectedValue(error);
// Act & Assert
await expect(service.getDriverRegistrationStatus(driverId, raceId))
.rejects.toThrow('Failed to fetch registration status');
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledWith(driverId, raceId);
expect(mockStatusPresenter.present).not.toHaveBeenCalled();
});
it('should handle network errors gracefully', async () => {
// Arrange
const driverId = 'driver-123';
const raceId = 'race-456';
const networkError = new Error('Network request failed');
vi.mocked(mockApiClient.getRegistrationStatus).mockRejectedValue(networkError);
// Act & Assert
await expect(service.getDriverRegistrationStatus(driverId, raceId))
.rejects.toThrow('Network request failed');
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledWith(driverId, raceId);
});
it('should handle API errors with proper error propagation', async () => {
// Arrange
const driverId = 'driver-123';
const raceId = 'race-456';
const apiError = new Error('Race not found');
vi.mocked(mockApiClient.getRegistrationStatus).mockRejectedValue(apiError);
// Act & Assert
await expect(service.getDriverRegistrationStatus(driverId, raceId))
.rejects.toThrow('Race not found');
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledWith(driverId, raceId);
expect(mockStatusPresenter.present).not.toHaveBeenCalled();
});
it('should handle multiple consecutive calls correctly', async () => {
// Arrange
const driverId = 'driver-123';
const raceId1 = 'race-456';
const raceId2 = 'race-789';
const mockDto1: DriverRegistrationStatusDto = {
isRegistered: true,
raceId: raceId1,
driverId,
};
const mockDto2: DriverRegistrationStatusDto = {
isRegistered: false,
raceId: raceId2,
driverId,
};
const mockViewModel1 = {
isRegistered: true,
raceId: raceId1,
driverId,
} as DriverRegistrationStatusViewModel;
const mockViewModel2 = {
isRegistered: false,
raceId: raceId2,
driverId,
} as DriverRegistrationStatusViewModel;
vi.mocked(mockApiClient.getRegistrationStatus)
.mockResolvedValueOnce(mockDto1)
.mockResolvedValueOnce(mockDto2);
vi.mocked(mockStatusPresenter.present)
.mockReturnValueOnce(mockViewModel1)
.mockReturnValueOnce(mockViewModel2);
// Act
const result1 = await service.getDriverRegistrationStatus(driverId, raceId1);
const result2 = await service.getDriverRegistrationStatus(driverId, raceId2);
// Assert
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledTimes(2);
expect(mockApiClient.getRegistrationStatus).toHaveBeenNthCalledWith(1, driverId, raceId1);
expect(mockApiClient.getRegistrationStatus).toHaveBeenNthCalledWith(2, driverId, raceId2);
expect(result1.isRegistered).toBe(true);
expect(result2.isRegistered).toBe(false);
});
it('should handle different driver IDs for same race', async () => {
// Arrange
const driverId1 = 'driver-123';
const driverId2 = 'driver-456';
const raceId = 'race-789';
const mockDto1: DriverRegistrationStatusDto = {
isRegistered: true,
raceId,
driverId: driverId1,
};
const mockDto2: DriverRegistrationStatusDto = {
isRegistered: false,
raceId,
driverId: driverId2,
};
const mockViewModel1 = {
isRegistered: true,
raceId,
driverId: driverId1,
} as DriverRegistrationStatusViewModel;
const mockViewModel2 = {
isRegistered: false,
raceId,
driverId: driverId2,
} as DriverRegistrationStatusViewModel;
vi.mocked(mockApiClient.getRegistrationStatus)
.mockResolvedValueOnce(mockDto1)
.mockResolvedValueOnce(mockDto2);
vi.mocked(mockStatusPresenter.present)
.mockReturnValueOnce(mockViewModel1)
.mockReturnValueOnce(mockViewModel2);
// Act
const result1 = await service.getDriverRegistrationStatus(driverId1, raceId);
const result2 = await service.getDriverRegistrationStatus(driverId2, raceId);
// Assert
expect(result1.driverId).toBe(driverId1);
expect(result1.isRegistered).toBe(true);
expect(result2.driverId).toBe(driverId2);
expect(result2.isRegistered).toBe(false);
});
it('should handle unauthorized access errors', async () => {
// Arrange
const driverId = 'driver-123';
const raceId = 'race-456';
const authError = new Error('Unauthorized: Driver not found');
vi.mocked(mockApiClient.getRegistrationStatus).mockRejectedValue(authError);
// Act & Assert
await expect(service.getDriverRegistrationStatus(driverId, raceId))
.rejects.toThrow('Unauthorized: Driver not found');
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledWith(driverId, raceId);
expect(mockStatusPresenter.present).not.toHaveBeenCalled();
});
it('should call presenter only after successful API response', async () => {
// Arrange
const driverId = 'driver-123';
const raceId = 'race-456';
const mockDto: DriverRegistrationStatusDto = {
isRegistered: true,
raceId: 'race-456',
driverId: 'driver-123',
};
const mockViewModel = {
isRegistered: true,
raceId: 'race-456',
driverId: 'driver-123',
} as DriverRegistrationStatusViewModel;
vi.mocked(mockApiClient.getRegistrationStatus).mockResolvedValue(mockDto);
vi.mocked(mockStatusPresenter.present).mockReturnValue(mockViewModel);
// Act
await service.getDriverRegistrationStatus(driverId, raceId);
// Assert - verify call order
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalled();
expect(mockStatusPresenter.present).toHaveBeenCalledAfter(
mockApiClient.getRegistrationStatus as any
);
});
});
});

View File

@@ -1,14 +1,27 @@
import { api as api } from '../../api';
import { presentDriverRegistrationStatus } from '../../presenters';
import { DriverRegistrationStatusViewModel } from '../../view-models';
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
import type { DriverRegistrationStatusPresenter } from '../../presenters/DriverRegistrationStatusPresenter';
import type { DriverRegistrationStatusViewModel } from '../../view-models';
export async function getDriverRegistrationStatus(driverId: string): Promise<DriverRegistrationStatusViewModel> {
// TODO: implement API call
const dto = { driverId, status: 'pending' };
return presentDriverRegistrationStatus(dto);
}
/**
* Driver Registration Service
*
* Orchestrates driver registration status operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class DriverRegistrationService {
constructor(
private readonly apiClient: DriversApiClient,
private readonly statusPresenter: DriverRegistrationStatusPresenter
) {}
export async function registerDriver(input: any): Promise<any> {
// TODO: implement
return {};
/**
* Get driver registration status for a specific race
*/
async getDriverRegistrationStatus(
driverId: string,
raceId: string
): Promise<DriverRegistrationStatusViewModel> {
const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
return this.statusPresenter.present(dto);
}
}

View File

@@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DriverService } from './DriverService';
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
import type { DriversLeaderboardPresenter } from '../../presenters/DriversLeaderboardPresenter';
import type { DriverPresenter } from '../../presenters/DriverPresenter';
import type { CompleteOnboardingPresenter } from '../../presenters/CompleteOnboardingPresenter';
import type { DriversLeaderboardDto, CompleteOnboardingOutputDto, DriverDto, CompleteOnboardingInputDto } from '../../dtos';
import type { DriverLeaderboardViewModel, DriverViewModel, CompleteOnboardingViewModel } from '../../view-models';
describe('DriverService', () => {
let service: DriverService;
let mockApiClient: DriversApiClient;
let mockLeaderboardPresenter: DriversLeaderboardPresenter;
let mockDriverPresenter: DriverPresenter;
let mockOnboardingPresenter: CompleteOnboardingPresenter;
beforeEach(() => {
mockApiClient = {
getLeaderboard: vi.fn(),
completeOnboarding: vi.fn(),
getCurrent: vi.fn(),
} as unknown as DriversApiClient;
mockLeaderboardPresenter = {
present: vi.fn(),
} as unknown as DriversLeaderboardPresenter;
mockDriverPresenter = {
present: vi.fn(),
} as unknown as DriverPresenter;
mockOnboardingPresenter = {
present: vi.fn(),
} as unknown as CompleteOnboardingPresenter;
service = new DriverService(
mockApiClient,
mockLeaderboardPresenter,
mockDriverPresenter,
mockOnboardingPresenter
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(DriverService);
});
});
describe('getDriverLeaderboard', () => {
it('should fetch leaderboard from API and transform via presenter', async () => {
// Arrange
const mockDto: DriversLeaderboardDto = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 2500,
races: 50,
wins: 10,
isActive: true,
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 2300,
races: 40,
wins: 8,
isActive: true,
},
],
};
const mockViewModel = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 2500,
races: 50,
wins: 10,
isActive: true,
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 2300,
races: 40,
wins: 8,
isActive: true,
},
],
} as DriverLeaderboardViewModel;
vi.mocked(mockApiClient.getLeaderboard).mockResolvedValue(mockDto);
vi.mocked(mockLeaderboardPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getDriverLeaderboard();
// Assert
expect(mockApiClient.getLeaderboard).toHaveBeenCalled();
expect(mockLeaderboardPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle empty leaderboard', async () => {
// Arrange
const mockDto: DriversLeaderboardDto = {
drivers: [],
};
const mockViewModel = {
drivers: [],
} as DriverLeaderboardViewModel;
vi.mocked(mockApiClient.getLeaderboard).mockResolvedValue(mockDto);
vi.mocked(mockLeaderboardPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getDriverLeaderboard();
// Assert
expect(mockApiClient.getLeaderboard).toHaveBeenCalled();
expect(mockLeaderboardPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Leaderboard fetch failed');
vi.mocked(mockApiClient.getLeaderboard).mockRejectedValue(error);
// Act & Assert
await expect(service.getDriverLeaderboard()).rejects.toThrow('Leaderboard fetch failed');
expect(mockApiClient.getLeaderboard).toHaveBeenCalled();
expect(mockLeaderboardPresenter.present).not.toHaveBeenCalled();
});
});
describe('completeDriverOnboarding', () => {
it('should complete onboarding and transform via presenter', async () => {
// Arrange
const input: CompleteOnboardingInputDto = {
iracingId: '123456',
displayName: 'John Doe',
};
const mockDto: CompleteOnboardingOutputDto = {
driverId: 'driver-123',
success: true,
};
const mockViewModel: CompleteOnboardingViewModel = {
driverId: 'driver-123',
success: true,
};
vi.mocked(mockApiClient.completeOnboarding).mockResolvedValue(mockDto);
vi.mocked(mockOnboardingPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.completeDriverOnboarding(input);
// Assert
expect(mockApiClient.completeOnboarding).toHaveBeenCalledWith(input);
expect(mockOnboardingPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle onboarding failure', async () => {
// Arrange
const input: CompleteOnboardingInputDto = {
iracingId: '123456',
displayName: 'John Doe',
};
const mockDto: CompleteOnboardingOutputDto = {
driverId: '',
success: false,
};
const mockViewModel: CompleteOnboardingViewModel = {
driverId: '',
success: false,
};
vi.mocked(mockApiClient.completeOnboarding).mockResolvedValue(mockDto);
vi.mocked(mockOnboardingPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.completeDriverOnboarding(input);
// Assert
expect(mockApiClient.completeOnboarding).toHaveBeenCalledWith(input);
expect(mockOnboardingPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: CompleteOnboardingInputDto = {
iracingId: '123456',
displayName: 'John Doe',
};
const error = new Error('Onboarding failed');
vi.mocked(mockApiClient.completeOnboarding).mockRejectedValue(error);
// Act & Assert
await expect(service.completeDriverOnboarding(input)).rejects.toThrow('Onboarding failed');
expect(mockApiClient.completeOnboarding).toHaveBeenCalledWith(input);
expect(mockOnboardingPresenter.present).not.toHaveBeenCalled();
});
});
describe('getCurrentDriver', () => {
it('should fetch current driver and transform via presenter', async () => {
// Arrange
const mockDto: DriverDto = {
id: 'driver-123',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: '123456',
rating: 2500,
};
const mockViewModel: DriverViewModel = {
id: 'driver-123',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: '123456',
rating: 2500,
};
vi.mocked(mockApiClient.getCurrent).mockResolvedValue(mockDto);
vi.mocked(mockDriverPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getCurrentDriver();
// Assert
expect(mockApiClient.getCurrent).toHaveBeenCalled();
expect(mockDriverPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should return null when no current driver', async () => {
// Arrange
vi.mocked(mockApiClient.getCurrent).mockResolvedValue(null);
// Act
const result = await service.getCurrentDriver();
// Assert
expect(mockApiClient.getCurrent).toHaveBeenCalled();
expect(mockDriverPresenter.present).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle driver without optional fields', async () => {
// Arrange
const mockDto: DriverDto = {
id: 'driver-123',
name: 'John Doe',
};
const mockViewModel: DriverViewModel = {
id: 'driver-123',
name: 'John Doe',
};
vi.mocked(mockApiClient.getCurrent).mockResolvedValue(mockDto);
vi.mocked(mockDriverPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getCurrentDriver();
// Assert
expect(mockApiClient.getCurrent).toHaveBeenCalled();
expect(mockDriverPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch current driver');
vi.mocked(mockApiClient.getCurrent).mockRejectedValue(error);
// Act & Assert
await expect(service.getCurrentDriver()).rejects.toThrow('Failed to fetch current driver');
expect(mockApiClient.getCurrent).toHaveBeenCalled();
expect(mockDriverPresenter.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,43 +1,50 @@
import { api as api } from '../../api';
import { presentDriversLeaderboard } from '../../presenters';
import { DriverLeaderboardViewModel } from '../../view-models';
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
import type { DriversLeaderboardPresenter } from '../../presenters/DriversLeaderboardPresenter';
import type { DriverPresenter } from '../../presenters/DriverPresenter';
import type { CompleteOnboardingPresenter } from '../../presenters/CompleteOnboardingPresenter';
import type { DriverLeaderboardViewModel } from '../../view-models';
import type { DriverViewModel } from '../../view-models/DriverViewModel';
import type { CompleteOnboardingViewModel } from '../../view-models/CompleteOnboardingViewModel';
import type { CompleteOnboardingInputDto } from '../../dtos';
/**
* Driver Service
*
* Handles driver-related operations including profiles, leaderboards, and onboarding.
* Orchestrates driver operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class DriverService {
constructor(
private readonly apiClient = api.drivers
private readonly apiClient: DriversApiClient,
private readonly leaderboardPresenter: DriversLeaderboardPresenter,
private readonly driverPresenter: DriverPresenter,
private readonly onboardingPresenter: CompleteOnboardingPresenter
) {}
/**
* Get driver leaderboard with presentation transformation
*/
async getDriverLeaderboard(): Promise<DriverLeaderboardViewModel> {
const dto = await this.apiClient.getLeaderboard();
return presentDriversLeaderboard(dto);
return this.leaderboardPresenter.present(dto);
}
async completeDriverOnboarding(input: any): Promise<any> {
return await this.apiClient.completeOnboarding(input);
/**
* Complete driver onboarding with presentation transformation
*/
async completeDriverOnboarding(input: CompleteOnboardingInputDto): Promise<CompleteOnboardingViewModel> {
const dto = await this.apiClient.completeOnboarding(input);
return this.onboardingPresenter.present(dto);
}
async getCurrentDriver(): Promise<any> {
return await this.apiClient.getCurrent();
/**
* Get current driver with presentation transformation
*/
async getCurrentDriver(): Promise<DriverViewModel | null> {
const dto = await this.apiClient.getCurrent();
if (!dto) {
return null;
}
return this.driverPresenter.present(dto);
}
}
// Singleton instance
export const driverService = new DriverService();
// Backward compatibility functional exports
export async function getDriverLeaderboard(): Promise<DriverLeaderboardViewModel> {
return driverService.getDriverLeaderboard();
}
export async function completeDriverOnboarding(input: any): Promise<any> {
return driverService.completeDriverOnboarding(input);
}
export async function getCurrentDriver(): Promise<any> {
return driverService.getCurrentDriver();
}

View File

@@ -1,6 +0,0 @@
// Export the class-based service
export { DriverService, driverService } from './DriverService';
// Export backward compatibility functions
export { getDriverLeaderboard, completeDriverOnboarding, getCurrentDriver } from './DriverService';
export { registerDriver, getDriverRegistrationStatus } from './DriverRegistrationService';

View File

@@ -1,36 +0,0 @@
// Analytics Services
export * from './analytics/AnalyticsService';
export * from './analytics/DashboardService';
// Auth Services
export * from './auth/AuthService';
export * from './auth/SessionService';
// Driver Services
export * from './drivers/DriverService';
export * from './drivers/DriverRegistrationService';
// League Services
export * from './leagues/LeagueService';
export * from './leagues/LeagueMembershipService';
// Media Services
export * from './media/MediaService';
export * from './media/AvatarService';
// Payments Services
export * from './payments/PaymentService';
export * from './payments/WalletService';
export * from './payments/MembershipFeeService';
// Race Services
export * from './races/RaceService';
export * from './races/RaceResultsService';
// Sponsor Services
export * from './sponsors/SponsorService';
export * from './sponsors/SponsorshipService';
// Team Services
export * from './teams/TeamService';
export * from './teams/TeamJoinService';

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LeagueMembershipService } from './LeagueMembershipService';
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import type { LeagueMembersPresenter } from '../../presenters/LeagueMembersPresenter';
import type { LeagueMembershipsDto } from '../../dtos';
import type { LeagueMemberViewModel } from '../../view-models';
describe('LeagueMembershipService', () => {
let service: LeagueMembershipService;
let mockApiClient: LeaguesApiClient;
let mockLeagueMembersPresenter: LeagueMembersPresenter;
beforeEach(() => {
mockApiClient = {
getMemberships: vi.fn(),
removeMember: vi.fn(),
} as unknown as LeaguesApiClient;
mockLeagueMembersPresenter = {
present: vi.fn(),
} as unknown as LeagueMembersPresenter;
service = new LeagueMembershipService(
mockApiClient,
mockLeagueMembersPresenter
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(LeagueMembershipService);
});
});
describe('getLeagueMemberships', () => {
it('should fetch league memberships from API and transform via presenter', async () => {
// Arrange
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto: LeagueMembershipsDto = {
members: [
{
driverId: 'driver-1',
role: 'owner',
joinedAt: '2024-01-01',
},
{
driverId: 'driver-2',
role: 'member',
joinedAt: '2024-01-02',
},
],
};
const mockViewModels: LeagueMemberViewModel[] = [
{
driverId: 'driver-1',
role: 'owner',
joinedAt: '2024-01-01',
} as LeagueMemberViewModel,
{
driverId: 'driver-2',
role: 'member',
joinedAt: '2024-01-02',
} as LeagueMemberViewModel,
];
vi.mocked(mockApiClient.getMemberships).mockResolvedValue(mockDto);
vi.mocked(mockLeagueMembersPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getLeagueMemberships(leagueId, currentUserId);
// Assert
expect(mockApiClient.getMemberships).toHaveBeenCalledWith(leagueId);
expect(mockLeagueMembersPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId);
expect(result).toEqual(mockViewModels);
});
it('should handle empty memberships list', async () => {
// Arrange
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto: LeagueMembershipsDto = {
members: [],
};
const mockViewModels: LeagueMemberViewModel[] = [];
vi.mocked(mockApiClient.getMemberships).mockResolvedValue(mockDto);
vi.mocked(mockLeagueMembersPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getLeagueMemberships(leagueId, currentUserId);
// Assert
expect(mockApiClient.getMemberships).toHaveBeenCalledWith(leagueId);
expect(mockLeagueMembersPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId);
expect(result).toEqual([]);
});
it('should propagate errors from API client', async () => {
// Arrange
const leagueId = 'league-123';
const currentUserId = 'user-456';
const error = new Error('Failed to fetch memberships');
vi.mocked(mockApiClient.getMemberships).mockRejectedValue(error);
// Act & Assert
await expect(service.getLeagueMemberships(leagueId, currentUserId)).rejects.toThrow('Failed to fetch memberships');
expect(mockApiClient.getMemberships).toHaveBeenCalledWith(leagueId);
expect(mockLeagueMembersPresenter.present).not.toHaveBeenCalled();
});
it('should pass correct currentUserId to presenter', async () => {
// Arrange
const leagueId = 'league-123';
const currentUserId = 'current-user-789';
const mockDto: LeagueMembershipsDto = {
members: [
{
driverId: 'driver-1',
role: 'member',
joinedAt: '2024-01-01',
},
],
};
const mockViewModels: LeagueMemberViewModel[] = [
{
driverId: 'driver-1',
role: 'member',
joinedAt: '2024-01-01',
} as LeagueMemberViewModel,
];
vi.mocked(mockApiClient.getMemberships).mockResolvedValue(mockDto);
vi.mocked(mockLeagueMembersPresenter.present).mockReturnValue(mockViewModels);
// Act
await service.getLeagueMemberships(leagueId, currentUserId);
// Assert
expect(mockLeagueMembersPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId);
});
});
describe('removeMember', () => {
it('should remove a member from league', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'driver-admin';
const targetDriverId = 'driver-remove';
const mockOutput = { success: true };
vi.mocked(mockApiClient.removeMember).mockResolvedValue(mockOutput);
// Act
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
// Assert
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
expect(result).toEqual(mockOutput);
});
it('should handle removal failure', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'driver-admin';
const targetDriverId = 'driver-remove';
const mockOutput = { success: false };
vi.mocked(mockApiClient.removeMember).mockResolvedValue(mockOutput);
// Act
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
// Assert
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
expect(result).toEqual(mockOutput);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'driver-admin';
const targetDriverId = 'driver-remove';
const error = new Error('Failed to remove member');
vi.mocked(mockApiClient.removeMember).mockRejectedValue(error);
// Act & Assert
await expect(service.removeMember(leagueId, performerDriverId, targetDriverId)).rejects.toThrow('Failed to remove member');
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
});
it('should handle unauthorized removal attempt', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'non-admin-driver';
const targetDriverId = 'driver-remove';
const error = new Error('Unauthorized');
vi.mocked(mockApiClient.removeMember).mockRejectedValue(error);
// Act & Assert
await expect(service.removeMember(leagueId, performerDriverId, targetDriverId)).rejects.toThrow('Unauthorized');
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
});
it('should handle removing non-existent member', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'driver-admin';
const targetDriverId = 'non-existent-driver';
const error = new Error('Member not found');
vi.mocked(mockApiClient.removeMember).mockRejectedValue(error);
// Act & Assert
await expect(service.removeMember(leagueId, performerDriverId, targetDriverId)).rejects.toThrow('Member not found');
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
});
});
});

View File

@@ -1,12 +1,31 @@
import { api as api } from '../../api';
import { presentLeagueMember } from '../../presenters';
import { LeagueMemberViewModel } from '../../view-models';
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import type { LeagueMemberViewModel } from '../../view-models';
import type { LeagueMembersPresenter } from '../../presenters/LeagueMembersPresenter';
export async function getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
const dto = await api.leagues.getMemberships(leagueId);
return dto.members.map(m => presentLeagueMember(m, currentUserId));
}
/**
* League Membership Service
*
* Orchestrates league membership operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class LeagueMembershipService {
constructor(
private readonly apiClient: LeaguesApiClient,
private readonly leagueMembersPresenter: LeagueMembersPresenter
) {}
export async function removeLeagueMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<void> {
await api.leagues.removeMember(leagueId, performerDriverId, targetDriverId);
/**
* Get league memberships with presentation transformation
*/
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
const dto = await this.apiClient.getMemberships(leagueId);
return this.leagueMembersPresenter.present(dto, currentUserId);
}
/**
* Remove a member from league
*/
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
return await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
}
}

View File

@@ -0,0 +1,448 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LeagueService } from './LeagueService';
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import type { LeagueSummaryPresenter } from '../../presenters/LeagueSummaryPresenter';
import type { LeagueStandingsPresenter } from '../../presenters/LeagueStandingsPresenter';
import type {
AllLeaguesWithCapacityDto,
LeagueStandingsDto,
LeagueStatsDto,
LeagueScheduleDto,
LeagueMembershipsDto,
CreateLeagueInputDto,
CreateLeagueOutputDto,
} from '../../dtos';
import type { LeagueSummaryViewModel, LeagueStandingsViewModel } from '../../view-models';
describe('LeagueService', () => {
let service: LeagueService;
let mockApiClient: LeaguesApiClient;
let mockLeagueSummaryPresenter: LeagueSummaryPresenter;
let mockLeagueStandingsPresenter: LeagueStandingsPresenter;
beforeEach(() => {
mockApiClient = {
getAllWithCapacity: vi.fn(),
getTotal: vi.fn(),
getStandings: vi.fn(),
getSchedule: vi.fn(),
getMemberships: vi.fn(),
create: vi.fn(),
removeMember: vi.fn(),
} as unknown as LeaguesApiClient;
mockLeagueSummaryPresenter = {
present: vi.fn(),
} as unknown as LeagueSummaryPresenter;
mockLeagueStandingsPresenter = {
present: vi.fn(),
} as unknown as LeagueStandingsPresenter;
service = new LeagueService(
mockApiClient,
mockLeagueSummaryPresenter,
mockLeagueStandingsPresenter
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(LeagueService);
});
});
describe('getAllLeagues', () => {
it('should fetch all leagues from API and transform via presenter', async () => {
// Arrange
const mockDto: AllLeaguesWithCapacityDto = {
leagues: [
{
id: 'league-1',
name: 'Championship League',
description: 'Top tier racing',
memberCount: 10,
maxMembers: 20,
isPublic: true,
ownerId: 'owner-1',
},
{
id: 'league-2',
name: 'Rookie League',
description: 'Entry level racing',
memberCount: 5,
maxMembers: 15,
isPublic: true,
ownerId: 'owner-2',
},
],
};
const mockViewModels: LeagueSummaryViewModel[] = [
{
id: 'league-1',
name: 'Championship League',
description: 'Top tier racing',
memberCount: 10,
maxMembers: 20,
isPublic: true,
ownerId: 'owner-1',
} as LeagueSummaryViewModel,
{
id: 'league-2',
name: 'Rookie League',
description: 'Entry level racing',
memberCount: 5,
maxMembers: 15,
isPublic: true,
ownerId: 'owner-2',
} as LeagueSummaryViewModel,
];
vi.mocked(mockApiClient.getAllWithCapacity).mockResolvedValue(mockDto);
vi.mocked(mockLeagueSummaryPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getAllLeagues();
// Assert
expect(mockApiClient.getAllWithCapacity).toHaveBeenCalled();
expect(mockLeagueSummaryPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModels);
});
it('should handle empty leagues list', async () => {
// Arrange
const mockDto: AllLeaguesWithCapacityDto = {
leagues: [],
};
const mockViewModels: LeagueSummaryViewModel[] = [];
vi.mocked(mockApiClient.getAllWithCapacity).mockResolvedValue(mockDto);
vi.mocked(mockLeagueSummaryPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getAllLeagues();
// Assert
expect(mockApiClient.getAllWithCapacity).toHaveBeenCalled();
expect(mockLeagueSummaryPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual([]);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch leagues');
vi.mocked(mockApiClient.getAllWithCapacity).mockRejectedValue(error);
// Act & Assert
await expect(service.getAllLeagues()).rejects.toThrow('Failed to fetch leagues');
expect(mockApiClient.getAllWithCapacity).toHaveBeenCalled();
expect(mockLeagueSummaryPresenter.present).not.toHaveBeenCalled();
});
});
describe('getLeagueStandings', () => {
it('should fetch league standings and transform via presenter', async () => {
// Arrange
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto: LeagueStandingsDto = {
standings: [
{
position: 1,
driverId: 'driver-1',
points: 100,
},
{
position: 2,
driverId: 'driver-2',
points: 85,
},
],
drivers: [],
memberships: [],
};
const mockViewModel: LeagueStandingsViewModel = {
standings: [
{
position: 1,
driverId: 'driver-1',
points: 100,
},
{
position: 2,
driverId: 'driver-2',
points: 85,
},
],
drivers: [],
memberships: [],
} as LeagueStandingsViewModel;
vi.mocked(mockApiClient.getStandings).mockResolvedValue(mockDto);
vi.mocked(mockLeagueStandingsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getLeagueStandings(leagueId, currentUserId);
// Assert
expect(mockApiClient.getStandings).toHaveBeenCalledWith(leagueId);
expect(mockLeagueStandingsPresenter.present).toHaveBeenCalledWith(
expect.objectContaining(mockDto),
currentUserId
);
expect(result).toEqual(mockViewModel);
});
it('should handle empty standings', async () => {
// Arrange
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto: LeagueStandingsDto = {
standings: [],
drivers: [],
memberships: [],
};
const mockViewModel: LeagueStandingsViewModel = {
standings: [],
drivers: [],
memberships: [],
} as LeagueStandingsViewModel;
vi.mocked(mockApiClient.getStandings).mockResolvedValue(mockDto);
vi.mocked(mockLeagueStandingsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getLeagueStandings(leagueId, currentUserId);
// Assert
expect(mockApiClient.getStandings).toHaveBeenCalledWith(leagueId);
expect(mockLeagueStandingsPresenter.present).toHaveBeenCalled();
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const leagueId = 'league-123';
const currentUserId = 'user-456';
const error = new Error('Failed to fetch standings');
vi.mocked(mockApiClient.getStandings).mockRejectedValue(error);
// Act & Assert
await expect(service.getLeagueStandings(leagueId, currentUserId)).rejects.toThrow('Failed to fetch standings');
expect(mockApiClient.getStandings).toHaveBeenCalledWith(leagueId);
expect(mockLeagueStandingsPresenter.present).not.toHaveBeenCalled();
});
});
describe('getLeagueStats', () => {
it('should fetch league statistics', async () => {
// Arrange
const mockStats: LeagueStatsDto = {
totalLeagues: 42,
};
vi.mocked(mockApiClient.getTotal).mockResolvedValue(mockStats);
// Act
const result = await service.getLeagueStats();
// Assert
expect(mockApiClient.getTotal).toHaveBeenCalled();
expect(result).toEqual(mockStats);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch stats');
vi.mocked(mockApiClient.getTotal).mockRejectedValue(error);
// Act & Assert
await expect(service.getLeagueStats()).rejects.toThrow('Failed to fetch stats');
expect(mockApiClient.getTotal).toHaveBeenCalled();
});
});
describe('getLeagueSchedule', () => {
it('should fetch league schedule', async () => {
// Arrange
const leagueId = 'league-123';
const mockSchedule: LeagueScheduleDto = {
races: [
{
id: 'race-1',
name: 'Race 1',
date: '2024-01-01',
},
{
id: 'race-2',
name: 'Race 2',
date: '2024-01-08',
},
],
};
vi.mocked(mockApiClient.getSchedule).mockResolvedValue(mockSchedule);
// Act
const result = await service.getLeagueSchedule(leagueId);
// Assert
expect(mockApiClient.getSchedule).toHaveBeenCalledWith(leagueId);
expect(result).toEqual(mockSchedule);
});
it('should propagate errors from API client', async () => {
// Arrange
const leagueId = 'league-123';
const error = new Error('Failed to fetch schedule');
vi.mocked(mockApiClient.getSchedule).mockRejectedValue(error);
// Act & Assert
await expect(service.getLeagueSchedule(leagueId)).rejects.toThrow('Failed to fetch schedule');
expect(mockApiClient.getSchedule).toHaveBeenCalledWith(leagueId);
});
});
describe('getLeagueMemberships', () => {
it('should fetch league memberships', async () => {
// Arrange
const leagueId = 'league-123';
const mockMemberships: LeagueMembershipsDto = {
memberships: [
{
driverId: 'driver-1',
role: 'admin',
joinedAt: '2024-01-01',
},
{
driverId: 'driver-2',
role: 'member',
joinedAt: '2024-01-02',
},
],
};
vi.mocked(mockApiClient.getMemberships).mockResolvedValue(mockMemberships);
// Act
const result = await service.getLeagueMemberships(leagueId);
// Assert
expect(mockApiClient.getMemberships).toHaveBeenCalledWith(leagueId);
expect(result).toEqual(mockMemberships);
});
it('should propagate errors from API client', async () => {
// Arrange
const leagueId = 'league-123';
const error = new Error('Failed to fetch memberships');
vi.mocked(mockApiClient.getMemberships).mockRejectedValue(error);
// Act & Assert
await expect(service.getLeagueMemberships(leagueId)).rejects.toThrow('Failed to fetch memberships');
expect(mockApiClient.getMemberships).toHaveBeenCalledWith(leagueId);
});
});
describe('createLeague', () => {
it('should create a new league', async () => {
// Arrange
const input: CreateLeagueInputDto = {
name: 'New League',
description: 'A brand new league',
maxMembers: 25,
isPublic: true,
};
const mockOutput: CreateLeagueOutputDto = {
id: 'league-new',
name: 'New League',
success: true,
};
vi.mocked(mockApiClient.create).mockResolvedValue(mockOutput);
// Act
const result = await service.createLeague(input);
// Assert
expect(mockApiClient.create).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: CreateLeagueInputDto = {
name: 'New League',
description: 'A brand new league',
maxMembers: 25,
isPublic: true,
};
const error = new Error('Failed to create league');
vi.mocked(mockApiClient.create).mockRejectedValue(error);
// Act & Assert
await expect(service.createLeague(input)).rejects.toThrow('Failed to create league');
expect(mockApiClient.create).toHaveBeenCalledWith(input);
});
});
describe('removeMember', () => {
it('should remove a member from league', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'driver-admin';
const targetDriverId = 'driver-remove';
const mockOutput = { success: true };
vi.mocked(mockApiClient.removeMember).mockResolvedValue(mockOutput);
// Act
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
// Assert
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
expect(result).toEqual(mockOutput);
});
it('should handle removal failure', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'driver-admin';
const targetDriverId = 'driver-remove';
const mockOutput = { success: false };
vi.mocked(mockApiClient.removeMember).mockResolvedValue(mockOutput);
// Act
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
// Assert
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
expect(result).toEqual(mockOutput);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const leagueId = 'league-123';
const performerDriverId = 'driver-admin';
const targetDriverId = 'driver-remove';
const error = new Error('Failed to remove member');
vi.mocked(mockApiClient.removeMember).mockRejectedValue(error);
// Act & Assert
await expect(service.removeMember(leagueId, performerDriverId, targetDriverId)).rejects.toThrow('Failed to remove member');
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
});
});
});

View File

@@ -1,28 +1,76 @@
import { api as api } from '../../api';
import { presentLeagueSummaries, presentLeagueStandings } from '../../presenters';
import { LeagueSummaryViewModel, LeagueStandingsViewModel } from '../../view-models';
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import type { LeagueSummaryPresenter } from '../../presenters/LeagueSummaryPresenter';
import type { LeagueStandingsPresenter } from '../../presenters/LeagueStandingsPresenter';
import type { LeagueSummaryViewModel, LeagueStandingsViewModel } from '../../view-models';
import type { CreateLeagueInputDto, CreateLeagueOutputDto, LeagueStatsDto, LeagueScheduleDto, LeagueMembershipsDto } from '../../dtos';
export async function getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
const dto = await api.leagues.getAllWithCapacity();
return presentLeagueSummaries(dto.leagues);
}
/**
* League Service
*
* Orchestrates league operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class LeagueService {
constructor(
private readonly apiClient: LeaguesApiClient,
private readonly leagueSummaryPresenter: LeagueSummaryPresenter,
private readonly leagueStandingsPresenter: LeagueStandingsPresenter
) {}
export async function getLeagueStandings(leagueId: string, currentUserId?: string): Promise<LeagueStandingsViewModel> {
const dto = await api.leagues.getStandings(leagueId);
// TODO: include drivers and memberships in dto
const dtoWithExtras = {
...dto,
drivers: [], // TODO: fetch drivers
memberships: [], // TODO: fetch memberships
};
return presentLeagueStandings(dtoWithExtras, currentUserId || '');
}
/**
* Get all leagues with presentation transformation
*/
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
const dto = await this.apiClient.getAllWithCapacity();
return this.leagueSummaryPresenter.present(dto);
}
export async function createLeague(input: any): Promise<any> {
return await api.leagues.create(input);
}
/**
* Get league standings with presentation transformation
*/
async getLeagueStandings(leagueId: string, currentUserId: string): Promise<LeagueStandingsViewModel> {
const dto = await this.apiClient.getStandings(leagueId);
// TODO: include drivers and memberships in dto
const dtoWithExtras = {
...dto,
drivers: [], // TODO: fetch drivers
memberships: [], // TODO: fetch memberships
};
return this.leagueStandingsPresenter.present(dtoWithExtras, currentUserId);
}
export async function getLeagueAdminView(leagueId: string): Promise<any> {
// TODO: implement
return {};
/**
* Get league statistics
*/
async getLeagueStats(): Promise<LeagueStatsDto> {
return await this.apiClient.getTotal();
}
/**
* Get league schedule
*/
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDto> {
return await this.apiClient.getSchedule(leagueId);
}
/**
* Get league memberships
*/
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDto> {
return await this.apiClient.getMemberships(leagueId);
}
/**
* Create a new league
*/
async createLeague(input: CreateLeagueInputDto): Promise<CreateLeagueOutputDto> {
return await this.apiClient.create(input);
}
/**
* Remove a member from league
*/
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
return await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
}
}

View File

@@ -0,0 +1,259 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AvatarService } from './AvatarService';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { AvatarPresenter } from '../../presenters/AvatarPresenter';
import type {
RequestAvatarGenerationInputDto,
RequestAvatarGenerationOutputDto,
GetAvatarOutputDto,
UpdateAvatarInputDto,
UpdateAvatarOutputDto
} from '../../dtos';
import type {
RequestAvatarGenerationViewModel,
AvatarViewModel,
UpdateAvatarViewModel
} from '../../view-models';
describe('AvatarService', () => {
let service: AvatarService;
let mockApiClient: MediaApiClient;
let mockPresenter: AvatarPresenter;
beforeEach(() => {
mockApiClient = {
requestAvatarGeneration: vi.fn(),
getAvatar: vi.fn(),
updateAvatar: vi.fn(),
} as unknown as MediaApiClient;
mockPresenter = {
presentRequestGeneration: vi.fn(),
presentAvatar: vi.fn(),
presentUpdate: vi.fn(),
} as unknown as AvatarPresenter;
service = new AvatarService(mockApiClient, mockPresenter);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(AvatarService);
});
});
describe('requestAvatarGeneration', () => {
it('should request avatar generation and transform via presenter', async () => {
// Arrange
const input: RequestAvatarGenerationInputDto = {
driverId: 'driver-123',
style: 'realistic',
};
const mockDto: RequestAvatarGenerationOutputDto = {
success: true,
avatarUrl: 'https://example.com/avatar/generated.jpg',
};
const mockViewModel: RequestAvatarGenerationViewModel = {
success: true,
avatarUrl: 'https://example.com/avatar/generated.jpg',
};
vi.mocked(mockApiClient.requestAvatarGeneration).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentRequestGeneration).mockReturnValue(mockViewModel);
// Act
const result = await service.requestAvatarGeneration(input);
// Assert
expect(mockApiClient.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockPresenter.presentRequestGeneration).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle generation failure', async () => {
// Arrange
const input: RequestAvatarGenerationInputDto = {
driverId: 'driver-123',
};
const mockDto: RequestAvatarGenerationOutputDto = {
success: false,
error: 'Generation failed',
};
const mockViewModel: RequestAvatarGenerationViewModel = {
success: false,
error: 'Generation failed',
};
vi.mocked(mockApiClient.requestAvatarGeneration).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentRequestGeneration).mockReturnValue(mockViewModel);
// Act
const result = await service.requestAvatarGeneration(input);
// Assert
expect(mockApiClient.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockPresenter.presentRequestGeneration).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: RequestAvatarGenerationInputDto = {
driverId: 'driver-123',
};
const error = new Error('Network error');
vi.mocked(mockApiClient.requestAvatarGeneration).mockRejectedValue(error);
// Act & Assert
await expect(service.requestAvatarGeneration(input)).rejects.toThrow('Network error');
expect(mockApiClient.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockPresenter.presentRequestGeneration).not.toHaveBeenCalled();
});
});
describe('getAvatar', () => {
it('should fetch avatar and transform via presenter', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetAvatarOutputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/avatar.jpg',
hasAvatar: true,
};
const mockViewModel: AvatarViewModel = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/avatar.jpg',
hasAvatar: true,
};
vi.mocked(mockApiClient.getAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentAvatar).mockReturnValue(mockViewModel);
// Act
const result = await service.getAvatar(driverId);
// Assert
expect(mockApiClient.getAvatar).toHaveBeenCalledWith(driverId);
expect(mockPresenter.presentAvatar).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle driver without avatar', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetAvatarOutputDto = {
driverId: 'driver-123',
hasAvatar: false,
};
const mockViewModel: AvatarViewModel = {
driverId: 'driver-123',
hasAvatar: false,
};
vi.mocked(mockApiClient.getAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentAvatar).mockReturnValue(mockViewModel);
// Act
const result = await service.getAvatar(driverId);
// Assert
expect(mockApiClient.getAvatar).toHaveBeenCalledWith(driverId);
expect(mockPresenter.presentAvatar).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.hasAvatar).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const driverId = 'driver-123';
const error = new Error('Avatar not found');
vi.mocked(mockApiClient.getAvatar).mockRejectedValue(error);
// Act & Assert
await expect(service.getAvatar(driverId)).rejects.toThrow('Avatar not found');
expect(mockApiClient.getAvatar).toHaveBeenCalledWith(driverId);
expect(mockPresenter.presentAvatar).not.toHaveBeenCalled();
});
});
describe('updateAvatar', () => {
it('should update avatar and transform via presenter', async () => {
// Arrange
const input: UpdateAvatarInputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/new-avatar.jpg',
};
const mockDto: UpdateAvatarOutputDto = {
success: true,
};
const mockViewModel: UpdateAvatarViewModel = {
success: true,
};
vi.mocked(mockApiClient.updateAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpdate).mockReturnValue(mockViewModel);
// Act
const result = await service.updateAvatar(input);
// Assert
expect(mockApiClient.updateAvatar).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpdate).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle update failure', async () => {
// Arrange
const input: UpdateAvatarInputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/new-avatar.jpg',
};
const mockDto: UpdateAvatarOutputDto = {
success: false,
error: 'Update failed',
};
const mockViewModel: UpdateAvatarViewModel = {
success: false,
error: 'Update failed',
};
vi.mocked(mockApiClient.updateAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpdate).mockReturnValue(mockViewModel);
// Act
const result = await service.updateAvatar(input);
// Assert
expect(mockApiClient.updateAvatar).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpdate).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: UpdateAvatarInputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/new-avatar.jpg',
};
const error = new Error('Update failed');
vi.mocked(mockApiClient.updateAvatar).mockRejectedValue(error);
// Act & Assert
await expect(service.updateAvatar(input)).rejects.toThrow('Update failed');
expect(mockApiClient.updateAvatar).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpdate).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,60 @@
import { api as api } from '../../api';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { AvatarPresenter } from '../../presenters/AvatarPresenter';
import type {
RequestAvatarGenerationInputDto,
UpdateAvatarInputDto
} from '../../dtos';
import type {
RequestAvatarGenerationViewModel,
AvatarViewModel,
UpdateAvatarViewModel
} from '../../view-models';
export async function requestAvatarGeneration(input: any): Promise<any> {
return await api.media.requestAvatarGeneration(input);
/**
* Avatar Service
*
* Orchestrates avatar operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class AvatarService {
constructor(
private readonly apiClient: MediaApiClient,
private readonly presenter: AvatarPresenter
) {}
/**
* Request avatar generation with presentation transformation
*/
async requestAvatarGeneration(input: RequestAvatarGenerationInputDto): Promise<RequestAvatarGenerationViewModel> {
try {
const dto = await this.apiClient.requestAvatarGeneration(input);
return this.presenter.presentRequestGeneration(dto);
} catch (error) {
throw error;
}
}
/**
* Get avatar for driver with presentation transformation
*/
async getAvatar(driverId: string): Promise<AvatarViewModel> {
try {
const dto = await this.apiClient.getAvatar(driverId);
return this.presenter.presentAvatar(dto);
} catch (error) {
throw error;
}
}
/**
* Update avatar for driver with presentation transformation
*/
async updateAvatar(input: UpdateAvatarInputDto): Promise<UpdateAvatarViewModel> {
try {
const dto = await this.apiClient.updateAvatar(input);
return this.presenter.presentUpdate(dto);
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MediaService } from './MediaService';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { MediaPresenter } from '../../presenters/MediaPresenter';
import type {
UploadMediaInputDto,
UploadMediaOutputDto,
GetMediaOutputDto,
DeleteMediaOutputDto
} from '../../dtos';
import type {
UploadMediaViewModel,
MediaViewModel,
DeleteMediaViewModel
} from '../../view-models';
describe('MediaService', () => {
let service: MediaService;
let mockApiClient: MediaApiClient;
let mockPresenter: MediaPresenter;
beforeEach(() => {
mockApiClient = {
uploadMedia: vi.fn(),
getMedia: vi.fn(),
deleteMedia: vi.fn(),
} as unknown as MediaApiClient;
mockPresenter = {
presentUpload: vi.fn(),
presentMedia: vi.fn(),
presentDelete: vi.fn(),
} as unknown as MediaPresenter;
service = new MediaService(mockApiClient, mockPresenter);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(MediaService);
});
});
describe('uploadMedia', () => {
it('should upload media and transform via presenter', async () => {
// Arrange
const input: UploadMediaInputDto = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
category: 'avatar',
};
const mockDto: UploadMediaOutputDto = {
success: true,
mediaId: 'media-123',
url: 'https://example.com/media/test.jpg',
};
const mockViewModel: UploadMediaViewModel = {
success: true,
mediaId: 'media-123',
url: 'https://example.com/media/test.jpg',
};
vi.mocked(mockApiClient.uploadMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpload).mockReturnValue(mockViewModel);
// Act
const result = await service.uploadMedia(input);
// Assert
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpload).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle upload failure', async () => {
// Arrange
const input: UploadMediaInputDto = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
};
const mockDto: UploadMediaOutputDto = {
success: false,
error: 'Upload failed',
};
const mockViewModel: UploadMediaViewModel = {
success: false,
error: 'Upload failed',
};
vi.mocked(mockApiClient.uploadMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpload).mockReturnValue(mockViewModel);
// Act
const result = await service.uploadMedia(input);
// Assert
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpload).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: UploadMediaInputDto = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
};
const error = new Error('Network error');
vi.mocked(mockApiClient.uploadMedia).mockRejectedValue(error);
// Act & Assert
await expect(service.uploadMedia(input)).rejects.toThrow('Network error');
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpload).not.toHaveBeenCalled();
});
});
describe('getMedia', () => {
it('should fetch media and transform via presenter', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: GetMediaOutputDto = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
category: 'avatar',
uploadedAt: '2023-01-01T00:00:00Z',
size: 1024,
};
const mockViewModel: MediaViewModel = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
category: 'avatar',
uploadedAt: new Date('2023-01-01T00:00:00Z'),
size: 1024,
};
vi.mocked(mockApiClient.getMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentMedia).mockReturnValue(mockViewModel);
// Act
const result = await service.getMedia(mediaId);
// Assert
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentMedia).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle media without optional fields', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: GetMediaOutputDto = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
uploadedAt: '2023-01-01T00:00:00Z',
};
const mockViewModel: MediaViewModel = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
uploadedAt: new Date('2023-01-01T00:00:00Z'),
};
vi.mocked(mockApiClient.getMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentMedia).mockReturnValue(mockViewModel);
// Act
const result = await service.getMedia(mediaId);
// Assert
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentMedia).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const mediaId = 'media-123';
const error = new Error('Media not found');
vi.mocked(mockApiClient.getMedia).mockRejectedValue(error);
// Act & Assert
await expect(service.getMedia(mediaId)).rejects.toThrow('Media not found');
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentMedia).not.toHaveBeenCalled();
});
});
describe('deleteMedia', () => {
it('should delete media and transform via presenter', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: DeleteMediaOutputDto = {
success: true,
};
const mockViewModel: DeleteMediaViewModel = {
success: true,
};
vi.mocked(mockApiClient.deleteMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentDelete).mockReturnValue(mockViewModel);
// Act
const result = await service.deleteMedia(mediaId);
// Assert
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentDelete).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle delete failure', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: DeleteMediaOutputDto = {
success: false,
error: 'Delete failed',
};
const mockViewModel: DeleteMediaViewModel = {
success: false,
error: 'Delete failed',
};
vi.mocked(mockApiClient.deleteMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentDelete).mockReturnValue(mockViewModel);
// Act
const result = await service.deleteMedia(mediaId);
// Assert
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentDelete).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const mediaId = 'media-123';
const error = new Error('Delete failed');
vi.mocked(mockApiClient.deleteMedia).mockRejectedValue(error);
// Act & Assert
await expect(service.deleteMedia(mediaId)).rejects.toThrow('Delete failed');
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentDelete).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,6 +1,53 @@
import { api as api } from '../../api';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { MediaPresenter } from '../../presenters/MediaPresenter';
import type { UploadMediaInputDto, GetMediaOutputDto, DeleteMediaOutputDto } from '../../dtos';
import type { MediaViewModel, UploadMediaViewModel, DeleteMediaViewModel } from '../../view-models';
export async function uploadMedia(file: any): Promise<any> {
// TODO: implement
return {};
/**
* Media Service
*
* Orchestrates media operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class MediaService {
constructor(
private readonly apiClient: MediaApiClient,
private readonly presenter: MediaPresenter
) {}
/**
* Upload media file with presentation transformation
*/
async uploadMedia(input: UploadMediaInputDto): Promise<UploadMediaViewModel> {
try {
const dto = await this.apiClient.uploadMedia(input);
return this.presenter.presentUpload(dto);
} catch (error) {
throw error;
}
}
/**
* Get media by ID with presentation transformation
*/
async getMedia(mediaId: string): Promise<MediaViewModel> {
try {
const dto = await this.apiClient.getMedia(mediaId);
return this.presenter.presentMedia(dto);
} catch (error) {
throw error;
}
}
/**
* Delete media by ID with presentation transformation
*/
async deleteMedia(mediaId: string): Promise<DeleteMediaViewModel> {
try {
const dto = await this.apiClient.deleteMedia(mediaId);
return this.presenter.presentDelete(dto);
} catch (error) {
throw error;
}
}
}

View File

@@ -1,8 +1,27 @@
import { api as api } from '../../api';
import { presentMembershipFee } from '../../presenters';
import { MembershipFeeViewModel } from '../../view-models';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { presentMembershipFee } from '../../presenters/MembershipFeePresenter';
import type { MembershipFeeViewModel } from '../../view-models';
export async function getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
const dto = await api.payments.getMembershipFees(leagueId);
return dto.fees.map(f => presentMembershipFee(f));
/**
* Membership Fee Service
*
* Orchestrates membership fee operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class MembershipFeeService {
constructor(
private readonly apiClient: PaymentsApiClient
) {}
/**
* Get membership fees by league ID with presentation transformation
*/
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
try {
const dto = await this.apiClient.getMembershipFees(leagueId);
return dto.fees.map(presentMembershipFee);
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,506 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PaymentService } from './PaymentService';
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentListPresenter } from '../../presenters/PaymentListPresenter';
import type {
GetPaymentsOutputDto,
CreatePaymentInputDto,
CreatePaymentOutputDto,
GetMembershipFeesOutputDto,
GetPrizesOutputDto,
GetWalletOutputDto,
PaymentDto,
MembershipFeeDto,
PrizeDto,
WalletDto,
} from '../../dtos';
import type {
PaymentViewModel,
MembershipFeeViewModel,
PrizeViewModel,
WalletViewModel,
} from '../../view-models';
describe('PaymentService', () => {
let service: PaymentService;
let mockApiClient: PaymentsApiClient;
let mockPaymentListPresenter: PaymentListPresenter;
let mockPresentPayment: (dto: any) => PaymentViewModel;
let mockPresentMembershipFee: (dto: any) => MembershipFeeViewModel;
let mockPresentPrize: (dto: any) => PrizeViewModel;
let mockPresentWallet: (dto: any) => WalletViewModel;
beforeEach(() => {
mockApiClient = {
getPayments: vi.fn(),
createPayment: vi.fn(),
getMembershipFees: vi.fn(),
getPrizes: vi.fn(),
getWallet: vi.fn(),
} as unknown as PaymentsApiClient;
mockPaymentListPresenter = {
present: vi.fn(),
} as unknown as PaymentListPresenter;
mockPresentPayment = vi.fn();
mockPresentMembershipFee = vi.fn();
mockPresentPrize = vi.fn();
mockPresentWallet = vi.fn();
service = new PaymentService(
mockApiClient,
mockPaymentListPresenter,
mockPresentPayment,
mockPresentMembershipFee,
mockPresentPrize,
mockPresentWallet
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(PaymentService);
});
});
describe('getPayments', () => {
it('should fetch all payments and transform via presenter', async () => {
// Arrange
const mockDto: GetPaymentsOutputDto = {
payments: [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
},
{
id: 'payment-2',
amount: 200,
currency: 'EUR',
status: 'pending',
createdAt: '2024-01-02',
},
],
};
const mockViewModels: PaymentViewModel[] = [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
} as PaymentViewModel,
{
id: 'payment-2',
amount: 200,
currency: 'EUR',
status: 'pending',
createdAt: '2024-01-02',
} as PaymentViewModel,
];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getPayments();
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(undefined, undefined);
expect(mockPaymentListPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModels);
});
it('should filter payments by leagueId', async () => {
// Arrange
const leagueId = 'league-123';
const mockDto: GetPaymentsOutputDto = { payments: [] };
const mockViewModels: PaymentViewModel[] = [];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
await service.getPayments(leagueId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(leagueId, undefined);
});
it('should filter payments by driverId', async () => {
// Arrange
const driverId = 'driver-456';
const mockDto: GetPaymentsOutputDto = { payments: [] };
const mockViewModels: PaymentViewModel[] = [];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
await service.getPayments(undefined, driverId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(undefined, driverId);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch payments');
vi.mocked(mockApiClient.getPayments).mockRejectedValue(error);
// Act & Assert
await expect(service.getPayments()).rejects.toThrow('Failed to fetch payments');
expect(mockApiClient.getPayments).toHaveBeenCalled();
expect(mockPaymentListPresenter.present).not.toHaveBeenCalled();
});
});
describe('getPayment', () => {
it('should fetch single payment by ID', async () => {
// Arrange
const paymentId = 'payment-123';
const mockDto: GetPaymentsOutputDto = {
payments: [
{
id: paymentId,
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
},
],
};
const mockViewModel: PaymentViewModel = {
id: paymentId,
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
} as PaymentViewModel;
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPresentPayment).mockReturnValue(mockViewModel);
// Act
const result = await service.getPayment(paymentId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalled();
expect(mockPresentPayment).toHaveBeenCalledWith(mockDto.payments[0]);
expect(result).toEqual(mockViewModel);
});
it('should throw error when payment not found', async () => {
// Arrange
const paymentId = 'non-existent';
const mockDto: GetPaymentsOutputDto = { payments: [] };
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
// Act & Assert
await expect(service.getPayment(paymentId)).rejects.toThrow(
`Payment with ID ${paymentId} not found`
);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('API error');
vi.mocked(mockApiClient.getPayments).mockRejectedValue(error);
// Act & Assert
await expect(service.getPayment('payment-123')).rejects.toThrow('API error');
});
});
describe('createPayment', () => {
it('should create a new payment', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 150,
currency: 'USD',
leagueId: 'league-123',
driverId: 'driver-456',
};
const mockOutput: CreatePaymentOutputDto = {
paymentId: 'payment-new',
success: true,
};
vi.mocked(mockApiClient.createPayment).mockResolvedValue(mockOutput);
// Act
const result = await service.createPayment(input);
// Assert
expect(mockApiClient.createPayment).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 150,
currency: 'USD',
};
const error = new Error('Payment creation failed');
vi.mocked(mockApiClient.createPayment).mockRejectedValue(error);
// Act & Assert
await expect(service.createPayment(input)).rejects.toThrow('Payment creation failed');
});
});
describe('getMembershipFees', () => {
it('should fetch membership fees for a league', async () => {
// Arrange
const leagueId = 'league-123';
const mockDto: GetMembershipFeesOutputDto = {
fees: [
{
leagueId,
amount: 50,
currency: 'USD',
period: 'monthly',
},
],
memberPayments: [],
};
const mockViewModel: MembershipFeeViewModel = {
leagueId,
amount: 50,
currency: 'USD',
period: 'monthly',
} as MembershipFeeViewModel;
vi.mocked(mockApiClient.getMembershipFees).mockResolvedValue(mockDto);
vi.mocked(mockPresentMembershipFee).mockReturnValue(mockViewModel);
// Act
const result = await service.getMembershipFees(leagueId);
// Assert
expect(mockApiClient.getMembershipFees).toHaveBeenCalledWith(leagueId);
expect(mockPresentMembershipFee).toHaveBeenCalledWith(mockDto.fees[0]);
expect(result).toEqual([mockViewModel]);
});
it('should handle empty fees list', async () => {
// Arrange
const leagueId = 'league-123';
const mockDto: GetMembershipFeesOutputDto = {
fees: [],
memberPayments: [],
};
vi.mocked(mockApiClient.getMembershipFees).mockResolvedValue(mockDto);
// Act
const result = await service.getMembershipFees(leagueId);
// Assert
expect(result).toEqual([]);
expect(mockPresentMembershipFee).not.toHaveBeenCalled();
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch fees');
vi.mocked(mockApiClient.getMembershipFees).mockRejectedValue(error);
// Act & Assert
await expect(service.getMembershipFees('league-123')).rejects.toThrow('Failed to fetch fees');
});
});
describe('getPrizes', () => {
it('should fetch all prizes', async () => {
// Arrange
const mockDto: GetPrizesOutputDto = {
prizes: [
{
id: 'prize-1',
name: 'First Place',
amount: 1000,
currency: 'USD',
position: 1,
},
],
};
const mockViewModel: PrizeViewModel = {
id: 'prize-1',
name: 'First Place',
amount: 1000,
currency: 'USD',
position: 1,
} as PrizeViewModel;
vi.mocked(mockApiClient.getPrizes).mockResolvedValue(mockDto);
vi.mocked(mockPresentPrize).mockReturnValue(mockViewModel);
// Act
const result = await service.getPrizes();
// Assert
expect(mockApiClient.getPrizes).toHaveBeenCalledWith(undefined, undefined);
expect(mockPresentPrize).toHaveBeenCalledWith(mockDto.prizes[0]);
expect(result).toEqual([mockViewModel]);
});
it('should filter prizes by leagueId and seasonId', async () => {
// Arrange
const leagueId = 'league-123';
const seasonId = 'season-456';
const mockDto: GetPrizesOutputDto = { prizes: [] };
vi.mocked(mockApiClient.getPrizes).mockResolvedValue(mockDto);
// Act
await service.getPrizes(leagueId, seasonId);
// Assert
expect(mockApiClient.getPrizes).toHaveBeenCalledWith(leagueId, seasonId);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch prizes');
vi.mocked(mockApiClient.getPrizes).mockRejectedValue(error);
// Act & Assert
await expect(service.getPrizes()).rejects.toThrow('Failed to fetch prizes');
});
});
describe('getWallet', () => {
it('should fetch wallet for a driver', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetWalletOutputDto = {
driverId,
balance: 500,
currency: 'USD',
transactions: [],
};
const mockViewModel: WalletViewModel = {
driverId,
balance: 500,
currency: 'USD',
transactions: [],
} as WalletViewModel;
vi.mocked(mockApiClient.getWallet).mockResolvedValue(mockDto);
vi.mocked(mockPresentWallet).mockReturnValue(mockViewModel);
// Act
const result = await service.getWallet(driverId);
// Assert
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(mockPresentWallet).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch wallet');
vi.mocked(mockApiClient.getWallet).mockRejectedValue(error);
// Act & Assert
await expect(service.getWallet('driver-123')).rejects.toThrow('Failed to fetch wallet');
});
});
describe('processPayment', () => {
it('should process payment using createPayment', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 100,
currency: 'USD',
};
const mockOutput: CreatePaymentOutputDto = {
paymentId: 'payment-123',
success: true,
};
vi.mocked(mockApiClient.createPayment).mockResolvedValue(mockOutput);
// Act
const result = await service.processPayment(input);
// Assert
expect(mockApiClient.createPayment).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors', async () => {
// Arrange
const input: CreatePaymentInputDto = {
amount: 100,
currency: 'USD',
};
const error = new Error('Processing failed');
vi.mocked(mockApiClient.createPayment).mockRejectedValue(error);
// Act & Assert
await expect(service.processPayment(input)).rejects.toThrow('Processing failed');
});
});
describe('getPaymentHistory', () => {
it('should fetch payment history for a driver', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetPaymentsOutputDto = {
payments: [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
},
],
};
const mockViewModels: PaymentViewModel[] = [
{
id: 'payment-1',
amount: 100,
currency: 'USD',
status: 'completed',
createdAt: '2024-01-01',
} as PaymentViewModel,
];
vi.mocked(mockApiClient.getPayments).mockResolvedValue(mockDto);
vi.mocked(mockPaymentListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getPaymentHistory(driverId);
// Assert
expect(mockApiClient.getPayments).toHaveBeenCalledWith(undefined, driverId);
expect(result).toEqual(mockViewModels);
});
it('should propagate errors', async () => {
// Arrange
const error = new Error('History fetch failed');
vi.mocked(mockApiClient.getPayments).mockRejectedValue(error);
// Act & Assert
await expect(service.getPaymentHistory('driver-123')).rejects.toThrow('History fetch failed');
});
});
});

View File

@@ -1,12 +1,127 @@
import { api as api } from '../../api';
import { presentPayment } from '../../presenters';
import { PaymentViewModel } from '../../view-models';
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentListPresenter } from '../../presenters/PaymentListPresenter';
import type {
CreatePaymentInputDto,
CreatePaymentOutputDto,
} from '../../dtos';
import type {
PaymentViewModel,
MembershipFeeViewModel,
PrizeViewModel,
WalletViewModel,
} from '../../view-models';
export async function getPayments(leagueId?: string, driverId?: string): Promise<PaymentViewModel[]> {
const dto = await api.payments.getPayments(leagueId, driverId);
return dto.payments.map(p => presentPayment(p));
}
/**
* Payment Service
*
* Orchestrates payment operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class PaymentService {
constructor(
private readonly apiClient: PaymentsApiClient,
private readonly paymentListPresenter: PaymentListPresenter,
private readonly presentPayment: (dto: any) => PaymentViewModel,
private readonly presentMembershipFee: (dto: any) => MembershipFeeViewModel,
private readonly presentPrize: (dto: any) => PrizeViewModel,
private readonly presentWallet: (dto: any) => WalletViewModel
) {}
export async function createPayment(input: any): Promise<any> {
return await api.payments.createPayment(input);
/**
* Get all payments with optional filters
*/
async getPayments(leagueId?: string, driverId?: string): Promise<PaymentViewModel[]> {
try {
const dto = await this.apiClient.getPayments(leagueId, driverId);
return this.paymentListPresenter.present(dto);
} catch (error) {
throw error;
}
}
/**
* Get single payment by ID
*/
async getPayment(paymentId: string): Promise<PaymentViewModel> {
try {
// Note: Assuming the API returns a single payment from the list
const dto = await this.apiClient.getPayments();
const payment = dto.payments.find(p => p.id === paymentId);
if (!payment) {
throw new Error(`Payment with ID ${paymentId} not found`);
}
return this.presentPayment(payment);
} catch (error) {
throw error;
}
}
/**
* Create a new payment
*/
async createPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
try {
return await this.apiClient.createPayment(input);
} catch (error) {
throw error;
}
}
/**
* Get membership fees for a league
*/
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
try {
const dto = await this.apiClient.getMembershipFees(leagueId);
return dto.fees.map(fee => this.presentMembershipFee(fee));
} catch (error) {
throw error;
}
}
/**
* Get prizes with optional filters
*/
async getPrizes(leagueId?: string, seasonId?: string): Promise<PrizeViewModel[]> {
try {
const dto = await this.apiClient.getPrizes(leagueId, seasonId);
return dto.prizes.map(prize => this.presentPrize(prize));
} catch (error) {
throw error;
}
}
/**
* Get wallet for a driver
*/
async getWallet(driverId: string): Promise<WalletViewModel> {
try {
const dto = await this.apiClient.getWallet(driverId);
return this.presentWallet(dto);
} catch (error) {
throw error;
}
}
/**
* Process a payment (alias for createPayment)
*/
async processPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
try {
return await this.createPayment(input);
} catch (error) {
throw error;
}
}
/**
* Get payment history for a user (driver)
*/
async getPaymentHistory(driverId: string): Promise<PaymentViewModel[]> {
try {
return await this.getPayments(undefined, driverId);
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WalletService } from './WalletService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { GetWalletOutputDto } from '../../dtos';
import { presentWallet } from '../../presenters/WalletPresenter';
// Mock the presenter
vi.mock('../../presenters/WalletPresenter', () => ({
presentWallet: vi.fn(),
}));
describe('WalletService', () => {
let mockApiClient: PaymentsApiClient;
let service: WalletService;
beforeEach(() => {
mockApiClient = {
getWallet: vi.fn(),
getMembershipFees: vi.fn(),
getPayments: vi.fn(),
createPayment: vi.fn(),
getPrizes: vi.fn(),
} as unknown as PaymentsApiClient;
service = new WalletService(mockApiClient);
});
describe('getWallet', () => {
it('should get wallet via API client and present it', async () => {
// Arrange
const driverId = 'driver-1';
const dto: GetWalletOutputDto = {
balance: 1000,
currency: 'USD',
transactions: [],
};
const expectedViewModel = {
balance: 1000,
currency: 'USD',
transactions: [],
};
vi.mocked(mockApiClient.getWallet).mockResolvedValue(dto);
vi.mocked(presentWallet).mockReturnValue(expectedViewModel);
// Act
const result = await service.getWallet(driverId);
// Assert
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(mockApiClient.getWallet).toHaveBeenCalledTimes(1);
expect(presentWallet).toHaveBeenCalledWith(dto);
expect(result).toBe(expectedViewModel);
});
it('should propagate API client errors', async () => {
// Arrange
const driverId = 'driver-1';
const error = new Error('API Error: Failed to get wallet');
vi.mocked(mockApiClient.getWallet).mockRejectedValue(error);
// Act & Assert
await expect(service.getWallet(driverId)).rejects.toThrow(
'API Error: Failed to get wallet'
);
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(mockApiClient.getWallet).toHaveBeenCalledTimes(1);
});
it('should handle different driver IDs', async () => {
// Arrange
const driverId = 'driver-2';
const dto: GetWalletOutputDto = {
balance: 500,
currency: 'EUR',
transactions: [],
};
const expectedViewModel = {
balance: 500,
currency: 'EUR',
transactions: [],
};
vi.mocked(mockApiClient.getWallet).mockResolvedValue(dto);
vi.mocked(presentWallet).mockReturnValue(expectedViewModel);
// Act
const result = await service.getWallet(driverId);
// Assert
expect(mockApiClient.getWallet).toHaveBeenCalledWith(driverId);
expect(result).toBe(expectedViewModel);
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient', () => {
// This test verifies the constructor signature
expect(() => {
new WalletService(mockApiClient);
}).not.toThrow();
});
it('should use injected apiClient', async () => {
// Arrange
const customApiClient = {
getWallet: vi.fn().mockResolvedValue({ balance: 200, currency: 'USD', transactions: [] }),
getMembershipFees: vi.fn(),
getPayments: vi.fn(),
createPayment: vi.fn(),
getPrizes: vi.fn(),
} as unknown as PaymentsApiClient;
const customService = new WalletService(customApiClient);
vi.mocked(presentWallet).mockReturnValue({ balance: 200, currency: 'USD', transactions: [] });
// Act
await customService.getWallet('driver-1');
// Assert
expect(customApiClient.getWallet).toHaveBeenCalledWith('driver-1');
});
});
});

View File

@@ -1,8 +1,27 @@
import { api as api } from '../../api';
import { presentWallet } from '../../presenters';
import { WalletViewModel } from '../../view-models';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { presentWallet } from '../../presenters/WalletPresenter';
import type { WalletViewModel } from '../../view-models';
export async function getWallet(driverId: string): Promise<WalletViewModel> {
const dto = await api.payments.getWallet(driverId);
return presentWallet(dto);
/**
* Wallet Service
*
* Orchestrates wallet operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class WalletService {
constructor(
private readonly apiClient: PaymentsApiClient
) {}
/**
* Get wallet by driver ID with presentation transformation
*/
async getWallet(driverId: string): Promise<WalletViewModel> {
try {
const dto = await this.apiClient.getWallet(driverId);
return presentWallet(dto);
} catch (error) {
throw error;
}
}
}

View File

@@ -1,121 +1,256 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getRaceResults, getRaceSOF, importRaceResults } from './RaceResultsService';
import { RaceResultsService } from './RaceResultsService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailPresenter } from '../../presenters/RaceResultsDetailPresenter';
import { RaceWithSOFPresenter } from '../../presenters/RaceWithSOFPresenter';
import { ImportRaceResultsPresenter } from '../../presenters/ImportRaceResultsPresenter';
import type { RaceResultsDetailDto, RaceWithSOFDto, ImportRaceResultsSummaryDto } from '../../dtos';
import type { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import type { RaceWithSOFViewModel } from '../../presenters/RaceWithSOFPresenter';
import type { ImportRaceResultsSummaryViewModel } from '../../presenters/ImportRaceResultsPresenter';
// Mock the API client
vi.mock('../../api', () => ({
apiClient: {
races: {
describe('RaceResultsService', () => {
let service: RaceResultsService;
let mockApiClient: RacesApiClient;
let mockResultsDetailPresenter: RaceResultsDetailPresenter;
let mockSOFPresenter: RaceWithSOFPresenter;
let mockImportPresenter: ImportRaceResultsPresenter;
beforeEach(() => {
mockApiClient = {
getResultsDetail: vi.fn(),
getWithSOF: vi.fn(),
importResults: vi.fn(),
},
},
}));
} as unknown as RacesApiClient;
// Mock the presenter
vi.mock('../../presenters', () => ({
presentRaceResultsDetail: vi.fn(),
}));
mockResultsDetailPresenter = {
present: vi.fn(),
} as unknown as RaceResultsDetailPresenter;
import { api } from '../../api';
import { presentRaceResultsDetail } from '../../presenters';
mockSOFPresenter = {
present: vi.fn(),
} as unknown as RaceWithSOFPresenter;
describe('RaceResultsService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockImportPresenter = {
present: vi.fn(),
} as unknown as ImportRaceResultsPresenter;
service = new RaceResultsService(
mockApiClient,
mockResultsDetailPresenter,
mockSOFPresenter,
mockImportPresenter
);
});
describe('getRaceResults', () => {
it('should call API and presenter with correct parameters', async () => {
const mockDto: RaceResultsDetailDto = {
id: 'race-1',
name: 'Test Race',
results: [],
// ... other required fields
} as RaceResultsDetailDto;
const mockViewModel = {
id: 'race-1',
name: 'Test Race',
formattedResults: [],
};
describe('getResultsDetail', () => {
it('should fetch race results detail and transform via presenter', async () => {
// Arrange
const raceId = 'race-123';
const currentUserId = 'user-456';
// Mock API call
vi.mocked(api.races.getResultsDetail).mockResolvedValue(mockDto);
// Mock presenter
vi.mocked(presentRaceResultsDetail).mockReturnValue(mockViewModel);
const result = await getRaceResults(raceId, currentUserId);
expect(api.races.getResultsDetail).toHaveBeenCalledWith(raceId);
expect(presentRaceResultsDetail).toHaveBeenCalledWith(mockDto, currentUserId);
expect(result).toBe(mockViewModel);
});
it('should call presenter with undefined currentUserId when not provided', async () => {
const mockDto: RaceResultsDetailDto = {
id: 'race-1',
name: 'Test Race',
const mockDto: Partial<RaceResultsDetailDto> = {
raceId,
results: [],
};
const mockViewModel: Partial<RaceResultsDetailViewModel> = {
raceId,
results: [],
} as RaceResultsDetailDto;
const mockViewModel = {
id: 'race-1',
name: 'Test Race',
formattedResults: [],
};
vi.mocked(mockApiClient.getResultsDetail).mockResolvedValue(mockDto as RaceResultsDetailDto);
vi.mocked(mockResultsDetailPresenter.present).mockReturnValue(mockViewModel as RaceResultsDetailViewModel);
// Act
const result = await service.getResultsDetail(raceId, currentUserId);
// Assert
expect(mockApiClient.getResultsDetail).toHaveBeenCalledWith(raceId);
expect(mockResultsDetailPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId);
expect(result).toEqual(mockViewModel);
});
it('should fetch race results detail without currentUserId', async () => {
// Arrange
const raceId = 'race-123';
const mockDto: Partial<RaceResultsDetailDto> = {
raceId,
results: [],
};
const mockViewModel: Partial<RaceResultsDetailViewModel> = {
raceId,
results: [],
};
vi.mocked(api.races.getResultsDetail).mockResolvedValue(mockDto);
vi.mocked(presentRaceResultsDetail).mockReturnValue(mockViewModel);
vi.mocked(mockApiClient.getResultsDetail).mockResolvedValue(mockDto as RaceResultsDetailDto);
vi.mocked(mockResultsDetailPresenter.present).mockReturnValue(mockViewModel as RaceResultsDetailViewModel);
await getRaceResults(raceId);
// Act
const result = await service.getResultsDetail(raceId);
expect(presentRaceResultsDetail).toHaveBeenCalledWith(mockDto, undefined);
// Assert
expect(mockApiClient.getResultsDetail).toHaveBeenCalledWith(raceId);
expect(mockResultsDetailPresenter.present).toHaveBeenCalledWith(mockDto, undefined);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const raceId = 'race-123';
const error = new Error('API Error');
vi.mocked(mockApiClient.getResultsDetail).mockRejectedValue(error);
// Act & Assert
await expect(service.getResultsDetail(raceId)).rejects.toThrow('API Error');
expect(mockApiClient.getResultsDetail).toHaveBeenCalledWith(raceId);
expect(mockResultsDetailPresenter.present).not.toHaveBeenCalled();
});
});
describe('getRaceSOF', () => {
it('should call API and return DTO directly', async () => {
describe('getWithSOF', () => {
it('should fetch race with SOF and transform via presenter', async () => {
// Arrange
const raceId = 'race-123';
const mockDto: RaceWithSOFDto = {
id: 'race-1',
name: 'Test Race',
sof: 1500,
// ... other fields
} as RaceWithSOFDto;
id: raceId,
track: 'Spa-Francorchamps',
strengthOfField: 2500,
};
const mockViewModel: RaceWithSOFViewModel = {
id: raceId,
track: 'Spa-Francorchamps',
strengthOfField: 2500,
};
vi.mocked(mockApiClient.getWithSOF).mockResolvedValue(mockDto);
vi.mocked(mockSOFPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getWithSOF(raceId);
// Assert
expect(mockApiClient.getWithSOF).toHaveBeenCalledWith(raceId);
expect(mockSOFPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle null strengthOfField', async () => {
// Arrange
const raceId = 'race-123';
const mockDto: RaceWithSOFDto = {
id: raceId,
track: 'Spa-Francorchamps',
strengthOfField: null,
};
const mockViewModel: RaceWithSOFViewModel = {
id: raceId,
track: 'Spa-Francorchamps',
strengthOfField: null,
};
vi.mocked(api.races.getWithSOF).mockResolvedValue(mockDto);
vi.mocked(mockApiClient.getWithSOF).mockResolvedValue(mockDto);
vi.mocked(mockSOFPresenter.present).mockReturnValue(mockViewModel);
const result = await getRaceSOF(raceId);
// Act
const result = await service.getWithSOF(raceId);
expect(api.races.getWithSOF).toHaveBeenCalledWith(raceId);
expect(result).toBe(mockDto);
// Assert
expect(mockApiClient.getWithSOF).toHaveBeenCalledWith(raceId);
expect(mockSOFPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const raceId = 'race-123';
const error = new Error('SOF calculation failed');
vi.mocked(mockApiClient.getWithSOF).mockRejectedValue(error);
// Act & Assert
await expect(service.getWithSOF(raceId)).rejects.toThrow('SOF calculation failed');
expect(mockApiClient.getWithSOF).toHaveBeenCalledWith(raceId);
expect(mockSOFPresenter.present).not.toHaveBeenCalled();
});
});
describe('importRaceResults', () => {
it('should call API with correct parameters and return result', async () => {
const mockInput = { results: [] };
const mockSummary: ImportRaceResultsSummaryDto = {
totalImported: 10,
errors: [],
describe('importResults', () => {
it('should import race results and transform via presenter', async () => {
// Arrange
const raceId = 'race-123';
const input = {
sessionId: 'session-456',
results: [
{ position: 1, driverId: 'driver-1', finishTime: 120000 },
{ position: 2, driverId: 'driver-2', finishTime: 121000 },
],
};
const mockDto: ImportRaceResultsSummaryDto = {
success: true,
raceId,
driversProcessed: 2,
resultsRecorded: 2,
};
const mockViewModel: ImportRaceResultsSummaryViewModel = {
success: true,
raceId,
driversProcessed: 2,
resultsRecorded: 2,
};
vi.mocked(mockApiClient.importResults).mockResolvedValue(mockDto);
vi.mocked(mockImportPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.importResults(raceId, input);
// Assert
expect(mockApiClient.importResults).toHaveBeenCalledWith(raceId, input);
expect(mockImportPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle import with errors', async () => {
// Arrange
const raceId = 'race-123';
const input = { sessionId: 'session-456', results: [] };
const mockDto: ImportRaceResultsSummaryDto = {
success: false,
raceId,
driversProcessed: 5,
resultsRecorded: 3,
errors: ['Driver not found: driver-99', 'Invalid time for driver-88'],
};
const mockViewModel: ImportRaceResultsSummaryViewModel = {
success: false,
raceId,
driversProcessed: 5,
resultsRecorded: 3,
errors: ['Driver not found: driver-99', 'Invalid time for driver-88'],
};
vi.mocked(api.races.importResults).mockResolvedValue(mockSummary);
vi.mocked(mockApiClient.importResults).mockResolvedValue(mockDto);
vi.mocked(mockImportPresenter.present).mockReturnValue(mockViewModel);
const result = await importRaceResults(raceId, mockInput);
// Act
const result = await service.importResults(raceId, input);
expect(api.races.importResults).toHaveBeenCalledWith(raceId, mockInput);
expect(result).toBe(mockSummary);
// Assert
expect(mockApiClient.importResults).toHaveBeenCalledWith(raceId, input);
expect(mockImportPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.errors).toHaveLength(2);
});
it('should propagate errors from API client', async () => {
// Arrange
const raceId = 'race-123';
const input = { sessionId: 'session-456', results: [] };
const error = new Error('Import failed');
vi.mocked(mockApiClient.importResults).mockRejectedValue(error);
// Act & Assert
await expect(service.importResults(raceId, input)).rejects.toThrow('Import failed');
expect(mockApiClient.importResults).toHaveBeenCalledWith(raceId, input);
expect(mockImportPresenter.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,46 +1,47 @@
import { api as api } from '../../api';
import { RaceResultsDetailPresenter, RaceWithSOFPresenter, ImportRaceResultsPresenter } from '../../presenters';
import { RaceResultsDetailViewModel } from '../../view-models';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailPresenter } from '../../presenters/RaceResultsDetailPresenter';
import { RaceWithSOFPresenter } from '../../presenters/RaceWithSOFPresenter';
import type { RaceWithSOFViewModel } from '../../presenters/RaceWithSOFPresenter';
import { ImportRaceResultsPresenter } from '../../presenters/ImportRaceResultsPresenter';
import type { ImportRaceResultsSummaryViewModel } from '../../presenters/ImportRaceResultsPresenter';
import type { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import type { ImportRaceResultsInputDto } from '../../dtos';
/**
* Race Results Service
*
* Orchestrates race results operations including viewing, importing, and SOF calculations.
* All dependencies are injected via constructor.
*/
export class RaceResultsService {
constructor(
private readonly apiClient = api.races,
private readonly resultsDetailPresenter = new RaceResultsDetailPresenter(),
private readonly sofPresenter = new RaceWithSOFPresenter(),
private readonly importPresenter = new ImportRaceResultsPresenter()
private readonly apiClient: RacesApiClient,
private readonly resultsDetailPresenter: RaceResultsDetailPresenter,
private readonly sofPresenter: RaceWithSOFPresenter,
private readonly importPresenter: ImportRaceResultsPresenter
) {}
async importRaceResults(raceId: string, input: any): Promise<any> {
const dto = await this.apiClient.importResults(raceId, input);
return this.importPresenter.present(dto);
}
/**
* Get race results detail with presentation transformation
*/
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
const dto = await this.apiClient.getResultsDetail(raceId);
return this.resultsDetailPresenter.present(dto, currentUserId);
}
async getWithSOF(raceId: string): Promise<any> {
/**
* Get race with strength of field calculation
*/
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
const dto = await this.apiClient.getWithSOF(raceId);
return this.sofPresenter.present(dto);
}
}
// Singleton instance
export const raceResultsService = new RaceResultsService();
// Backward compatibility functions
export async function getRaceResults(
raceId: string,
currentUserId?: string
): Promise<RaceResultsDetailViewModel> {
return raceResultsService.getResultsDetail(raceId, currentUserId);
}
export async function getRaceSOF(raceId: string): Promise<any> {
return raceResultsService.getWithSOF(raceId);
}
export async function importRaceResults(raceId: string, input: any): Promise<any> {
return raceResultsService.importRaceResults(raceId, input);
/**
* Import race results and get summary
*/
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
const dto = await this.apiClient.importResults(raceId, input);
return this.importPresenter.present(dto);
}
}

View File

@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RaceService } from './RaceService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailPresenter } from '../../presenters/RaceDetailPresenter';
import type { RaceDetailDto, RacesPageDataDto, RaceStatsDto } from '../../dtos';
import type { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
describe('RaceService', () => {
let mockApiClient: RacesApiClient;
let mockPresenter: RaceDetailPresenter;
let service: RaceService;
beforeEach(() => {
mockApiClient = {
getDetail: vi.fn(),
getPageData: vi.fn(),
getTotal: vi.fn(),
} as unknown as RacesApiClient;
mockPresenter = {
present: vi.fn(),
} as unknown as RaceDetailPresenter;
service = new RaceService(mockApiClient, mockPresenter);
});
describe('getRaceDetail', () => {
it('should fetch race detail from API and transform via presenter', async () => {
// Arrange
const mockDto: RaceDetailDto = {
race: {
id: 'race-1',
name: 'Test Race',
scheduledTime: '2025-12-17T20:00:00Z',
status: 'upcoming',
trackName: 'Spa-Francorchamps',
carClasses: ['GT3'],
},
league: null,
entryList: [],
registration: {
isRegistered: false,
canRegister: true,
},
userResult: null,
};
const mockViewModel: RaceDetailViewModel = {
race: mockDto.race,
league: mockDto.league,
entryList: mockDto.entryList,
registration: mockDto.registration,
userResult: mockDto.userResult,
isRegistered: false,
canRegister: true,
raceStatusDisplay: 'Upcoming',
formattedScheduledTime: expect.any(String),
entryCount: 0,
hasResults: false,
registrationStatusMessage: 'You can register for this race',
} as unknown as RaceDetailViewModel;
vi.mocked(mockApiClient.getDetail).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getRaceDetail('race-1', 'driver-1');
// Assert
expect(mockApiClient.getDetail).toHaveBeenCalledWith('race-1', 'driver-1');
expect(mockApiClient.getDetail).toHaveBeenCalledTimes(1);
expect(mockPresenter.present).toHaveBeenCalledWith(mockDto);
expect(mockPresenter.present).toHaveBeenCalledTimes(1);
expect(result).toBe(mockViewModel);
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Race not found');
vi.mocked(mockApiClient.getDetail).mockRejectedValue(error);
// Act & Assert
await expect(
service.getRaceDetail('invalid-race', 'driver-1')
).rejects.toThrow('API Error: Race not found');
expect(mockApiClient.getDetail).toHaveBeenCalledWith('invalid-race', 'driver-1');
expect(mockPresenter.present).not.toHaveBeenCalled();
});
it('should propagate presenter errors', async () => {
// Arrange
const mockDto: RaceDetailDto = {
race: null,
league: null,
entryList: [],
registration: {
isRegistered: false,
canRegister: false,
},
userResult: null,
};
const error = new Error('Presenter Error: Invalid DTO structure');
vi.mocked(mockApiClient.getDetail).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.present).mockImplementation(() => {
throw error;
});
// Act & Assert
await expect(
service.getRaceDetail('race-1', 'driver-1')
).rejects.toThrow('Presenter Error: Invalid DTO structure');
expect(mockApiClient.getDetail).toHaveBeenCalledWith('race-1', 'driver-1');
expect(mockPresenter.present).toHaveBeenCalledWith(mockDto);
});
});
describe('getRacesPageData', () => {
it('should fetch races page data from API', async () => {
// Arrange
const mockPageData: RacesPageDataDto = {
races: [
{
id: 'race-1',
name: 'Test Race 1',
scheduledTime: '2025-12-17T20:00:00Z',
trackName: 'Spa-Francorchamps',
},
{
id: 'race-2',
name: 'Test Race 2',
scheduledTime: '2025-12-18T20:00:00Z',
trackName: 'Monza',
},
],
totalCount: 2,
};
vi.mocked(mockApiClient.getPageData).mockResolvedValue(mockPageData);
// Act
const result = await service.getRacesPageData();
// Assert
expect(mockApiClient.getPageData).toHaveBeenCalledTimes(1);
expect(result).toBe(mockPageData);
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Failed to fetch page data');
vi.mocked(mockApiClient.getPageData).mockRejectedValue(error);
// Act & Assert
await expect(service.getRacesPageData()).rejects.toThrow(
'API Error: Failed to fetch page data'
);
expect(mockApiClient.getPageData).toHaveBeenCalledTimes(1);
});
});
describe('getRacesTotal', () => {
it('should fetch race statistics from API', async () => {
// Arrange
const mockStats: RaceStatsDto = {
total: 42,
upcoming: 10,
live: 2,
finished: 30,
};
vi.mocked(mockApiClient.getTotal).mockResolvedValue(mockStats);
// Act
const result = await service.getRacesTotal();
// Assert
expect(mockApiClient.getTotal).toHaveBeenCalledTimes(1);
expect(result).toBe(mockStats);
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Failed to fetch statistics');
vi.mocked(mockApiClient.getTotal).mockRejectedValue(error);
// Act & Assert
await expect(service.getRacesTotal()).rejects.toThrow(
'API Error: Failed to fetch statistics'
);
expect(mockApiClient.getTotal).toHaveBeenCalledTimes(1);
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient and raceDetailPresenter', () => {
// This test verifies the constructor signature
expect(() => {
new RaceService(mockApiClient, mockPresenter);
}).not.toThrow();
});
it('should use injected dependencies', async () => {
// Arrange
const customApiClient = {
getDetail: vi.fn().mockResolvedValue({
race: null,
league: null,
entryList: [],
registration: { isRegistered: false, canRegister: false },
userResult: null,
}),
getPageData: vi.fn(),
getTotal: vi.fn(),
} as unknown as RacesApiClient;
const customPresenter = {
present: vi.fn().mockReturnValue({} as RaceDetailViewModel),
} as unknown as RaceDetailPresenter;
const customService = new RaceService(customApiClient, customPresenter);
// Act
await customService.getRaceDetail('race-1', 'driver-1');
// Assert
expect(customApiClient.getDetail).toHaveBeenCalledWith('race-1', 'driver-1');
expect(customPresenter.present).toHaveBeenCalled();
});
});
});

View File

@@ -1,48 +1,44 @@
import { api as api } from '../../api';
import { RaceDetailPresenter } from '../../presenters';
import { RaceDetailViewModel } from '../../view-models';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailPresenter } from '../../presenters/RaceDetailPresenter';
import type { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
import type { RacesPageDataDto, RaceStatsDto } from '../../dtos';
/**
* Race Service
*
* Orchestrates race operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class RaceService {
constructor(
private readonly apiClient = api.races,
private readonly presenter = new RaceDetailPresenter()
private readonly apiClient: RacesApiClient,
private readonly raceDetailPresenter: RaceDetailPresenter
) {}
/**
* Get race detail with presentation transformation
*/
async getRaceDetail(
raceId: string,
driverId: string
): Promise<RaceDetailViewModel> {
const dto = await this.apiClient.getDetail(raceId, driverId);
return this.presenter.present(dto);
return this.raceDetailPresenter.present(dto);
}
async getRacesPageData(): Promise<any> {
const dto = await this.apiClient.getPageData();
// TODO: use presenter
return dto;
/**
* Get races page data
* TODO: Add presenter transformation when presenter is available
*/
async getRacesPageData(): Promise<RacesPageDataDto> {
return this.apiClient.getPageData();
}
async getRacesTotal(): Promise<any> {
const dto = await this.apiClient.getTotal();
return dto;
/**
* Get total races statistics
* TODO: Add presenter transformation when presenter is available
*/
async getRacesTotal(): Promise<RaceStatsDto> {
return this.apiClient.getTotal();
}
}
// Singleton instance
export const raceService = new RaceService();
// Backward compatibility functions
export async function getRaceDetail(
raceId: string,
driverId: string
): Promise<RaceDetailViewModel> {
return raceService.getRaceDetail(raceId, driverId);
}
export async function getRacesPageData(): Promise<any> {
return raceService.getRacesPageData();
}
export async function getRacesTotal(): Promise<any> {
return raceService.getRacesTotal();
}

View File

@@ -1,7 +0,0 @@
// Export the class-based service
export { RaceService, raceService } from './RaceService';
export { RaceResultsService, raceResultsService } from './RaceResultsService';
// Export backward compatibility functions
export { getRaceDetail, getRacesPageData, getRacesTotal } from './RaceService';
export { getRaceResults, getRaceSOF, importRaceResults } from './RaceResultsService';

View File

@@ -0,0 +1,347 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SponsorService } from './SponsorService';
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import type { SponsorListPresenter } from '../../presenters/SponsorListPresenter';
import type { SponsorDashboardPresenter } from '../../presenters/SponsorDashboardPresenter';
import type { SponsorSponsorshipsPresenter } from '../../presenters/SponsorSponsorshipsPresenter';
import type {
GetSponsorsOutputDto,
SponsorDashboardDto,
SponsorSponsorshipsDto,
CreateSponsorInputDto,
CreateSponsorOutputDto,
GetEntitySponsorshipPricingResultDto,
} from '../../dtos';
import type { SponsorViewModel, SponsorDashboardViewModel, SponsorSponsorshipsViewModel } from '../../view-models';
describe('SponsorService', () => {
let service: SponsorService;
let mockApiClient: SponsorsApiClient;
let mockSponsorListPresenter: SponsorListPresenter;
let mockSponsorDashboardPresenter: SponsorDashboardPresenter;
let mockSponsorSponsorshipsPresenter: SponsorSponsorshipsPresenter;
beforeEach(() => {
mockApiClient = {
getAll: vi.fn(),
getDashboard: vi.fn(),
getSponsorships: vi.fn(),
create: vi.fn(),
getPricing: vi.fn(),
} as unknown as SponsorsApiClient;
mockSponsorListPresenter = {
present: vi.fn(),
} as unknown as SponsorListPresenter;
mockSponsorDashboardPresenter = {
present: vi.fn(),
} as unknown as SponsorDashboardPresenter;
mockSponsorSponsorshipsPresenter = {
present: vi.fn(),
} as unknown as SponsorSponsorshipsPresenter;
service = new SponsorService(
mockApiClient,
mockSponsorListPresenter,
mockSponsorDashboardPresenter,
mockSponsorSponsorshipsPresenter
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(SponsorService);
});
});
describe('getAllSponsors', () => {
it('should fetch all sponsors from API and transform via presenter', async () => {
// Arrange
const mockDto: GetSponsorsOutputDto = {
sponsors: [
{
id: 'sponsor-1',
name: 'Sponsor Alpha',
logoUrl: 'https://example.com/logo1.png',
websiteUrl: 'https://alpha.com',
},
{
id: 'sponsor-2',
name: 'Sponsor Beta',
logoUrl: 'https://example.com/logo2.png',
websiteUrl: 'https://beta.com',
},
],
};
const mockViewModels: SponsorViewModel[] = [
{
id: 'sponsor-1',
name: 'Sponsor Alpha',
logoUrl: 'https://example.com/logo1.png',
websiteUrl: 'https://alpha.com',
} as SponsorViewModel,
{
id: 'sponsor-2',
name: 'Sponsor Beta',
logoUrl: 'https://example.com/logo2.png',
websiteUrl: 'https://beta.com',
} as SponsorViewModel,
];
vi.mocked(mockApiClient.getAll).mockResolvedValue(mockDto);
vi.mocked(mockSponsorListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getAllSponsors();
// Assert
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(mockSponsorListPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModels);
});
it('should handle empty sponsors list', async () => {
// Arrange
const mockDto: GetSponsorsOutputDto = {
sponsors: [],
};
const mockViewModels: SponsorViewModel[] = [];
vi.mocked(mockApiClient.getAll).mockResolvedValue(mockDto);
vi.mocked(mockSponsorListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getAllSponsors();
// Assert
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(mockSponsorListPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual([]);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch sponsors');
vi.mocked(mockApiClient.getAll).mockRejectedValue(error);
// Act & Assert
await expect(service.getAllSponsors()).rejects.toThrow('Failed to fetch sponsors');
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(mockSponsorListPresenter.present).not.toHaveBeenCalled();
});
});
describe('getSponsorDashboard', () => {
it('should fetch sponsor dashboard and transform via presenter', async () => {
// Arrange
const sponsorId = 'sponsor-123';
const mockDto: SponsorDashboardDto = {
sponsorId,
sponsorName: 'Sponsor Alpha',
totalSponsorships: 10,
activeSponsorships: 7,
totalInvestment: 50000,
};
const mockViewModel: SponsorDashboardViewModel = {
sponsorId,
sponsorName: 'Sponsor Alpha',
totalSponsorships: 10,
activeSponsorships: 7,
totalInvestment: 50000,
} as SponsorDashboardViewModel;
vi.mocked(mockApiClient.getDashboard).mockResolvedValue(mockDto);
vi.mocked(mockSponsorDashboardPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorDashboard(sponsorId);
// Assert
expect(mockApiClient.getDashboard).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorDashboardPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should return null when dashboard is not found', async () => {
// Arrange
const sponsorId = 'non-existent';
vi.mocked(mockApiClient.getDashboard).mockResolvedValue(null);
// Act
const result = await service.getSponsorDashboard(sponsorId);
// Assert
expect(mockApiClient.getDashboard).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorDashboardPresenter.present).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should propagate errors from API client', async () => {
// Arrange
const sponsorId = 'sponsor-123';
const error = new Error('Failed to fetch dashboard');
vi.mocked(mockApiClient.getDashboard).mockRejectedValue(error);
// Act & Assert
await expect(service.getSponsorDashboard(sponsorId)).rejects.toThrow('Failed to fetch dashboard');
expect(mockApiClient.getDashboard).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorDashboardPresenter.present).not.toHaveBeenCalled();
});
});
describe('getSponsorSponsorships', () => {
it('should fetch sponsor sponsorships and transform via presenter', async () => {
// Arrange
const sponsorId = 'sponsor-123';
const mockDto: SponsorSponsorshipsDto = {
sponsorId,
sponsorName: 'Sponsor Alpha',
sponsorships: [
{
id: 'sponsorship-1',
leagueId: 'league-1',
leagueName: 'League One',
seasonId: 'season-1',
tier: 'main',
status: 'active',
amount: 10000,
currency: 'USD',
},
],
};
const mockViewModel: SponsorSponsorshipsViewModel = {
sponsorId,
sponsorName: 'Sponsor Alpha',
sponsorships: [],
} as SponsorSponsorshipsViewModel;
vi.mocked(mockApiClient.getSponsorships).mockResolvedValue(mockDto);
vi.mocked(mockSponsorSponsorshipsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorSponsorships(sponsorId);
// Assert
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should return null when sponsorships are not found', async () => {
// Arrange
const sponsorId = 'non-existent';
vi.mocked(mockApiClient.getSponsorships).mockResolvedValue(null);
// Act
const result = await service.getSponsorSponsorships(sponsorId);
// Assert
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should propagate errors from API client', async () => {
// Arrange
const sponsorId = 'sponsor-123';
const error = new Error('Failed to fetch sponsorships');
vi.mocked(mockApiClient.getSponsorships).mockRejectedValue(error);
// Act & Assert
await expect(service.getSponsorSponsorships(sponsorId)).rejects.toThrow('Failed to fetch sponsorships');
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).not.toHaveBeenCalled();
});
});
describe('createSponsor', () => {
it('should create a new sponsor', async () => {
// Arrange
const input: CreateSponsorInputDto = {
name: 'New Sponsor',
logoUrl: 'https://example.com/logo.png',
websiteUrl: 'https://newsponsor.com',
userId: 'user-123',
};
const mockOutput: CreateSponsorOutputDto = {
sponsorId: 'sponsor-new',
success: true,
};
vi.mocked(mockApiClient.create).mockResolvedValue(mockOutput);
// Act
const result = await service.createSponsor(input);
// Assert
expect(mockApiClient.create).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: CreateSponsorInputDto = {
name: 'New Sponsor',
userId: 'user-123',
};
const error = new Error('Failed to create sponsor');
vi.mocked(mockApiClient.create).mockRejectedValue(error);
// Act & Assert
await expect(service.createSponsor(input)).rejects.toThrow('Failed to create sponsor');
expect(mockApiClient.create).toHaveBeenCalledWith(input);
});
});
describe('getSponsorshipPricing', () => {
it('should fetch sponsorship pricing', async () => {
// Arrange
const mockPricing: GetEntitySponsorshipPricingResultDto = {
pricingItems: [
{
tier: 'main',
price: 10000,
currency: 'USD',
benefits: ['Logo placement', 'Race announcements'],
},
{
tier: 'secondary',
price: 5000,
currency: 'USD',
benefits: ['Logo placement'],
},
],
};
vi.mocked(mockApiClient.getPricing).mockResolvedValue(mockPricing);
// Act
const result = await service.getSponsorshipPricing();
// Assert
expect(mockApiClient.getPricing).toHaveBeenCalled();
expect(result).toEqual(mockPricing);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch pricing');
vi.mocked(mockApiClient.getPricing).mockRejectedValue(error);
// Act & Assert
await expect(service.getSponsorshipPricing()).rejects.toThrow('Failed to fetch pricing');
expect(mockApiClient.getPricing).toHaveBeenCalled();
});
});
});

View File

@@ -1,17 +1,65 @@
import { api as api } from '../../api';
import { presentSponsor } from '../../presenters';
import { SponsorViewModel } from '../../view-models';
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import type { SponsorListPresenter } from '../../presenters/SponsorListPresenter';
import type { SponsorDashboardPresenter } from '../../presenters/SponsorDashboardPresenter';
import type { SponsorSponsorshipsPresenter } from '../../presenters/SponsorSponsorshipsPresenter';
import type { SponsorViewModel, SponsorDashboardViewModel, SponsorSponsorshipsViewModel } from '../../view-models';
import type { CreateSponsorInputDto, CreateSponsorOutputDto, GetEntitySponsorshipPricingResultDto } from '../../dtos';
export async function getAllSponsors(): Promise<SponsorViewModel[]> {
const dto = await api.sponsors.getAll();
return dto.sponsors.map(s => presentSponsor(s));
}
/**
* Sponsor Service
*
* Orchestrates sponsor operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class SponsorService {
constructor(
private readonly apiClient: SponsorsApiClient,
private readonly sponsorListPresenter: SponsorListPresenter,
private readonly sponsorDashboardPresenter: SponsorDashboardPresenter,
private readonly sponsorSponsorshipsPresenter: SponsorSponsorshipsPresenter
) {}
export async function createSponsor(input: any): Promise<any> {
return await api.sponsors.create(input);
}
/**
* Get all sponsors with presentation transformation
*/
async getAllSponsors(): Promise<SponsorViewModel[]> {
const dto = await this.apiClient.getAll();
return this.sponsorListPresenter.present(dto);
}
export async function getSponsorDashboard(sponsorId: string): Promise<any> {
const dto = await api.sponsors.getDashboard(sponsorId);
return dto;
/**
* Get sponsor dashboard with presentation transformation
*/
async getSponsorDashboard(sponsorId: string): Promise<SponsorDashboardViewModel | null> {
const dto = await this.apiClient.getDashboard(sponsorId);
if (!dto) {
return null;
}
return this.sponsorDashboardPresenter.present(dto);
}
/**
* Get sponsor sponsorships with presentation transformation
*/
async getSponsorSponsorships(sponsorId: string): Promise<SponsorSponsorshipsViewModel | null> {
const dto = await this.apiClient.getSponsorships(sponsorId);
if (!dto) {
return null;
}
return this.sponsorSponsorshipsPresenter.present(dto);
}
/**
* Create a new sponsor
*/
async createSponsor(input: CreateSponsorInputDto): Promise<CreateSponsorOutputDto> {
return await this.apiClient.create(input);
}
/**
* Get sponsorship pricing
*/
async getSponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDto> {
return await this.apiClient.getPricing();
}
}

View File

@@ -0,0 +1,334 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SponsorshipService } from './SponsorshipService';
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import type { SponsorshipPricingPresenter } from '../../presenters/SponsorshipPricingPresenter';
import type { SponsorSponsorshipsPresenter } from '../../presenters/SponsorSponsorshipsPresenter';
import type {
GetEntitySponsorshipPricingResultDto,
SponsorSponsorshipsDto,
} from '../../dtos';
import type { SponsorshipPricingViewModel, SponsorSponsorshipsViewModel } from '../../view-models';
describe('SponsorshipService', () => {
let service: SponsorshipService;
let mockApiClient: SponsorsApiClient;
let mockSponsorshipPricingPresenter: SponsorshipPricingPresenter;
let mockSponsorSponsorshipsPresenter: SponsorSponsorshipsPresenter;
beforeEach(() => {
mockApiClient = {
getPricing: vi.fn(),
getSponsorships: vi.fn(),
} as unknown as SponsorsApiClient;
mockSponsorshipPricingPresenter = {
present: vi.fn(),
} as unknown as SponsorshipPricingPresenter;
mockSponsorSponsorshipsPresenter = {
present: vi.fn(),
} as unknown as SponsorSponsorshipsPresenter;
service = new SponsorshipService(
mockApiClient,
mockSponsorshipPricingPresenter,
mockSponsorSponsorshipsPresenter
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(SponsorshipService);
});
});
describe('getSponsorshipPricing', () => {
it('should fetch sponsorship pricing from API and transform via presenter', async () => {
// Arrange
const mockDto: GetEntitySponsorshipPricingResultDto = {
mainSlotPrice: 10000,
secondarySlotPrice: 5000,
currency: 'USD',
};
const mockViewModel: SponsorshipPricingViewModel = {
mainSlotPrice: 10000,
secondarySlotPrice: 5000,
currency: 'USD',
formattedMainSlotPrice: 'USD 10,000',
formattedSecondarySlotPrice: 'USD 5,000',
priceDifference: 5000,
formattedPriceDifference: 'USD 5,000',
secondaryDiscountPercentage: 50,
} as SponsorshipPricingViewModel;
vi.mocked(mockApiClient.getPricing).mockResolvedValue(mockDto);
vi.mocked(mockSponsorshipPricingPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorshipPricing();
// Assert
expect(mockApiClient.getPricing).toHaveBeenCalled();
expect(mockSponsorshipPricingPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle different currencies', async () => {
// Arrange
const mockDto: GetEntitySponsorshipPricingResultDto = {
mainSlotPrice: 8000,
secondarySlotPrice: 4000,
currency: 'EUR',
};
const mockViewModel: SponsorshipPricingViewModel = {
mainSlotPrice: 8000,
secondarySlotPrice: 4000,
currency: 'EUR',
formattedMainSlotPrice: 'EUR 8,000',
formattedSecondarySlotPrice: 'EUR 4,000',
priceDifference: 4000,
formattedPriceDifference: 'EUR 4,000',
secondaryDiscountPercentage: 50,
} as SponsorshipPricingViewModel;
vi.mocked(mockApiClient.getPricing).mockResolvedValue(mockDto);
vi.mocked(mockSponsorshipPricingPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorshipPricing();
// Assert
expect(mockApiClient.getPricing).toHaveBeenCalled();
expect(mockSponsorshipPricingPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.currency).toBe('EUR');
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch pricing');
vi.mocked(mockApiClient.getPricing).mockRejectedValue(error);
// Act & Assert
await expect(service.getSponsorshipPricing()).rejects.toThrow('Failed to fetch pricing');
expect(mockApiClient.getPricing).toHaveBeenCalled();
expect(mockSponsorshipPricingPresenter.present).not.toHaveBeenCalled();
});
it('should handle zero prices', async () => {
// Arrange
const mockDto: GetEntitySponsorshipPricingResultDto = {
mainSlotPrice: 0,
secondarySlotPrice: 0,
currency: 'USD',
};
const mockViewModel: SponsorshipPricingViewModel = {
mainSlotPrice: 0,
secondarySlotPrice: 0,
currency: 'USD',
formattedMainSlotPrice: 'USD 0',
formattedSecondarySlotPrice: 'USD 0',
priceDifference: 0,
formattedPriceDifference: 'USD 0',
secondaryDiscountPercentage: 0,
} as SponsorshipPricingViewModel;
vi.mocked(mockApiClient.getPricing).mockResolvedValue(mockDto);
vi.mocked(mockSponsorshipPricingPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorshipPricing();
// Assert
expect(mockApiClient.getPricing).toHaveBeenCalled();
expect(mockSponsorshipPricingPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
});
describe('getSponsorSponsorships', () => {
it('should fetch sponsor sponsorships and transform via presenter', async () => {
// Arrange
const sponsorId = 'sponsor-123';
const mockDto: SponsorSponsorshipsDto = {
sponsorId,
sponsorName: 'Sponsor Alpha',
sponsorships: [
{
id: 'sponsorship-1',
leagueId: 'league-1',
leagueName: 'League One',
seasonId: 'season-1',
tier: 'main',
status: 'active',
amount: 10000,
currency: 'USD',
},
{
id: 'sponsorship-2',
leagueId: 'league-2',
leagueName: 'League Two',
seasonId: 'season-2',
tier: 'secondary',
status: 'active',
amount: 5000,
currency: 'USD',
},
],
};
const mockViewModel: SponsorSponsorshipsViewModel = {
sponsorId,
sponsorName: 'Sponsor Alpha',
sponsorships: [],
totalCount: 2,
activeCount: 2,
hasSponsorships: true,
totalInvestment: 15000,
formattedTotalInvestment: 'USD 15,000',
} as SponsorSponsorshipsViewModel;
vi.mocked(mockApiClient.getSponsorships).mockResolvedValue(mockDto);
vi.mocked(mockSponsorSponsorshipsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorSponsorships(sponsorId);
// Assert
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should return null when sponsorships are not found', async () => {
// Arrange
const sponsorId = 'non-existent';
vi.mocked(mockApiClient.getSponsorships).mockResolvedValue(null);
// Act
const result = await service.getSponsorSponsorships(sponsorId);
// Assert
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle empty sponsorships list', async () => {
// Arrange
const sponsorId = 'sponsor-123';
const mockDto: SponsorSponsorshipsDto = {
sponsorId,
sponsorName: 'Sponsor Alpha',
sponsorships: [],
};
const mockViewModel: SponsorSponsorshipsViewModel = {
sponsorId,
sponsorName: 'Sponsor Alpha',
sponsorships: [],
totalCount: 0,
activeCount: 0,
hasSponsorships: false,
totalInvestment: 0,
formattedTotalInvestment: 'USD 0',
} as SponsorSponsorshipsViewModel;
vi.mocked(mockApiClient.getSponsorships).mockResolvedValue(mockDto);
vi.mocked(mockSponsorSponsorshipsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorSponsorships(sponsorId);
// Assert
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result?.totalCount).toBe(0);
});
it('should propagate errors from API client', async () => {
// Arrange
const sponsorId = 'sponsor-123';
const error = new Error('Failed to fetch sponsorships');
vi.mocked(mockApiClient.getSponsorships).mockRejectedValue(error);
// Act & Assert
await expect(service.getSponsorSponsorships(sponsorId)).rejects.toThrow('Failed to fetch sponsorships');
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).not.toHaveBeenCalled();
});
it('should handle multiple sponsorship tiers', async () => {
// Arrange
const sponsorId = 'sponsor-456';
const mockDto: SponsorSponsorshipsDto = {
sponsorId,
sponsorName: 'Sponsor Beta',
sponsorships: [
{
id: 'sponsorship-1',
leagueId: 'league-1',
leagueName: 'League One',
seasonId: 'season-1',
tier: 'main',
status: 'active',
amount: 10000,
currency: 'USD',
},
{
id: 'sponsorship-2',
leagueId: 'league-2',
leagueName: 'League Two',
seasonId: 'season-2',
tier: 'secondary',
status: 'pending',
amount: 5000,
currency: 'USD',
},
{
id: 'sponsorship-3',
leagueId: 'league-3',
leagueName: 'League Three',
seasonId: 'season-3',
tier: 'main',
status: 'expired',
amount: 10000,
currency: 'USD',
},
],
};
const mockViewModel: SponsorSponsorshipsViewModel = {
sponsorId,
sponsorName: 'Sponsor Beta',
sponsorships: [],
totalCount: 3,
activeCount: 1,
hasSponsorships: true,
totalInvestment: 25000,
formattedTotalInvestment: 'USD 25,000',
} as SponsorSponsorshipsViewModel;
vi.mocked(mockApiClient.getSponsorships).mockResolvedValue(mockDto);
vi.mocked(mockSponsorSponsorshipsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getSponsorSponsorships(sponsorId);
// Assert
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith(sponsorId);
expect(mockSponsorSponsorshipsPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result?.totalCount).toBe(3);
expect(result?.activeCount).toBe(1);
});
});
});

View File

@@ -1,11 +1,44 @@
import { api as api } from '../../api';
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import type { SponsorshipPricingPresenter } from '../../presenters/SponsorshipPricingPresenter';
import type { SponsorSponsorshipsPresenter } from '../../presenters/SponsorSponsorshipsPresenter';
import type {
SponsorshipPricingViewModel,
SponsorSponsorshipsViewModel
} from '../../view-models';
import type {
GetEntitySponsorshipPricingResultDto,
SponsorSponsorshipsDto
} from '../../dtos';
export async function getSponsorshipPricing(): Promise<any> {
const dto = await api.sponsors.getPricing();
return dto;
}
/**
* Sponsorship Service
*
* Orchestrates sponsorship operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class SponsorshipService {
constructor(
private readonly apiClient: SponsorsApiClient,
private readonly sponsorshipPricingPresenter: SponsorshipPricingPresenter,
private readonly sponsorSponsorshipsPresenter: SponsorSponsorshipsPresenter
) {}
export async function getSponsorSponsorships(sponsorId: string): Promise<any> {
const dto = await api.sponsors.getSponsorships(sponsorId);
return dto;
/**
* Get sponsorship pricing with presentation transformation
*/
async getSponsorshipPricing(): Promise<SponsorshipPricingViewModel> {
const dto = await this.apiClient.getPricing();
return this.sponsorshipPricingPresenter.present(dto);
}
/**
* Get sponsor sponsorships with presentation transformation
*/
async getSponsorSponsorships(sponsorId: string): Promise<SponsorSponsorshipsViewModel | null> {
const dto = await this.apiClient.getSponsorships(sponsorId);
if (!dto) {
return null;
}
return this.sponsorSponsorshipsPresenter.present(dto);
}
}

View File

@@ -0,0 +1,254 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TeamJoinService } from './TeamJoinService';
import { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import { TeamJoinRequestPresenter } from '../../presenters/TeamJoinRequestPresenter';
import type { TeamJoinRequestsDto, TeamJoinRequestItemDto } from '../../dtos';
import type { TeamJoinRequestViewModel } from '../../view-models';
describe('TeamJoinService', () => {
let mockApiClient: TeamsApiClient;
let mockPresenter: TeamJoinRequestPresenter;
let service: TeamJoinService;
beforeEach(() => {
mockApiClient = {
getJoinRequests: vi.fn(),
} as unknown as TeamsApiClient;
mockPresenter = {
present: vi.fn(),
} as unknown as TeamJoinRequestPresenter;
service = new TeamJoinService(mockApiClient, mockPresenter);
});
describe('getJoinRequests', () => {
it('should fetch join requests from API and transform via presenter', async () => {
// Arrange
const mockRequestDto: TeamJoinRequestItemDto = {
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2025-12-17T20:00:00Z',
message: 'Please let me join',
};
const mockDto: TeamJoinRequestsDto = {
requests: [mockRequestDto],
};
const mockViewModel: TeamJoinRequestViewModel = {
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2025-12-17T20:00:00Z',
message: 'Please let me join',
canApprove: true,
formattedRequestedAt: '12/17/2025, 9:00:00 PM',
status: 'Pending',
statusColor: 'yellow',
approveButtonText: 'Approve',
rejectButtonText: 'Reject',
} as unknown as TeamJoinRequestViewModel;
vi.mocked(mockApiClient.getJoinRequests).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getJoinRequests('team-1', 'driver-owner', true);
// Assert
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
expect(mockApiClient.getJoinRequests).toHaveBeenCalledTimes(1);
expect(mockPresenter.present).toHaveBeenCalledWith(mockRequestDto, 'driver-owner', true);
expect(mockPresenter.present).toHaveBeenCalledTimes(1);
expect(result).toEqual([mockViewModel]);
});
it('should handle multiple join requests', async () => {
// Arrange
const mockRequestDto1: TeamJoinRequestItemDto = {
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2025-12-17T20:00:00Z',
};
const mockRequestDto2: TeamJoinRequestItemDto = {
id: 'request-2',
teamId: 'team-1',
driverId: 'driver-2',
requestedAt: '2025-12-17T21:00:00Z',
message: 'I want to join',
};
const mockDto: TeamJoinRequestsDto = {
requests: [mockRequestDto1, mockRequestDto2],
};
const mockViewModel1 = { id: 'request-1' } as TeamJoinRequestViewModel;
const mockViewModel2 = { id: 'request-2' } as TeamJoinRequestViewModel;
vi.mocked(mockApiClient.getJoinRequests).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.present)
.mockReturnValueOnce(mockViewModel1)
.mockReturnValueOnce(mockViewModel2);
// Act
const result = await service.getJoinRequests('team-1', 'driver-owner', true);
// Assert
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
expect(mockPresenter.present).toHaveBeenCalledTimes(2);
expect(mockPresenter.present).toHaveBeenNthCalledWith(1, mockRequestDto1, 'driver-owner', true);
expect(mockPresenter.present).toHaveBeenNthCalledWith(2, mockRequestDto2, 'driver-owner', true);
expect(result).toEqual([mockViewModel1, mockViewModel2]);
});
it('should handle empty join requests list', async () => {
// Arrange
const mockDto: TeamJoinRequestsDto = {
requests: [],
};
vi.mocked(mockApiClient.getJoinRequests).mockResolvedValue(mockDto);
// Act
const result = await service.getJoinRequests('team-1', 'driver-owner', true);
// Assert
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
expect(mockPresenter.present).not.toHaveBeenCalled();
expect(result).toEqual([]);
});
it('should propagate API client errors', async () => {
// Arrange
const error = new Error('API Error: Team not found');
vi.mocked(mockApiClient.getJoinRequests).mockRejectedValue(error);
// Act & Assert
await expect(
service.getJoinRequests('invalid-team', 'driver-1', false)
).rejects.toThrow('API Error: Team not found');
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('invalid-team');
expect(mockPresenter.present).not.toHaveBeenCalled();
});
it('should propagate presenter errors', async () => {
// Arrange
const mockRequestDto: TeamJoinRequestItemDto = {
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2025-12-17T20:00:00Z',
};
const mockDto: TeamJoinRequestsDto = {
requests: [mockRequestDto],
};
const error = new Error('Presenter Error: Invalid DTO structure');
vi.mocked(mockApiClient.getJoinRequests).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.present).mockImplementation(() => {
throw error;
});
// Act & Assert
await expect(
service.getJoinRequests('team-1', 'driver-1', false)
).rejects.toThrow('Presenter Error: Invalid DTO structure');
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
expect(mockPresenter.present).toHaveBeenCalledWith(mockRequestDto, 'driver-1', false);
});
it('should pass correct isOwner flag to presenter', async () => {
// Arrange
const mockRequestDto: TeamJoinRequestItemDto = {
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2025-12-17T20:00:00Z',
};
const mockDto: TeamJoinRequestsDto = {
requests: [mockRequestDto],
};
const mockViewModel = { id: 'request-1' } as TeamJoinRequestViewModel;
vi.mocked(mockApiClient.getJoinRequests).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.present).mockReturnValue(mockViewModel);
// Act - non-owner
await service.getJoinRequests('team-1', 'driver-member', false);
// Assert
expect(mockPresenter.present).toHaveBeenCalledWith(mockRequestDto, 'driver-member', false);
});
});
describe('approveJoinRequest', () => {
it('should throw not implemented error', async () => {
// Act & Assert
await expect(
service.approveJoinRequest('team-1', 'request-1')
).rejects.toThrow('Not implemented: API endpoint for approving join requests');
});
it('should propagate errors when API is implemented', async () => {
// This test ensures error handling is in place for future implementation
// Act & Assert
await expect(
service.approveJoinRequest('team-1', 'request-1')
).rejects.toThrow();
});
});
describe('rejectJoinRequest', () => {
it('should throw not implemented error', async () => {
// Act & Assert
await expect(
service.rejectJoinRequest('team-1', 'request-1')
).rejects.toThrow('Not implemented: API endpoint for rejecting join requests');
});
it('should propagate errors when API is implemented', async () => {
// This test ensures error handling is in place for future implementation
// Act & Assert
await expect(
service.rejectJoinRequest('team-1', 'request-1')
).rejects.toThrow();
});
});
describe('Constructor Dependency Injection', () => {
it('should require apiClient and teamJoinRequestPresenter', () => {
// This test verifies the constructor signature
expect(() => {
new TeamJoinService(mockApiClient, mockPresenter);
}).not.toThrow();
});
it('should use injected dependencies', async () => {
// Arrange
const customApiClient = {
getJoinRequests: vi.fn().mockResolvedValue({ requests: [] }),
} as unknown as TeamsApiClient;
const customPresenter = {
present: vi.fn().mockReturnValue({} as TeamJoinRequestViewModel),
} as unknown as TeamJoinRequestPresenter;
const customService = new TeamJoinService(customApiClient, customPresenter);
// Act
await customService.getJoinRequests('team-1', 'driver-1', true);
// Assert
expect(customApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
});
});
});

View File

@@ -1,16 +1,52 @@
import { api as api } from '../../api';
import { presentTeamJoinRequest } from '../../presenters';
import { TeamJoinRequestViewModel } from '../../view-models';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import type { TeamJoinRequestPresenter } from '../../presenters/TeamJoinRequestPresenter';
import type { TeamJoinRequestViewModel } from '../../view-models';
export async function getTeamJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
const dto = await api.teams.getJoinRequests(teamId);
return dto.requests.map(r => presentTeamJoinRequest(r, currentUserId, isOwner));
}
/**
* Team Join Service
*
* Orchestrates team join/leave operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class TeamJoinService {
constructor(
private readonly apiClient: TeamsApiClient,
private readonly teamJoinRequestPresenter: TeamJoinRequestPresenter
) {}
export async function approveTeamJoinRequest(teamId: string, requestId: string): Promise<void> {
// TODO: implement API call
}
/**
* Get team join requests with presentation transformation
*/
async getJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
try {
const dto = await this.apiClient.getJoinRequests(teamId);
return dto.requests.map(r => this.teamJoinRequestPresenter.present(r, currentUserId, isOwner));
} catch (error) {
throw error;
}
}
export async function rejectTeamJoinRequest(teamId: string, requestId: string): Promise<void> {
// TODO: implement API call
/**
* Approve a team join request
*/
async approveJoinRequest(teamId: string, requestId: string): Promise<void> {
try {
// TODO: implement API call when endpoint is available
throw new Error('Not implemented: API endpoint for approving join requests');
} catch (error) {
throw error;
}
}
/**
* Reject a team join request
*/
async rejectJoinRequest(teamId: string, requestId: string): Promise<void> {
try {
// TODO: implement API call when endpoint is available
throw new Error('Not implemented: API endpoint for rejecting join requests');
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,422 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TeamService } from './TeamService';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import type { TeamDetailsPresenter } from '../../presenters/TeamDetailsPresenter';
import type { TeamListPresenter } from '../../presenters/TeamListPresenter';
import type { TeamMembersPresenter } from '../../presenters/TeamMembersPresenter';
import type {
AllTeamsDto,
TeamDetailsDto,
TeamMembersDto,
CreateTeamInputDto,
CreateTeamOutputDto,
UpdateTeamInputDto,
UpdateTeamOutputDto,
DriverTeamDto,
} from '../../dtos';
import type { TeamSummaryViewModel, TeamDetailsViewModel, TeamMemberViewModel } from '../../view-models';
describe('TeamService', () => {
let service: TeamService;
let mockApiClient: TeamsApiClient;
let mockTeamListPresenter: TeamListPresenter;
let mockTeamDetailsPresenter: TeamDetailsPresenter;
let mockTeamMembersPresenter: TeamMembersPresenter;
beforeEach(() => {
mockApiClient = {
getAll: vi.fn(),
getDetails: vi.fn(),
getMembers: vi.fn(),
create: vi.fn(),
update: vi.fn(),
getDriverTeam: vi.fn(),
} as unknown as TeamsApiClient;
mockTeamListPresenter = {
present: vi.fn(),
} as unknown as TeamListPresenter;
mockTeamDetailsPresenter = {
present: vi.fn(),
} as unknown as TeamDetailsPresenter;
mockTeamMembersPresenter = {
present: vi.fn(),
} as unknown as TeamMembersPresenter;
service = new TeamService(
mockApiClient,
mockTeamListPresenter,
mockTeamDetailsPresenter,
mockTeamMembersPresenter
);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(TeamService);
});
});
describe('getAllTeams', () => {
it('should fetch all teams from API and transform via presenter', async () => {
// Arrange
const mockDto: AllTeamsDto = {
teams: [
{
id: 'team-1',
name: 'Team Alpha',
logoUrl: 'https://example.com/logo1.png',
memberCount: 5,
rating: 2500,
},
{
id: 'team-2',
name: 'Team Beta',
logoUrl: 'https://example.com/logo2.png',
memberCount: 3,
rating: 2300,
},
],
};
const mockViewModels: TeamSummaryViewModel[] = [
{
id: 'team-1',
name: 'Team Alpha',
logoUrl: 'https://example.com/logo1.png',
memberCount: 5,
rating: 2500,
} as TeamSummaryViewModel,
{
id: 'team-2',
name: 'Team Beta',
logoUrl: 'https://example.com/logo2.png',
memberCount: 3,
rating: 2300,
} as TeamSummaryViewModel,
];
vi.mocked(mockApiClient.getAll).mockResolvedValue(mockDto);
vi.mocked(mockTeamListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getAllTeams();
// Assert
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(mockTeamListPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModels);
});
it('should handle empty teams list', async () => {
// Arrange
const mockDto: AllTeamsDto = {
teams: [],
};
const mockViewModels: TeamSummaryViewModel[] = [];
vi.mocked(mockApiClient.getAll).mockResolvedValue(mockDto);
vi.mocked(mockTeamListPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getAllTeams();
// Assert
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(mockTeamListPresenter.present).toHaveBeenCalledWith(mockDto);
expect(result).toEqual([]);
});
it('should propagate errors from API client', async () => {
// Arrange
const error = new Error('Failed to fetch teams');
vi.mocked(mockApiClient.getAll).mockRejectedValue(error);
// Act & Assert
await expect(service.getAllTeams()).rejects.toThrow('Failed to fetch teams');
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(mockTeamListPresenter.present).not.toHaveBeenCalled();
});
});
describe('getTeamDetails', () => {
it('should fetch team details and transform via presenter', async () => {
// Arrange
const teamId = 'team-123';
const currentUserId = 'user-456';
const mockDto: TeamDetailsDto = {
id: teamId,
name: 'Team Alpha',
description: 'A competitive racing team',
logoUrl: 'https://example.com/logo.png',
memberCount: 5,
ownerId: 'user-789',
};
const mockViewModel: TeamDetailsViewModel = {
id: teamId,
name: 'Team Alpha',
description: 'A competitive racing team',
logoUrl: 'https://example.com/logo.png',
memberCount: 5,
ownerId: 'user-789',
members: [],
} as TeamDetailsViewModel;
vi.mocked(mockApiClient.getDetails).mockResolvedValue(mockDto);
vi.mocked(mockTeamDetailsPresenter.present).mockReturnValue(mockViewModel);
// Act
const result = await service.getTeamDetails(teamId, currentUserId);
// Assert
expect(mockApiClient.getDetails).toHaveBeenCalledWith(teamId);
expect(mockTeamDetailsPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId);
expect(result).toEqual(mockViewModel);
});
it('should return null when team is not found', async () => {
// Arrange
const teamId = 'non-existent';
const currentUserId = 'user-456';
vi.mocked(mockApiClient.getDetails).mockResolvedValue(null);
// Act
const result = await service.getTeamDetails(teamId, currentUserId);
// Assert
expect(mockApiClient.getDetails).toHaveBeenCalledWith(teamId);
expect(mockTeamDetailsPresenter.present).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should propagate errors from API client', async () => {
// Arrange
const teamId = 'team-123';
const currentUserId = 'user-456';
const error = new Error('Failed to fetch team details');
vi.mocked(mockApiClient.getDetails).mockRejectedValue(error);
// Act & Assert
await expect(service.getTeamDetails(teamId, currentUserId)).rejects.toThrow('Failed to fetch team details');
expect(mockApiClient.getDetails).toHaveBeenCalledWith(teamId);
expect(mockTeamDetailsPresenter.present).not.toHaveBeenCalled();
});
});
describe('getTeamMembers', () => {
it('should fetch team members and transform via presenter', async () => {
// Arrange
const teamId = 'team-123';
const currentUserId = 'user-456';
const teamOwnerId = 'user-789';
const mockDto: TeamMembersDto = {
members: [
{
driverId: 'driver-1',
role: 'owner',
joinedAt: '2024-01-01',
},
{
driverId: 'driver-2',
role: 'member',
joinedAt: '2024-01-02',
},
],
};
const mockViewModels: TeamMemberViewModel[] = [
{
driverId: 'driver-1',
role: 'owner',
joinedAt: '2024-01-01',
} as TeamMemberViewModel,
{
driverId: 'driver-2',
role: 'member',
joinedAt: '2024-01-02',
} as TeamMemberViewModel,
];
vi.mocked(mockApiClient.getMembers).mockResolvedValue(mockDto);
vi.mocked(mockTeamMembersPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getTeamMembers(teamId, currentUserId, teamOwnerId);
// Assert
expect(mockApiClient.getMembers).toHaveBeenCalledWith(teamId);
expect(mockTeamMembersPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId, teamOwnerId);
expect(result).toEqual(mockViewModels);
});
it('should handle empty members list', async () => {
// Arrange
const teamId = 'team-123';
const currentUserId = 'user-456';
const teamOwnerId = 'user-789';
const mockDto: TeamMembersDto = {
members: [],
};
const mockViewModels: TeamMemberViewModel[] = [];
vi.mocked(mockApiClient.getMembers).mockResolvedValue(mockDto);
vi.mocked(mockTeamMembersPresenter.present).mockReturnValue(mockViewModels);
// Act
const result = await service.getTeamMembers(teamId, currentUserId, teamOwnerId);
// Assert
expect(mockApiClient.getMembers).toHaveBeenCalledWith(teamId);
expect(mockTeamMembersPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId, teamOwnerId);
expect(result).toEqual([]);
});
it('should propagate errors from API client', async () => {
// Arrange
const teamId = 'team-123';
const currentUserId = 'user-456';
const teamOwnerId = 'user-789';
const error = new Error('Failed to fetch team members');
vi.mocked(mockApiClient.getMembers).mockRejectedValue(error);
// Act & Assert
await expect(service.getTeamMembers(teamId, currentUserId, teamOwnerId)).rejects.toThrow('Failed to fetch team members');
expect(mockApiClient.getMembers).toHaveBeenCalledWith(teamId);
expect(mockTeamMembersPresenter.present).not.toHaveBeenCalled();
});
});
describe('createTeam', () => {
it('should create a new team', async () => {
// Arrange
const input: CreateTeamInputDto = {
name: 'New Team',
description: 'A new racing team',
};
const mockOutput: CreateTeamOutputDto = {
id: 'team-new',
name: 'New Team',
success: true,
};
vi.mocked(mockApiClient.create).mockResolvedValue(mockOutput);
// Act
const result = await service.createTeam(input);
// Assert
expect(mockApiClient.create).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: CreateTeamInputDto = {
name: 'New Team',
description: 'A new racing team',
};
const error = new Error('Failed to create team');
vi.mocked(mockApiClient.create).mockRejectedValue(error);
// Act & Assert
await expect(service.createTeam(input)).rejects.toThrow('Failed to create team');
expect(mockApiClient.create).toHaveBeenCalledWith(input);
});
});
describe('updateTeam', () => {
it('should update team details', async () => {
// Arrange
const teamId = 'team-123';
const input: UpdateTeamInputDto = {
name: 'Updated Team Name',
description: 'Updated description',
};
const mockOutput: UpdateTeamOutputDto = {
id: teamId,
success: true,
};
vi.mocked(mockApiClient.update).mockResolvedValue(mockOutput);
// Act
const result = await service.updateTeam(teamId, input);
// Assert
expect(mockApiClient.update).toHaveBeenCalledWith(teamId, input);
expect(result).toEqual(mockOutput);
});
it('should propagate errors from API client', async () => {
// Arrange
const teamId = 'team-123';
const input: UpdateTeamInputDto = {
name: 'Updated Team Name',
};
const error = new Error('Failed to update team');
vi.mocked(mockApiClient.update).mockRejectedValue(error);
// Act & Assert
await expect(service.updateTeam(teamId, input)).rejects.toThrow('Failed to update team');
expect(mockApiClient.update).toHaveBeenCalledWith(teamId, input);
});
});
describe('getDriverTeam', () => {
it('should fetch driver team', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: DriverTeamDto = {
teamId: 'team-456',
teamName: 'Team Alpha',
role: 'member',
};
vi.mocked(mockApiClient.getDriverTeam).mockResolvedValue(mockDto);
// Act
const result = await service.getDriverTeam(driverId);
// Assert
expect(mockApiClient.getDriverTeam).toHaveBeenCalledWith(driverId);
expect(result).toEqual(mockDto);
});
it('should return null when driver has no team', async () => {
// Arrange
const driverId = 'driver-123';
vi.mocked(mockApiClient.getDriverTeam).mockResolvedValue(null);
// Act
const result = await service.getDriverTeam(driverId);
// Assert
expect(mockApiClient.getDriverTeam).toHaveBeenCalledWith(driverId);
expect(result).toBeNull();
});
it('should propagate errors from API client', async () => {
// Arrange
const driverId = 'driver-123';
const error = new Error('Failed to fetch driver team');
vi.mocked(mockApiClient.getDriverTeam).mockRejectedValue(error);
// Act & Assert
await expect(service.getDriverTeam(driverId)).rejects.toThrow('Failed to fetch driver team');
expect(mockApiClient.getDriverTeam).toHaveBeenCalledWith(driverId);
});
});
});

View File

@@ -1,30 +1,69 @@
import { api as api } from '../../api';
import { presentTeamDetails, presentTeamMember, presentTeamSummary } from '../../presenters';
import { TeamDetailsViewModel, TeamMemberViewModel, TeamSummaryViewModel } from '../../view-models';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import type { TeamDetailsPresenter } from '../../presenters/TeamDetailsPresenter';
import type { TeamListPresenter } from '../../presenters/TeamListPresenter';
import type { TeamMembersPresenter } from '../../presenters/TeamMembersPresenter';
import type { TeamSummaryViewModel, TeamDetailsViewModel, TeamMemberViewModel } from '../../view-models';
import type { CreateTeamInputDto, CreateTeamOutputDto, UpdateTeamInputDto, UpdateTeamOutputDto, DriverTeamDto } from '../../dtos';
export async function getAllTeams(): Promise<TeamSummaryViewModel[]> {
const dto = await api.teams.getAll();
return dto.teams.map(t => presentTeamSummary(t));
}
/**
* Team Service
*
* Orchestrates team operations by coordinating API calls and presentation logic.
* All dependencies are injected via constructor.
*/
export class TeamService {
constructor(
private readonly apiClient: TeamsApiClient,
private readonly teamListPresenter: TeamListPresenter,
private readonly teamDetailsPresenter: TeamDetailsPresenter,
private readonly teamMembersPresenter: TeamMembersPresenter
) {}
export async function getTeamDetails(teamId: string): Promise<TeamDetailsViewModel | null> {
const dto = await api.teams.getDetails(teamId);
return dto ? presentTeamDetails(dto) : null;
}
/**
* Get all teams with presentation transformation
*/
async getAllTeams(): Promise<TeamSummaryViewModel[]> {
const dto = await this.apiClient.getAll();
return this.teamListPresenter.present(dto);
}
export async function getTeamMembers(teamId: string): Promise<TeamMemberViewModel[]> {
const dto = await api.teams.getMembers(teamId);
return dto.members.map(m => presentTeamMember(m));
}
/**
* Get team details with presentation transformation
*/
async getTeamDetails(teamId: string, currentUserId: string): Promise<TeamDetailsViewModel | null> {
const dto = await this.apiClient.getDetails(teamId);
if (!dto) {
return null;
}
return this.teamDetailsPresenter.present(dto, currentUserId);
}
export async function createTeam(input: any): Promise<any> {
return await api.teams.create(input);
}
/**
* Get team members with presentation transformation
*/
async getTeamMembers(teamId: string, currentUserId: string, teamOwnerId: string): Promise<TeamMemberViewModel[]> {
const dto = await this.apiClient.getMembers(teamId);
return this.teamMembersPresenter.present(dto, currentUserId, teamOwnerId);
}
export async function updateTeam(teamId: string, input: any): Promise<any> {
return await api.teams.update(teamId, input);
}
/**
* Create a new team
*/
async createTeam(input: CreateTeamInputDto): Promise<CreateTeamOutputDto> {
return await this.apiClient.create(input);
}
export async function getDriverTeam(driverId: string): Promise<any> {
return await api.teams.getDriverTeam(driverId);
/**
* Update team
*/
async updateTeam(teamId: string, input: UpdateTeamInputDto): Promise<UpdateTeamOutputDto> {
return await this.apiClient.update(teamId, input);
}
/**
* Get driver's team
*/
async getDriverTeam(driverId: string): Promise<DriverTeamDto | null> {
return await this.apiClient.getDriverTeam(driverId);
}
}

View File

@@ -0,0 +1,10 @@
/**
* Avatar View Model
*
* Represents avatar information for the UI layer
*/
export interface AvatarViewModel {
driverId: string;
avatarUrl?: string;
hasAvatar: boolean;
}

View File

@@ -0,0 +1,8 @@
/**
* Complete onboarding view model
* UI representation of onboarding completion result
*/
export interface CompleteOnboardingViewModel {
driverId: string;
success: boolean;
}

View File

@@ -0,0 +1,9 @@
/**
* Delete Media View Model
*
* Represents the result of a media deletion operation
*/
export interface DeleteMediaViewModel {
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Driver view model
* UI representation of a driver
*/
export interface DriverViewModel {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
}

View File

@@ -0,0 +1,13 @@
/**
* Media View Model
*
* Represents media information for the UI layer
*/
export interface MediaViewModel {
id: string;
url: string;
type: 'image' | 'video' | 'document';
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
uploadedAt: Date;
size?: number;
}

View File

@@ -0,0 +1,10 @@
/**
* Request Avatar Generation View Model
*
* Represents the result of an avatar generation request
*/
export interface RequestAvatarGenerationViewModel {
success: boolean;
avatarUrl?: string;
error?: string;
}

View File

@@ -0,0 +1,41 @@
import type { SponsorDashboardDto } from '../dtos';
/**
* Sponsor Dashboard View Model
*
* View model for sponsor dashboard data with UI-specific transformations.
*/
export class SponsorDashboardViewModel implements SponsorDashboardDto {
sponsorId: string;
sponsorName: string;
totalSponsorships: number;
activeSponsorships: number;
totalInvestment: number;
constructor(dto: SponsorDashboardDto) {
Object.assign(this, dto);
}
/** UI-specific: Formatted total investment */
get formattedTotalInvestment(): string {
return `$${this.totalInvestment.toLocaleString()}`;
}
/** UI-specific: Active percentage */
get activePercentage(): number {
if (this.totalSponsorships === 0) return 0;
return Math.round((this.activeSponsorships / this.totalSponsorships) * 100);
}
/** UI-specific: Has sponsorships */
get hasSponsorships(): boolean {
return this.totalSponsorships > 0;
}
/** UI-specific: Status text */
get statusText(): string {
if (this.activeSponsorships === 0) return 'No active sponsorships';
if (this.activeSponsorships === this.totalSponsorships) return 'All sponsorships active';
return `${this.activeSponsorships} of ${this.totalSponsorships} active`;
}
}

View File

@@ -0,0 +1,50 @@
import type { SponsorSponsorshipsDto } from '../dtos';
import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
/**
* Sponsor Sponsorships View Model
*
* View model for sponsor sponsorships data with UI-specific transformations.
*/
export class SponsorSponsorshipsViewModel {
sponsorId: string;
sponsorName: string;
sponsorships: SponsorshipDetailViewModel[];
constructor(dto: SponsorSponsorshipsDto) {
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
this.sponsorships = dto.sponsorships.map(s => new SponsorshipDetailViewModel(s));
}
/** UI-specific: Total sponsorships count */
get totalCount(): number {
return this.sponsorships.length;
}
/** UI-specific: Active sponsorships */
get activeSponsorships(): SponsorshipDetailViewModel[] {
return this.sponsorships.filter(s => s.status === 'active');
}
/** UI-specific: Active count */
get activeCount(): number {
return this.activeSponsorships.length;
}
/** UI-specific: Has sponsorships */
get hasSponsorships(): boolean {
return this.sponsorships.length > 0;
}
/** UI-specific: Total investment */
get totalInvestment(): number {
return this.sponsorships.reduce((sum, s) => sum + s.amount, 0);
}
/** UI-specific: Formatted total investment */
get formattedTotalInvestment(): string {
const firstCurrency = this.sponsorships[0]?.currency || 'USD';
return `${firstCurrency} ${this.totalInvestment.toLocaleString()}`;
}
}

View File

@@ -0,0 +1,44 @@
import type { GetEntitySponsorshipPricingResultDto } from '../dtos';
/**
* Sponsorship Pricing View Model
*
* View model for sponsorship pricing data with UI-specific transformations.
*/
export class SponsorshipPricingViewModel {
mainSlotPrice: number;
secondarySlotPrice: number;
currency: string;
constructor(dto: GetEntitySponsorshipPricingResultDto) {
this.mainSlotPrice = dto.mainSlotPrice;
this.secondarySlotPrice = dto.secondarySlotPrice;
this.currency = dto.currency;
}
/** UI-specific: Formatted main slot price */
get formattedMainSlotPrice(): string {
return `${this.currency} ${this.mainSlotPrice.toLocaleString()}`;
}
/** UI-specific: Formatted secondary slot price */
get formattedSecondarySlotPrice(): string {
return `${this.currency} ${this.secondarySlotPrice.toLocaleString()}`;
}
/** UI-specific: Price difference */
get priceDifference(): number {
return this.mainSlotPrice - this.secondarySlotPrice;
}
/** UI-specific: Formatted price difference */
get formattedPriceDifference(): string {
return `${this.currency} ${this.priceDifference.toLocaleString()}`;
}
/** UI-specific: Discount percentage for secondary slot */
get secondaryDiscountPercentage(): number {
if (this.mainSlotPrice === 0) return 0;
return Math.round((1 - this.secondarySlotPrice / this.mainSlotPrice) * 100);
}
}

View File

@@ -0,0 +1,9 @@
/**
* Update Avatar View Model
*
* Represents the result of an avatar update operation
*/
export interface UpdateAvatarViewModel {
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Upload Media View Model
*
* Represents the result of a media upload operation
*/
export interface UploadMediaViewModel {
success: boolean;
mediaId?: string;
url?: string;
error?: string;
}

View File

@@ -32,7 +32,10 @@ export { RaceResultsDetailViewModel } from './RaceResultsDetailViewModel';
// Sponsor ViewModels
export { SponsorViewModel } from './SponsorViewModel';
export { SponsorDashboardViewModel } from './SponsorDashboardViewModel';
export { SponsorSponsorshipsViewModel } from './SponsorSponsorshipsViewModel';
export { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
export { SponsorshipPricingViewModel } from './SponsorshipPricingViewModel';
// Team ViewModels
export { TeamDetailsViewModel } from './TeamDetailsViewModel';