services refactor
This commit is contained in:
@@ -4,6 +4,8 @@ import type {
|
|||||||
RecordPageViewOutputDto,
|
RecordPageViewOutputDto,
|
||||||
RecordEngagementInputDto,
|
RecordEngagementInputDto,
|
||||||
RecordEngagementOutputDto,
|
RecordEngagementOutputDto,
|
||||||
|
AnalyticsDashboardDto,
|
||||||
|
AnalyticsMetricsDto,
|
||||||
} from '../../dtos';
|
} from '../../dtos';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,4 +23,14 @@ export class AnalyticsApiClient extends BaseApiClient {
|
|||||||
recordEngagement(input: RecordEngagementInputDto): Promise<RecordEngagementOutputDto> {
|
recordEngagement(input: RecordEngagementInputDto): Promise<RecordEngagementOutputDto> {
|
||||||
return this.post<RecordEngagementOutputDto>('/analytics/engagement', input);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CompleteOnboardingInputDto,
|
CompleteOnboardingInputDto,
|
||||||
CompleteOnboardingOutputDto,
|
CompleteOnboardingOutputDto,
|
||||||
DriverDto,
|
DriverDto,
|
||||||
|
DriverRegistrationStatusDto,
|
||||||
} from '../../dtos';
|
} from '../../dtos';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,4 +27,9 @@ export class DriversApiClient extends BaseApiClient {
|
|||||||
getCurrent(): Promise<DriverDto | null> {
|
getCurrent(): Promise<DriverDto | null> {
|
||||||
return this.get<DriverDto | null>('/drivers/current');
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,13 @@ import { BaseApiClient } from '../base/BaseApiClient';
|
|||||||
import type {
|
import type {
|
||||||
RequestAvatarGenerationInputDto,
|
RequestAvatarGenerationInputDto,
|
||||||
RequestAvatarGenerationOutputDto,
|
RequestAvatarGenerationOutputDto,
|
||||||
|
UploadMediaInputDto,
|
||||||
|
UploadMediaOutputDto,
|
||||||
|
GetMediaOutputDto,
|
||||||
|
DeleteMediaOutputDto,
|
||||||
|
GetAvatarOutputDto,
|
||||||
|
UpdateAvatarInputDto,
|
||||||
|
UpdateAvatarOutputDto,
|
||||||
} from '../../dtos';
|
} from '../../dtos';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,8 +17,39 @@ import type {
|
|||||||
* Handles all media-related API operations.
|
* Handles all media-related API operations.
|
||||||
*/
|
*/
|
||||||
export class MediaApiClient extends BaseApiClient {
|
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 */
|
/** Request avatar generation */
|
||||||
requestAvatarGeneration(input: RequestAvatarGenerationInputDto): Promise<RequestAvatarGenerationOutputDto> {
|
requestAvatarGeneration(input: RequestAvatarGenerationInputDto): Promise<RequestAvatarGenerationOutputDto> {
|
||||||
return this.post<RequestAvatarGenerationOutputDto>('/media/avatar/generate', input);
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,11 @@ import type {
|
|||||||
GetMembershipFeesOutputDto,
|
GetMembershipFeesOutputDto,
|
||||||
GetPrizesOutputDto,
|
GetPrizesOutputDto,
|
||||||
GetWalletOutputDto,
|
GetWalletOutputDto,
|
||||||
|
ProcessWalletTransactionInputDto,
|
||||||
|
ProcessWalletTransactionOutputDto,
|
||||||
|
UpdateMemberPaymentInputDto,
|
||||||
|
UpdateMemberPaymentOutputDto,
|
||||||
|
GetWalletTransactionsOutputDto,
|
||||||
} from '../../dtos';
|
} from '../../dtos';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
6
apps/website/lib/dtos/AnalyticsDashboardDto.ts
Normal file
6
apps/website/lib/dtos/AnalyticsDashboardDto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface AnalyticsDashboardDto {
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
totalRaces: number;
|
||||||
|
totalLeagues: number;
|
||||||
|
}
|
||||||
6
apps/website/lib/dtos/AnalyticsMetricsDto.ts
Normal file
6
apps/website/lib/dtos/AnalyticsMetricsDto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface AnalyticsMetricsDto {
|
||||||
|
pageViews: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
averageSessionDuration: number;
|
||||||
|
bounceRate: number;
|
||||||
|
}
|
||||||
8
apps/website/lib/dtos/DeleteMediaOutputDto.ts
Normal file
8
apps/website/lib/dtos/DeleteMediaOutputDto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Delete media output data transfer object
|
||||||
|
* Output from deleting media
|
||||||
|
*/
|
||||||
|
export interface DeleteMediaOutputDto {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
9
apps/website/lib/dtos/GetAvatarOutputDto.ts
Normal file
9
apps/website/lib/dtos/GetAvatarOutputDto.ts
Normal 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;
|
||||||
|
}
|
||||||
12
apps/website/lib/dtos/GetMediaOutputDto.ts
Normal file
12
apps/website/lib/dtos/GetMediaOutputDto.ts
Normal 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;
|
||||||
|
}
|
||||||
8
apps/website/lib/dtos/UpdateAvatarInputDto.ts
Normal file
8
apps/website/lib/dtos/UpdateAvatarInputDto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Update avatar input data transfer object
|
||||||
|
* Input for updating driver avatar
|
||||||
|
*/
|
||||||
|
export interface UpdateAvatarInputDto {
|
||||||
|
driverId: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
8
apps/website/lib/dtos/UpdateAvatarOutputDto.ts
Normal file
8
apps/website/lib/dtos/UpdateAvatarOutputDto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Update avatar output data transfer object
|
||||||
|
* Output from updating avatar
|
||||||
|
*/
|
||||||
|
export interface UpdateAvatarOutputDto {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
9
apps/website/lib/dtos/UploadMediaInputDto.ts
Normal file
9
apps/website/lib/dtos/UploadMediaInputDto.ts
Normal 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';
|
||||||
|
}
|
||||||
10
apps/website/lib/dtos/UploadMediaOutputDto.ts
Normal file
10
apps/website/lib/dtos/UploadMediaOutputDto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { AnalyticsDashboardDto } from '../dtos';
|
||||||
|
import { AnalyticsDashboardViewModel } from '../view-models';
|
||||||
|
|
||||||
|
export class AnalyticsDashboardPresenter {
|
||||||
|
present(dto: AnalyticsDashboardDto): AnalyticsDashboardViewModel {
|
||||||
|
return new AnalyticsDashboardViewModel(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/website/lib/presenters/AnalyticsMetricsPresenter.ts
Normal file
8
apps/website/lib/presenters/AnalyticsMetricsPresenter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/website/lib/presenters/AvatarPresenter.ts
Normal file
39
apps/website/lib/presenters/AvatarPresenter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/website/lib/presenters/CompleteOnboardingPresenter.ts
Normal file
15
apps/website/lib/presenters/CompleteOnboardingPresenter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/website/lib/presenters/DriverPresenter.ts
Normal file
18
apps/website/lib/presenters/DriverPresenter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import { DriverRegistrationStatusDto } from '../dtos';
|
import type { DriverRegistrationStatusDto } from '../dtos';
|
||||||
import { DriverRegistrationStatusViewModel } from '../view-models';
|
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 => {
|
export const presentDriverRegistrationStatus = (dto: DriverRegistrationStatusDto): DriverRegistrationStatusViewModel => {
|
||||||
return new DriverRegistrationStatusViewModel(dto);
|
const presenter = new DriverRegistrationStatusPresenter();
|
||||||
|
return presenter.present(dto);
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { DriversLeaderboardDto, DriverLeaderboardItemDto } from '../dtos';
|
import type { DriversLeaderboardDto } from '../dtos';
|
||||||
import { DriverLeaderboardViewModel } from '../view-models';
|
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);
|
||||||
};
|
};
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import type {
|
import type { ImportRaceResultsSummaryDto } from '../dtos/ImportRaceResultsSummaryDto';
|
||||||
IImportRaceResultsPresenter,
|
|
||||||
ImportRaceResultsSummaryViewModel,
|
|
||||||
} from '@core/racing/application/presenters/IImportRaceResultsPresenter';
|
|
||||||
|
|
||||||
export class ImportRaceResultsPresenter implements IImportRaceResultsPresenter {
|
export interface ImportRaceResultsSummaryViewModel {
|
||||||
private viewModel: ImportRaceResultsSummaryViewModel | null = null;
|
success: boolean;
|
||||||
|
raceId: string;
|
||||||
|
driversProcessed: number;
|
||||||
|
resultsRecorded: number;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel {
|
export class ImportRaceResultsPresenter {
|
||||||
this.viewModel = viewModel;
|
present(dto: ImportRaceResultsSummaryDto): ImportRaceResultsSummaryViewModel {
|
||||||
return this.viewModel;
|
return {
|
||||||
}
|
success: dto.success,
|
||||||
|
raceId: dto.raceId,
|
||||||
getViewModel(): ImportRaceResultsSummaryViewModel | null {
|
driversProcessed: dto.driversProcessed,
|
||||||
return this.viewModel;
|
resultsRecorded: dto.resultsRecorded,
|
||||||
|
errors: dto.errors,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
16
apps/website/lib/presenters/LeagueMembersPresenter.ts
Normal file
16
apps/website/lib/presenters/LeagueMembersPresenter.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { LeagueStandingsDto, StandingEntryDto } from '../dtos';
|
import type { LeagueStandingsDto, StandingEntryDto } from '../dtos';
|
||||||
import { LeagueStandingsViewModel } from '../view-models';
|
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 => {
|
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);
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { LeagueSummaryDto } from '../dtos';
|
import type { AllLeaguesWithCapacityDto } from '../dtos';
|
||||||
import { LeagueSummaryViewModel } from '../view-models';
|
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);
|
return new LeagueSummaryViewModel(dto);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const presentLeagueSummaries = (dtos: LeagueSummaryDto[]): LeagueSummaryViewModel[] => {
|
export const presentLeagueSummaries = (dtos: any[]): LeagueSummaryViewModel[] => {
|
||||||
return dtos.map(presentLeagueSummary);
|
return dtos.map(presentLeagueSummary);
|
||||||
};
|
};
|
||||||
35
apps/website/lib/presenters/MediaPresenter.ts
Normal file
35
apps/website/lib/presenters/MediaPresenter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/website/lib/presenters/PaymentListPresenter.ts
Normal file
17
apps/website/lib/presenters/PaymentListPresenter.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
import type {
|
import type { RaceWithSOFDto } from '../dtos/RaceWithSOFDto';
|
||||||
IRaceWithSOFPresenter,
|
|
||||||
RaceWithSOFResultDTO,
|
|
||||||
RaceWithSOFViewModel,
|
|
||||||
} from '@core/racing/application/presenters/IRaceWithSOFPresenter';
|
|
||||||
|
|
||||||
export class RaceWithSOFPresenter implements IRaceWithSOFPresenter {
|
export interface RaceWithSOFViewModel {
|
||||||
present(dto: RaceWithSOFResultDTO): RaceWithSOFViewModel {
|
id: string;
|
||||||
|
track: string;
|
||||||
|
strengthOfField: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RaceWithSOFPresenter {
|
||||||
|
present(dto: RaceWithSOFDto): RaceWithSOFViewModel {
|
||||||
return {
|
return {
|
||||||
id: dto.raceId,
|
id: dto.id,
|
||||||
leagueId: dto.leagueId,
|
|
||||||
scheduledAt: dto.scheduledAt.toISOString(),
|
|
||||||
track: dto.track,
|
track: dto.track,
|
||||||
trackId: dto.trackId,
|
|
||||||
car: dto.car,
|
|
||||||
carId: dto.carId,
|
|
||||||
sessionType: dto.sessionType,
|
|
||||||
status: dto.status,
|
|
||||||
strengthOfField: dto.strengthOfField,
|
strengthOfField: dto.strengthOfField,
|
||||||
registeredCount: dto.registeredCount,
|
|
||||||
maxParticipants: dto.maxParticipants,
|
|
||||||
participantCount: dto.participantCount,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
apps/website/lib/presenters/SessionPresenter.ts
Normal file
13
apps/website/lib/presenters/SessionPresenter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,13 @@
|
|||||||
import type {
|
import type { SponsorDashboardDto } from '../dtos';
|
||||||
ISponsorDashboardPresenter,
|
import { SponsorDashboardViewModel } from '../view-models/SponsorDashboardViewModel';
|
||||||
SponsorDashboardViewModel,
|
|
||||||
} from '@core/racing/application/presenters/ISponsorDashboardPresenter';
|
|
||||||
import type { SponsorDashboardDTO } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
|
|
||||||
|
|
||||||
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
|
/**
|
||||||
private viewModel: SponsorDashboardViewModel = null;
|
* Sponsor Dashboard Presenter
|
||||||
|
*
|
||||||
reset(): void {
|
* Transforms sponsor dashboard DTOs into view models.
|
||||||
this.viewModel = null;
|
*/
|
||||||
}
|
export class SponsorDashboardPresenter {
|
||||||
|
present(dto: SponsorDashboardDto): SponsorDashboardViewModel {
|
||||||
present(data: SponsorDashboardDTO | null): void {
|
return new SponsorDashboardViewModel(dto);
|
||||||
this.viewModel = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
getViewModel(): SponsorDashboardViewModel {
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
getData(): SponsorDashboardDTO | null {
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
apps/website/lib/presenters/SponsorListPresenter.ts
Normal file
13
apps/website/lib/presenters/SponsorListPresenter.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { SponsorDto } from '../dtos';
|
import type { SponsorDto } from '../dtos';
|
||||||
import { SponsorViewModel } from '../view-models';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,13 @@
|
|||||||
import type {
|
import type { SponsorSponsorshipsDto } from '../dtos';
|
||||||
ISponsorSponsorshipsPresenter,
|
import { SponsorSponsorshipsViewModel } from '../view-models/SponsorSponsorshipsViewModel';
|
||||||
SponsorSponsorshipsViewModel,
|
|
||||||
} from '@core/racing/application/presenters/ISponsorSponsorshipsPresenter';
|
|
||||||
import type { SponsorSponsorshipsDTO } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
|
||||||
|
|
||||||
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
|
/**
|
||||||
private viewModel: SponsorSponsorshipsViewModel = null;
|
* Sponsor Sponsorships Presenter
|
||||||
|
*
|
||||||
reset(): void {
|
* Transforms sponsor sponsorships DTOs into view models.
|
||||||
this.viewModel = null;
|
*/
|
||||||
}
|
export class SponsorSponsorshipsPresenter {
|
||||||
|
present(dto: SponsorSponsorshipsDto): SponsorSponsorshipsViewModel {
|
||||||
present(data: SponsorSponsorshipsDTO | null): void {
|
return new SponsorSponsorshipsViewModel(dto);
|
||||||
this.viewModel = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
getViewModel(): SponsorSponsorshipsViewModel {
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
getData(): SponsorSponsorshipsDTO | null {
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
apps/website/lib/presenters/SponsorshipPricingPresenter.ts
Normal file
13
apps/website/lib/presenters/SponsorshipPricingPresenter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { TeamDetailsDto, TeamMemberDto } from '../dtos';
|
import type { TeamDetailsDto } from '../dtos';
|
||||||
import { TeamDetailsViewModel } from '../view-models';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { TeamJoinRequestItemDto } from '../dtos';
|
import type { TeamJoinRequestItemDto } from '../dtos';
|
||||||
import { TeamJoinRequestViewModel } from '../view-models';
|
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 => {
|
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);
|
||||||
};
|
};
|
||||||
12
apps/website/lib/presenters/TeamListPresenter.ts
Normal file
12
apps/website/lib/presenters/TeamListPresenter.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,12 @@
|
|||||||
import type {
|
import type { TeamMembersDto } from '../dtos';
|
||||||
ITeamMembersPresenter,
|
import { TeamMemberViewModel } from '../view-models';
|
||||||
TeamMemberViewModel,
|
|
||||||
TeamMembersViewModel,
|
|
||||||
TeamMembersResultDTO,
|
|
||||||
} from '@core/racing/application/presenters/ITeamMembersPresenter';
|
|
||||||
|
|
||||||
export class TeamMembersPresenter implements ITeamMembersPresenter {
|
/**
|
||||||
private viewModel: TeamMembersViewModel | null = null;
|
* Team Members Presenter
|
||||||
|
* Transforms TeamMembersDto to array of TeamMemberViewModel
|
||||||
reset(): void {
|
*/
|
||||||
this.viewModel = null;
|
export class TeamMembersPresenter {
|
||||||
}
|
present(dto: TeamMembersDto, currentUserId: string, teamOwnerId: string): TeamMemberViewModel[] {
|
||||||
|
return dto.members.map(member => new TeamMemberViewModel(member, currentUserId, teamOwnerId));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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';
|
|
||||||
517
apps/website/lib/services/ServiceFactory.test.ts
Normal file
517
apps/website/lib/services/ServiceFactory.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
346
apps/website/lib/services/ServiceFactory.ts
Normal file
346
apps/website/lib/services/ServiceFactory.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
193
apps/website/lib/services/analytics/AnalyticsService.test.ts
Normal file
193
apps/website/lib/services/analytics/AnalyticsService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
227
apps/website/lib/services/analytics/DashboardService.test.ts
Normal file
227
apps/website/lib/services/analytics/DashboardService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* Dashboard Service
|
||||||
return {};
|
*
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
204
apps/website/lib/services/auth/AuthService.test.ts
Normal file
204
apps/website/lib/services/auth/AuthService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
138
apps/website/lib/services/auth/SessionService.test.ts
Normal file
138
apps/website/lib/services/auth/SessionService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,28 @@
|
|||||||
import { api as api } from '../../api';
|
import { AuthApiClient } from '../../api/auth/AuthApiClient';
|
||||||
import { SessionViewModel } from '../../view-models';
|
import { SessionPresenter } from '../../presenters/SessionPresenter';
|
||||||
|
import type { SessionViewModel } from '../../view-models';
|
||||||
|
|
||||||
export async function getSession(): Promise<SessionViewModel | null> {
|
/**
|
||||||
const dto = await api.auth.getSession();
|
* Session Service
|
||||||
if (!dto) return null;
|
*
|
||||||
// TODO: presenter
|
* Orchestrates session operations by coordinating API calls and presentation logic.
|
||||||
return dto as any;
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,27 @@
|
|||||||
import { api as api } from '../../api';
|
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
|
||||||
import { presentDriverRegistrationStatus } from '../../presenters';
|
import type { DriverRegistrationStatusPresenter } from '../../presenters/DriverRegistrationStatusPresenter';
|
||||||
import { DriverRegistrationStatusViewModel } from '../../view-models';
|
import type { DriverRegistrationStatusViewModel } from '../../view-models';
|
||||||
|
|
||||||
export async function getDriverRegistrationStatus(driverId: string): Promise<DriverRegistrationStatusViewModel> {
|
/**
|
||||||
// TODO: implement API call
|
* Driver Registration Service
|
||||||
const dto = { driverId, status: 'pending' };
|
*
|
||||||
return presentDriverRegistrationStatus(dto);
|
* 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
|
* Get driver registration status for a specific race
|
||||||
return {};
|
*/
|
||||||
|
async getDriverRegistrationStatus(
|
||||||
|
driverId: string,
|
||||||
|
raceId: string
|
||||||
|
): Promise<DriverRegistrationStatusViewModel> {
|
||||||
|
const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
|
||||||
|
return this.statusPresenter.present(dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
296
apps/website/lib/services/drivers/DriverService.test.ts
Normal file
296
apps/website/lib/services/drivers/DriverService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,43 +1,50 @@
|
|||||||
import { api as api } from '../../api';
|
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
|
||||||
import { presentDriversLeaderboard } from '../../presenters';
|
import type { DriversLeaderboardPresenter } from '../../presenters/DriversLeaderboardPresenter';
|
||||||
import { DriverLeaderboardViewModel } from '../../view-models';
|
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
|
* 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 {
|
export class DriverService {
|
||||||
constructor(
|
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> {
|
async getDriverLeaderboard(): Promise<DriverLeaderboardViewModel> {
|
||||||
const dto = await this.apiClient.getLeaderboard();
|
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();
|
|
||||||
}
|
}
|
||||||
@@ -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';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
import { api as api } from '../../api';
|
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||||
import { presentLeagueMember } from '../../presenters';
|
import type { LeagueMemberViewModel } from '../../view-models';
|
||||||
import { 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);
|
* League Membership Service
|
||||||
return dto.members.map(m => presentLeagueMember(m, currentUserId));
|
*
|
||||||
}
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
448
apps/website/lib/services/leagues/LeagueService.test.ts
Normal file
448
apps/website/lib/services/leagues/LeagueService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,28 +1,76 @@
|
|||||||
import { api as api } from '../../api';
|
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||||
import { presentLeagueSummaries, presentLeagueStandings } from '../../presenters';
|
import type { LeagueSummaryPresenter } from '../../presenters/LeagueSummaryPresenter';
|
||||||
import { LeagueSummaryViewModel, LeagueStandingsViewModel } from '../../view-models';
|
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();
|
* League Service
|
||||||
return presentLeagueSummaries(dto.leagues);
|
*
|
||||||
}
|
* 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);
|
* Get all leagues with presentation transformation
|
||||||
// TODO: include drivers and memberships in dto
|
*/
|
||||||
const dtoWithExtras = {
|
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
|
||||||
...dto,
|
const dto = await this.apiClient.getAllWithCapacity();
|
||||||
drivers: [], // TODO: fetch drivers
|
return this.leagueSummaryPresenter.present(dto);
|
||||||
memberships: [], // TODO: fetch memberships
|
}
|
||||||
};
|
|
||||||
return presentLeagueStandings(dtoWithExtras, currentUserId || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
* Get league statistics
|
||||||
return {};
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
259
apps/website/lib/services/media/AvatarService.test.ts
Normal file
259
apps/website/lib/services/media/AvatarService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
261
apps/website/lib/services/media/MediaService.test.ts
Normal file
261
apps/website/lib/services/media/MediaService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* Media Service
|
||||||
return {};
|
*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,27 @@
|
|||||||
import { api as api } from '../../api';
|
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
|
||||||
import { presentMembershipFee } from '../../presenters';
|
import { presentMembershipFee } from '../../presenters/MembershipFeePresenter';
|
||||||
import { MembershipFeeViewModel } from '../../view-models';
|
import type { MembershipFeeViewModel } from '../../view-models';
|
||||||
|
|
||||||
export async function getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
|
/**
|
||||||
const dto = await api.payments.getMembershipFees(leagueId);
|
* Membership Fee Service
|
||||||
return dto.fees.map(f => presentMembershipFee(f));
|
*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
506
apps/website/lib/services/payments/PaymentService.test.ts
Normal file
506
apps/website/lib/services/payments/PaymentService.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,127 @@
|
|||||||
import { api as api } from '../../api';
|
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
|
||||||
import { presentPayment } from '../../presenters';
|
import type { PaymentListPresenter } from '../../presenters/PaymentListPresenter';
|
||||||
import { PaymentViewModel } from '../../view-models';
|
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);
|
* Payment Service
|
||||||
return dto.payments.map(p => presentPayment(p));
|
*
|
||||||
}
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
131
apps/website/lib/services/payments/WalletService.test.ts
Normal file
131
apps/website/lib/services/payments/WalletService.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,27 @@
|
|||||||
import { api as api } from '../../api';
|
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
|
||||||
import { presentWallet } from '../../presenters';
|
import { presentWallet } from '../../presenters/WalletPresenter';
|
||||||
import { WalletViewModel } from '../../view-models';
|
import type { WalletViewModel } from '../../view-models';
|
||||||
|
|
||||||
export async function getWallet(driverId: string): Promise<WalletViewModel> {
|
/**
|
||||||
const dto = await api.payments.getWallet(driverId);
|
* Wallet Service
|
||||||
return presentWallet(dto);
|
*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,121 +1,256 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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 { 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
|
describe('RaceResultsService', () => {
|
||||||
vi.mock('../../api', () => ({
|
let service: RaceResultsService;
|
||||||
apiClient: {
|
let mockApiClient: RacesApiClient;
|
||||||
races: {
|
let mockResultsDetailPresenter: RaceResultsDetailPresenter;
|
||||||
|
let mockSOFPresenter: RaceWithSOFPresenter;
|
||||||
|
let mockImportPresenter: ImportRaceResultsPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiClient = {
|
||||||
getResultsDetail: vi.fn(),
|
getResultsDetail: vi.fn(),
|
||||||
getWithSOF: vi.fn(),
|
getWithSOF: vi.fn(),
|
||||||
importResults: vi.fn(),
|
importResults: vi.fn(),
|
||||||
},
|
} as unknown as RacesApiClient;
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the presenter
|
mockResultsDetailPresenter = {
|
||||||
vi.mock('../../presenters', () => ({
|
present: vi.fn(),
|
||||||
presentRaceResultsDetail: vi.fn(),
|
} as unknown as RaceResultsDetailPresenter;
|
||||||
}));
|
|
||||||
|
|
||||||
import { api } from '../../api';
|
mockSOFPresenter = {
|
||||||
import { presentRaceResultsDetail } from '../../presenters';
|
present: vi.fn(),
|
||||||
|
} as unknown as RaceWithSOFPresenter;
|
||||||
|
|
||||||
describe('RaceResultsService', () => {
|
mockImportPresenter = {
|
||||||
beforeEach(() => {
|
present: vi.fn(),
|
||||||
vi.clearAllMocks();
|
} as unknown as ImportRaceResultsPresenter;
|
||||||
|
|
||||||
|
service = new RaceResultsService(
|
||||||
|
mockApiClient,
|
||||||
|
mockResultsDetailPresenter,
|
||||||
|
mockSOFPresenter,
|
||||||
|
mockImportPresenter
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getRaceResults', () => {
|
describe('getResultsDetail', () => {
|
||||||
it('should call API and presenter with correct parameters', async () => {
|
it('should fetch race results detail and transform via presenter', async () => {
|
||||||
const mockDto: RaceResultsDetailDto = {
|
// Arrange
|
||||||
id: 'race-1',
|
|
||||||
name: 'Test Race',
|
|
||||||
results: [],
|
|
||||||
// ... other required fields
|
|
||||||
} as RaceResultsDetailDto;
|
|
||||||
|
|
||||||
const mockViewModel = {
|
|
||||||
id: 'race-1',
|
|
||||||
name: 'Test Race',
|
|
||||||
formattedResults: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const raceId = 'race-123';
|
const raceId = 'race-123';
|
||||||
const currentUserId = 'user-456';
|
const currentUserId = 'user-456';
|
||||||
|
const mockDto: Partial<RaceResultsDetailDto> = {
|
||||||
// Mock API call
|
raceId,
|
||||||
vi.mocked(api.races.getResultsDetail).mockResolvedValue(mockDto);
|
results: [],
|
||||||
// Mock presenter
|
};
|
||||||
vi.mocked(presentRaceResultsDetail).mockReturnValue(mockViewModel);
|
const mockViewModel: Partial<RaceResultsDetailViewModel> = {
|
||||||
|
raceId,
|
||||||
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',
|
|
||||||
results: [],
|
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 raceId = 'race-123';
|
||||||
|
const mockDto: Partial<RaceResultsDetailDto> = {
|
||||||
|
raceId,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
const mockViewModel: Partial<RaceResultsDetailViewModel> = {
|
||||||
|
raceId,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
|
||||||
vi.mocked(api.races.getResultsDetail).mockResolvedValue(mockDto);
|
vi.mocked(mockApiClient.getResultsDetail).mockResolvedValue(mockDto as RaceResultsDetailDto);
|
||||||
vi.mocked(presentRaceResultsDetail).mockReturnValue(mockViewModel);
|
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', () => {
|
describe('getWithSOF', () => {
|
||||||
it('should call API and return DTO directly', async () => {
|
it('should fetch race with SOF and transform via presenter', async () => {
|
||||||
|
// Arrange
|
||||||
|
const raceId = 'race-123';
|
||||||
const mockDto: RaceWithSOFDto = {
|
const mockDto: RaceWithSOFDto = {
|
||||||
id: 'race-1',
|
id: raceId,
|
||||||
name: 'Test Race',
|
track: 'Spa-Francorchamps',
|
||||||
sof: 1500,
|
strengthOfField: 2500,
|
||||||
// ... other fields
|
};
|
||||||
} as RaceWithSOFDto;
|
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 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);
|
// Assert
|
||||||
expect(result).toBe(mockDto);
|
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', () => {
|
describe('importResults', () => {
|
||||||
it('should call API with correct parameters and return result', async () => {
|
it('should import race results and transform via presenter', async () => {
|
||||||
const mockInput = { results: [] };
|
// Arrange
|
||||||
const mockSummary: ImportRaceResultsSummaryDto = {
|
const raceId = 'race-123';
|
||||||
totalImported: 10,
|
const input = {
|
||||||
errors: [],
|
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 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);
|
// Assert
|
||||||
expect(result).toBe(mockSummary);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,46 +1,47 @@
|
|||||||
import { api as api } from '../../api';
|
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||||
import { RaceResultsDetailPresenter, RaceWithSOFPresenter, ImportRaceResultsPresenter } from '../../presenters';
|
import { RaceResultsDetailPresenter } from '../../presenters/RaceResultsDetailPresenter';
|
||||||
import { RaceResultsDetailViewModel } from '../../view-models';
|
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 {
|
export class RaceResultsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly apiClient = api.races,
|
private readonly apiClient: RacesApiClient,
|
||||||
private readonly resultsDetailPresenter = new RaceResultsDetailPresenter(),
|
private readonly resultsDetailPresenter: RaceResultsDetailPresenter,
|
||||||
private readonly sofPresenter = new RaceWithSOFPresenter(),
|
private readonly sofPresenter: RaceWithSOFPresenter,
|
||||||
private readonly importPresenter = new ImportRaceResultsPresenter()
|
private readonly importPresenter: ImportRaceResultsPresenter
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async importRaceResults(raceId: string, input: any): Promise<any> {
|
/**
|
||||||
const dto = await this.apiClient.importResults(raceId, input);
|
* Get race results detail with presentation transformation
|
||||||
return this.importPresenter.present(dto);
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
|
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
|
||||||
const dto = await this.apiClient.getResultsDetail(raceId);
|
const dto = await this.apiClient.getResultsDetail(raceId);
|
||||||
return this.resultsDetailPresenter.present(dto, currentUserId);
|
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);
|
const dto = await this.apiClient.getWithSOF(raceId);
|
||||||
return this.sofPresenter.present(dto);
|
return this.sofPresenter.present(dto);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
/**
|
||||||
export const raceResultsService = new RaceResultsService();
|
* Import race results and get summary
|
||||||
|
*/
|
||||||
// Backward compatibility functions
|
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
|
||||||
export async function getRaceResults(
|
const dto = await this.apiClient.importResults(raceId, input);
|
||||||
raceId: string,
|
return this.importPresenter.present(dto);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
235
apps/website/lib/services/races/RaceService.test.ts
Normal file
235
apps/website/lib/services/races/RaceService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,48 +1,44 @@
|
|||||||
import { api as api } from '../../api';
|
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||||
import { RaceDetailPresenter } from '../../presenters';
|
import { RaceDetailPresenter } from '../../presenters/RaceDetailPresenter';
|
||||||
import { RaceDetailViewModel } from '../../view-models';
|
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 {
|
export class RaceService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly apiClient = api.races,
|
private readonly apiClient: RacesApiClient,
|
||||||
private readonly presenter = new RaceDetailPresenter()
|
private readonly raceDetailPresenter: RaceDetailPresenter
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get race detail with presentation transformation
|
||||||
|
*/
|
||||||
async getRaceDetail(
|
async getRaceDetail(
|
||||||
raceId: string,
|
raceId: string,
|
||||||
driverId: string
|
driverId: string
|
||||||
): Promise<RaceDetailViewModel> {
|
): Promise<RaceDetailViewModel> {
|
||||||
const dto = await this.apiClient.getDetail(raceId, driverId);
|
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();
|
* Get races page data
|
||||||
// TODO: use presenter
|
* TODO: Add presenter transformation when presenter is available
|
||||||
return dto;
|
*/
|
||||||
|
async getRacesPageData(): Promise<RacesPageDataDto> {
|
||||||
|
return this.apiClient.getPageData();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRacesTotal(): Promise<any> {
|
/**
|
||||||
const dto = await this.apiClient.getTotal();
|
* Get total races statistics
|
||||||
return dto;
|
* 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();
|
|
||||||
}
|
}
|
||||||
@@ -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';
|
|
||||||
347
apps/website/lib/services/sponsors/SponsorService.test.ts
Normal file
347
apps/website/lib/services/sponsors/SponsorService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,65 @@
|
|||||||
import { api as api } from '../../api';
|
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
|
||||||
import { presentSponsor } from '../../presenters';
|
import type { SponsorListPresenter } from '../../presenters/SponsorListPresenter';
|
||||||
import { SponsorViewModel } from '../../view-models';
|
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();
|
* Sponsor Service
|
||||||
return dto.sponsors.map(s => presentSponsor(s));
|
*
|
||||||
}
|
* 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);
|
* Get sponsor dashboard with presentation transformation
|
||||||
return dto;
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
334
apps/website/lib/services/sponsors/SponsorshipService.test.ts
Normal file
334
apps/website/lib/services/sponsors/SponsorshipService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
* Sponsorship Service
|
||||||
return dto;
|
*
|
||||||
}
|
* 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);
|
* Get sponsorship pricing with presentation transformation
|
||||||
return dto;
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
254
apps/website/lib/services/teams/TeamJoinService.test.ts
Normal file
254
apps/website/lib/services/teams/TeamJoinService.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,52 @@
|
|||||||
import { api as api } from '../../api';
|
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
|
||||||
import { presentTeamJoinRequest } from '../../presenters';
|
import type { TeamJoinRequestPresenter } from '../../presenters/TeamJoinRequestPresenter';
|
||||||
import { TeamJoinRequestViewModel } from '../../view-models';
|
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);
|
* Team Join Service
|
||||||
return dto.requests.map(r => presentTeamJoinRequest(r, currentUserId, isOwner));
|
*
|
||||||
}
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
422
apps/website/lib/services/teams/TeamService.test.ts
Normal file
422
apps/website/lib/services/teams/TeamService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,69 @@
|
|||||||
import { api as api } from '../../api';
|
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
|
||||||
import { presentTeamDetails, presentTeamMember, presentTeamSummary } from '../../presenters';
|
import type { TeamDetailsPresenter } from '../../presenters/TeamDetailsPresenter';
|
||||||
import { TeamDetailsViewModel, TeamMemberViewModel, TeamSummaryViewModel } from '../../view-models';
|
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();
|
* Team Service
|
||||||
return dto.teams.map(t => presentTeamSummary(t));
|
*
|
||||||
}
|
* 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);
|
* Get all teams with presentation transformation
|
||||||
return dto ? presentTeamDetails(dto) : null;
|
*/
|
||||||
}
|
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);
|
* Get team details with presentation transformation
|
||||||
return dto.members.map(m => presentTeamMember(m));
|
*/
|
||||||
}
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
10
apps/website/lib/view-models/AvatarViewModel.ts
Normal file
10
apps/website/lib/view-models/AvatarViewModel.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Avatar View Model
|
||||||
|
*
|
||||||
|
* Represents avatar information for the UI layer
|
||||||
|
*/
|
||||||
|
export interface AvatarViewModel {
|
||||||
|
driverId: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Complete onboarding view model
|
||||||
|
* UI representation of onboarding completion result
|
||||||
|
*/
|
||||||
|
export interface CompleteOnboardingViewModel {
|
||||||
|
driverId: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
9
apps/website/lib/view-models/DeleteMediaViewModel.ts
Normal file
9
apps/website/lib/view-models/DeleteMediaViewModel.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Delete Media View Model
|
||||||
|
*
|
||||||
|
* Represents the result of a media deletion operation
|
||||||
|
*/
|
||||||
|
export interface DeleteMediaViewModel {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
11
apps/website/lib/view-models/DriverViewModel.ts
Normal file
11
apps/website/lib/view-models/DriverViewModel.ts
Normal 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;
|
||||||
|
}
|
||||||
13
apps/website/lib/view-models/MediaViewModel.ts
Normal file
13
apps/website/lib/view-models/MediaViewModel.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
41
apps/website/lib/view-models/SponsorDashboardViewModel.ts
Normal file
41
apps/website/lib/view-models/SponsorDashboardViewModel.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts
Normal file
50
apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts
Normal 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()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/website/lib/view-models/SponsorshipPricingViewModel.ts
Normal file
44
apps/website/lib/view-models/SponsorshipPricingViewModel.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/website/lib/view-models/UpdateAvatarViewModel.ts
Normal file
9
apps/website/lib/view-models/UpdateAvatarViewModel.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Update Avatar View Model
|
||||||
|
*
|
||||||
|
* Represents the result of an avatar update operation
|
||||||
|
*/
|
||||||
|
export interface UpdateAvatarViewModel {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
11
apps/website/lib/view-models/UploadMediaViewModel.ts
Normal file
11
apps/website/lib/view-models/UploadMediaViewModel.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -32,7 +32,10 @@ export { RaceResultsDetailViewModel } from './RaceResultsDetailViewModel';
|
|||||||
|
|
||||||
// Sponsor ViewModels
|
// Sponsor ViewModels
|
||||||
export { SponsorViewModel } from './SponsorViewModel';
|
export { SponsorViewModel } from './SponsorViewModel';
|
||||||
|
export { SponsorDashboardViewModel } from './SponsorDashboardViewModel';
|
||||||
|
export { SponsorSponsorshipsViewModel } from './SponsorSponsorshipsViewModel';
|
||||||
export { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
|
export { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
|
||||||
|
export { SponsorshipPricingViewModel } from './SponsorshipPricingViewModel';
|
||||||
|
|
||||||
// Team ViewModels
|
// Team ViewModels
|
||||||
export { TeamDetailsViewModel } from './TeamDetailsViewModel';
|
export { TeamDetailsViewModel } from './TeamDetailsViewModel';
|
||||||
|
|||||||
Reference in New Issue
Block a user