services refactor
This commit is contained in:
@@ -4,6 +4,8 @@ import type {
|
||||
RecordPageViewOutputDto,
|
||||
RecordEngagementInputDto,
|
||||
RecordEngagementOutputDto,
|
||||
AnalyticsDashboardDto,
|
||||
AnalyticsMetricsDto,
|
||||
} from '../../dtos';
|
||||
|
||||
/**
|
||||
@@ -21,4 +23,14 @@ export class AnalyticsApiClient extends BaseApiClient {
|
||||
recordEngagement(input: RecordEngagementInputDto): Promise<RecordEngagementOutputDto> {
|
||||
return this.post<RecordEngagementOutputDto>('/analytics/engagement', input);
|
||||
}
|
||||
|
||||
/** Get analytics dashboard data */
|
||||
getDashboardData(): Promise<AnalyticsDashboardDto> {
|
||||
return this.get<AnalyticsDashboardDto>('/analytics/dashboard');
|
||||
}
|
||||
|
||||
/** Get analytics metrics */
|
||||
getAnalyticsMetrics(): Promise<AnalyticsMetricsDto> {
|
||||
return this.get<AnalyticsMetricsDto>('/analytics/metrics');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CompleteOnboardingInputDto,
|
||||
CompleteOnboardingOutputDto,
|
||||
DriverDto,
|
||||
DriverRegistrationStatusDto,
|
||||
} from '../../dtos';
|
||||
|
||||
/**
|
||||
@@ -26,4 +27,9 @@ export class DriversApiClient extends BaseApiClient {
|
||||
getCurrent(): Promise<DriverDto | null> {
|
||||
return this.get<DriverDto | null>('/drivers/current');
|
||||
}
|
||||
|
||||
/** Get driver registration status for a specific race */
|
||||
getRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusDto> {
|
||||
return this.get<DriverRegistrationStatusDto>(`/drivers/${driverId}/races/${raceId}/registration-status`);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { BaseApiClient } from '../base/BaseApiClient';
|
||||
import type {
|
||||
RequestAvatarGenerationInputDto,
|
||||
RequestAvatarGenerationOutputDto,
|
||||
UploadMediaInputDto,
|
||||
UploadMediaOutputDto,
|
||||
GetMediaOutputDto,
|
||||
DeleteMediaOutputDto,
|
||||
GetAvatarOutputDto,
|
||||
UpdateAvatarInputDto,
|
||||
UpdateAvatarOutputDto,
|
||||
} from '../../dtos';
|
||||
|
||||
/**
|
||||
@@ -10,8 +17,39 @@ import type {
|
||||
* Handles all media-related API operations.
|
||||
*/
|
||||
export class MediaApiClient extends BaseApiClient {
|
||||
/** Upload media file */
|
||||
uploadMedia(input: UploadMediaInputDto): Promise<UploadMediaOutputDto> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', input.file);
|
||||
formData.append('type', input.type);
|
||||
if (input.category) {
|
||||
formData.append('category', input.category);
|
||||
}
|
||||
return this.post<UploadMediaOutputDto>('/media/upload', formData);
|
||||
}
|
||||
|
||||
/** Get media by ID */
|
||||
getMedia(mediaId: string): Promise<GetMediaOutputDto> {
|
||||
return this.get<GetMediaOutputDto>(`/media/${mediaId}`);
|
||||
}
|
||||
|
||||
/** Delete media by ID */
|
||||
deleteMedia(mediaId: string): Promise<DeleteMediaOutputDto> {
|
||||
return this.delete<DeleteMediaOutputDto>(`/media/${mediaId}`);
|
||||
}
|
||||
|
||||
/** Request avatar generation */
|
||||
requestAvatarGeneration(input: RequestAvatarGenerationInputDto): Promise<RequestAvatarGenerationOutputDto> {
|
||||
return this.post<RequestAvatarGenerationOutputDto>('/media/avatar/generate', input);
|
||||
}
|
||||
|
||||
/** Get avatar for driver */
|
||||
getAvatar(driverId: string): Promise<GetAvatarOutputDto> {
|
||||
return this.get<GetAvatarOutputDto>(`/media/avatar/${driverId}`);
|
||||
}
|
||||
|
||||
/** Update avatar for driver */
|
||||
updateAvatar(input: UpdateAvatarInputDto): Promise<UpdateAvatarOutputDto> {
|
||||
return this.put<UpdateAvatarOutputDto>(`/media/avatar/${input.driverId}`, { avatarUrl: input.avatarUrl });
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
GetMembershipFeesOutputDto,
|
||||
GetPrizesOutputDto,
|
||||
GetWalletOutputDto,
|
||||
ProcessWalletTransactionInputDto,
|
||||
ProcessWalletTransactionOutputDto,
|
||||
UpdateMemberPaymentInputDto,
|
||||
UpdateMemberPaymentOutputDto,
|
||||
GetWalletTransactionsOutputDto,
|
||||
} 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 { DriverRegistrationStatusViewModel } from '../view-models';
|
||||
import type { DriverRegistrationStatusDto } from '../dtos';
|
||||
import type { DriverRegistrationStatusViewModel } from '../view-models';
|
||||
import { DriverRegistrationStatusViewModel as ViewModel } from '../view-models';
|
||||
|
||||
/**
|
||||
* Driver Registration Status Presenter
|
||||
* Transforms DriverRegistrationStatusDto to DriverRegistrationStatusViewModel
|
||||
*/
|
||||
export class DriverRegistrationStatusPresenter {
|
||||
present(dto: DriverRegistrationStatusDto): DriverRegistrationStatusViewModel {
|
||||
return new ViewModel(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functional export for backward compatibility
|
||||
export const presentDriverRegistrationStatus = (dto: DriverRegistrationStatusDto): DriverRegistrationStatusViewModel => {
|
||||
return new DriverRegistrationStatusViewModel(dto);
|
||||
const presenter = new DriverRegistrationStatusPresenter();
|
||||
return presenter.present(dto);
|
||||
};
|
||||
@@ -1,6 +1,18 @@
|
||||
import { DriversLeaderboardDto, DriverLeaderboardItemDto } from '../dtos';
|
||||
import { DriverLeaderboardViewModel } from '../view-models';
|
||||
import type { DriversLeaderboardDto } from '../dtos';
|
||||
import type { DriverLeaderboardViewModel } from '../view-models';
|
||||
|
||||
export const presentDriversLeaderboard = (dto: DriversLeaderboardDto, previousDrivers?: DriverLeaderboardItemDto[]): DriverLeaderboardViewModel => {
|
||||
return new DriverLeaderboardViewModel(dto, previousDrivers);
|
||||
/**
|
||||
* Drivers Leaderboard Presenter
|
||||
* Transforms DriversLeaderboardDto to DriverLeaderboardViewModel
|
||||
*/
|
||||
export class DriversLeaderboardPresenter {
|
||||
present(dto: DriversLeaderboardDto, previousDrivers?: any): DriverLeaderboardViewModel {
|
||||
return new DriverLeaderboardViewModel(dto as any, previousDrivers);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functional export for backward compatibility
|
||||
export const presentDriversLeaderboard = (dto: DriversLeaderboardDto, previousDrivers?: any): DriverLeaderboardViewModel => {
|
||||
const presenter = new DriversLeaderboardPresenter();
|
||||
return presenter.present(dto, previousDrivers);
|
||||
};
|
||||
@@ -1,17 +1,21 @@
|
||||
import type {
|
||||
IImportRaceResultsPresenter,
|
||||
ImportRaceResultsSummaryViewModel,
|
||||
} from '@core/racing/application/presenters/IImportRaceResultsPresenter';
|
||||
import type { ImportRaceResultsSummaryDto } from '../dtos/ImportRaceResultsSummaryDto';
|
||||
|
||||
export class ImportRaceResultsPresenter implements IImportRaceResultsPresenter {
|
||||
private viewModel: ImportRaceResultsSummaryViewModel | null = null;
|
||||
export interface ImportRaceResultsSummaryViewModel {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
driversProcessed: number;
|
||||
resultsRecorded: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel {
|
||||
this.viewModel = viewModel;
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): ImportRaceResultsSummaryViewModel | null {
|
||||
return this.viewModel;
|
||||
export class ImportRaceResultsPresenter {
|
||||
present(dto: ImportRaceResultsSummaryDto): ImportRaceResultsSummaryViewModel {
|
||||
return {
|
||||
success: dto.success,
|
||||
raceId: dto.raceId,
|
||||
driversProcessed: dto.driversProcessed,
|
||||
resultsRecorded: dto.resultsRecorded,
|
||||
errors: dto.errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
/**
|
||||
* League Standings Presenter
|
||||
* Transforms LeagueStandingsDto to LeagueStandingsViewModel
|
||||
*/
|
||||
export class LeagueStandingsPresenter {
|
||||
present(dto: LeagueStandingsDto, currentUserId: string, previousStandings?: StandingEntryDto[]): LeagueStandingsViewModel {
|
||||
return new LeagueStandingsViewModel(dto, currentUserId, previousStandings);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functional export for backward compatibility
|
||||
export const presentLeagueStandings = (dto: LeagueStandingsDto, currentUserId: string, previousStandings?: StandingEntryDto[]): LeagueStandingsViewModel => {
|
||||
return new LeagueStandingsViewModel(dto, currentUserId, previousStandings);
|
||||
const presenter = new LeagueStandingsPresenter();
|
||||
return presenter.present(dto, currentUserId, previousStandings);
|
||||
};
|
||||
@@ -1,10 +1,21 @@
|
||||
import { LeagueSummaryDto } from '../dtos';
|
||||
import type { AllLeaguesWithCapacityDto } from '../dtos';
|
||||
import { LeagueSummaryViewModel } from '../view-models';
|
||||
|
||||
export const presentLeagueSummary = (dto: LeagueSummaryDto): LeagueSummaryViewModel => {
|
||||
/**
|
||||
* League Summary Presenter
|
||||
* Transforms AllLeaguesWithCapacityDto to array of LeagueSummaryViewModel
|
||||
*/
|
||||
export class LeagueSummaryPresenter {
|
||||
present(dto: AllLeaguesWithCapacityDto): LeagueSummaryViewModel[] {
|
||||
return dto.leagues.map(league => new LeagueSummaryViewModel(league));
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy functional exports for backward compatibility
|
||||
export const presentLeagueSummary = (dto: any): LeagueSummaryViewModel => {
|
||||
return new LeagueSummaryViewModel(dto);
|
||||
};
|
||||
|
||||
export const presentLeagueSummaries = (dtos: LeagueSummaryDto[]): LeagueSummaryViewModel[] => {
|
||||
export const presentLeagueSummaries = (dtos: any[]): LeagueSummaryViewModel[] => {
|
||||
return dtos.map(presentLeagueSummary);
|
||||
};
|
||||
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 {
|
||||
IRaceWithSOFPresenter,
|
||||
RaceWithSOFResultDTO,
|
||||
RaceWithSOFViewModel,
|
||||
} from '@core/racing/application/presenters/IRaceWithSOFPresenter';
|
||||
import type { RaceWithSOFDto } from '../dtos/RaceWithSOFDto';
|
||||
|
||||
export class RaceWithSOFPresenter implements IRaceWithSOFPresenter {
|
||||
present(dto: RaceWithSOFResultDTO): RaceWithSOFViewModel {
|
||||
export interface RaceWithSOFViewModel {
|
||||
id: string;
|
||||
track: string;
|
||||
strengthOfField: number | null;
|
||||
}
|
||||
|
||||
export class RaceWithSOFPresenter {
|
||||
present(dto: RaceWithSOFDto): RaceWithSOFViewModel {
|
||||
return {
|
||||
id: dto.raceId,
|
||||
leagueId: dto.leagueId,
|
||||
scheduledAt: dto.scheduledAt.toISOString(),
|
||||
id: dto.id,
|
||||
track: dto.track,
|
||||
trackId: dto.trackId,
|
||||
car: dto.car,
|
||||
carId: dto.carId,
|
||||
sessionType: dto.sessionType,
|
||||
status: dto.status,
|
||||
strengthOfField: dto.strengthOfField,
|
||||
registeredCount: dto.registeredCount,
|
||||
maxParticipants: dto.maxParticipants,
|
||||
participantCount: dto.participantCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
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 {
|
||||
ISponsorDashboardPresenter,
|
||||
SponsorDashboardViewModel,
|
||||
} from '@core/racing/application/presenters/ISponsorDashboardPresenter';
|
||||
import type { SponsorDashboardDTO } from '@core/racing/application/use-cases/GetSponsorDashboardUseCase';
|
||||
import type { SponsorDashboardDto } from '../dtos';
|
||||
import { SponsorDashboardViewModel } from '../view-models/SponsorDashboardViewModel';
|
||||
|
||||
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
|
||||
private viewModel: SponsorDashboardViewModel = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(data: SponsorDashboardDTO | null): void {
|
||||
this.viewModel = data;
|
||||
}
|
||||
|
||||
getViewModel(): SponsorDashboardViewModel {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getData(): SponsorDashboardDTO | null {
|
||||
return this.viewModel;
|
||||
/**
|
||||
* Sponsor Dashboard Presenter
|
||||
*
|
||||
* Transforms sponsor dashboard DTOs into view models.
|
||||
*/
|
||||
export class SponsorDashboardPresenter {
|
||||
present(dto: SponsorDashboardDto): SponsorDashboardViewModel {
|
||||
return new SponsorDashboardViewModel(dto);
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
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 {
|
||||
ISponsorSponsorshipsPresenter,
|
||||
SponsorSponsorshipsViewModel,
|
||||
} from '@core/racing/application/presenters/ISponsorSponsorshipsPresenter';
|
||||
import type { SponsorSponsorshipsDTO } from '@core/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
||||
import type { SponsorSponsorshipsDto } from '../dtos';
|
||||
import { SponsorSponsorshipsViewModel } from '../view-models/SponsorSponsorshipsViewModel';
|
||||
|
||||
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
|
||||
private viewModel: SponsorSponsorshipsViewModel = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(data: SponsorSponsorshipsDTO | null): void {
|
||||
this.viewModel = data;
|
||||
}
|
||||
|
||||
getViewModel(): SponsorSponsorshipsViewModel {
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getData(): SponsorSponsorshipsDTO | null {
|
||||
return this.viewModel;
|
||||
/**
|
||||
* Sponsor Sponsorships Presenter
|
||||
*
|
||||
* Transforms sponsor sponsorships DTOs into view models.
|
||||
*/
|
||||
export class SponsorSponsorshipsPresenter {
|
||||
present(dto: SponsorSponsorshipsDto): SponsorSponsorshipsViewModel {
|
||||
return new SponsorSponsorshipsViewModel(dto);
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Team Join Request Presenter
|
||||
* Transforms TeamJoinRequestItemDto to TeamJoinRequestViewModel
|
||||
*/
|
||||
export class TeamJoinRequestPresenter {
|
||||
present(dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean): TeamJoinRequestViewModel {
|
||||
return new TeamJoinRequestViewModel(dto, currentUserId, isOwner);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility export (deprecated)
|
||||
export const presentTeamJoinRequest = (dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean): TeamJoinRequestViewModel => {
|
||||
return new TeamJoinRequestViewModel(dto, currentUserId, isOwner);
|
||||
const presenter = new TeamJoinRequestPresenter();
|
||||
return presenter.present(dto, currentUserId, isOwner);
|
||||
};
|
||||
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 {
|
||||
ITeamMembersPresenter,
|
||||
TeamMemberViewModel,
|
||||
TeamMembersViewModel,
|
||||
TeamMembersResultDTO,
|
||||
} from '@core/racing/application/presenters/ITeamMembersPresenter';
|
||||
import type { TeamMembersDto } from '../dtos';
|
||||
import { TeamMemberViewModel } from '../view-models';
|
||||
|
||||
export class TeamMembersPresenter implements ITeamMembersPresenter {
|
||||
private viewModel: TeamMembersViewModel | null = null;
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamMembersResultDTO): void {
|
||||
const members: TeamMemberViewModel[] = input.memberships.map((membership) => ({
|
||||
driverId: membership.driverId,
|
||||
driverName: input.driverNames[membership.driverId] ?? 'Unknown Driver',
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.status === 'active',
|
||||
avatarUrl: input.avatarUrls[membership.driverId] ?? '',
|
||||
}));
|
||||
|
||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||
const managerCount = members.filter((m) => m.role === 'manager').length;
|
||||
const memberCount = members.filter((m) => m.role === 'member').length;
|
||||
|
||||
this.viewModel = {
|
||||
members,
|
||||
totalCount: members.length,
|
||||
ownerCount,
|
||||
managerCount,
|
||||
memberCount,
|
||||
};
|
||||
}
|
||||
|
||||
getViewModel(): TeamMembersViewModel | null {
|
||||
return this.viewModel;
|
||||
/**
|
||||
* Team Members Presenter
|
||||
* Transforms TeamMembersDto to array of TeamMemberViewModel
|
||||
*/
|
||||
export class TeamMembersPresenter {
|
||||
present(dto: TeamMembersDto, currentUserId: string, teamOwnerId: string): TeamMemberViewModel[] {
|
||||
return dto.members.map(member => new TeamMemberViewModel(member, currentUserId, teamOwnerId));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
return {};
|
||||
/**
|
||||
* Dashboard Service
|
||||
*
|
||||
* Orchestrates dashboard operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
private readonly apiClient: AnalyticsApiClient,
|
||||
private readonly analyticsDashboardPresenter: AnalyticsDashboardPresenter,
|
||||
private readonly analyticsMetricsPresenter: AnalyticsMetricsPresenter
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get dashboard data with presentation transformation
|
||||
*/
|
||||
async getDashboardData(): Promise<AnalyticsDashboardViewModel> {
|
||||
try {
|
||||
const dto = await this.apiClient.getDashboardData();
|
||||
return this.analyticsDashboardPresenter.present(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics metrics with presentation transformation
|
||||
*/
|
||||
async getAnalyticsMetrics(): Promise<AnalyticsMetricsViewModel> {
|
||||
try {
|
||||
const dto = await this.apiClient.getAnalyticsMetrics();
|
||||
return this.analyticsMetricsPresenter.present(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard overview (legacy method for backward compatibility)
|
||||
* TODO: Remove when no longer needed
|
||||
*/
|
||||
async getDashboardOverview(): Promise<AnalyticsDashboardViewModel> {
|
||||
return this.getDashboardData();
|
||||
}
|
||||
}
|
||||
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 { SessionViewModel } from '../../view-models';
|
||||
import { AuthApiClient } from '../../api/auth/AuthApiClient';
|
||||
import { SessionPresenter } from '../../presenters/SessionPresenter';
|
||||
import type { SessionViewModel } from '../../view-models';
|
||||
|
||||
export async function getSession(): Promise<SessionViewModel | null> {
|
||||
const dto = await api.auth.getSession();
|
||||
if (!dto) return null;
|
||||
// TODO: presenter
|
||||
return dto as any;
|
||||
/**
|
||||
* Session Service
|
||||
*
|
||||
* Orchestrates session operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class SessionService {
|
||||
constructor(
|
||||
private readonly apiClient: AuthApiClient,
|
||||
private readonly presenter: SessionPresenter
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get current user session with presentation transformation
|
||||
*/
|
||||
async getSession(): Promise<SessionViewModel | null> {
|
||||
try {
|
||||
const dto = await this.apiClient.getSession();
|
||||
return this.presenter.presentSession(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { presentDriverRegistrationStatus } from '../../presenters';
|
||||
import { DriverRegistrationStatusViewModel } from '../../view-models';
|
||||
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
|
||||
import type { DriverRegistrationStatusPresenter } from '../../presenters/DriverRegistrationStatusPresenter';
|
||||
import type { DriverRegistrationStatusViewModel } from '../../view-models';
|
||||
|
||||
export async function getDriverRegistrationStatus(driverId: string): Promise<DriverRegistrationStatusViewModel> {
|
||||
// TODO: implement API call
|
||||
const dto = { driverId, status: 'pending' };
|
||||
return presentDriverRegistrationStatus(dto);
|
||||
}
|
||||
/**
|
||||
* Driver Registration Service
|
||||
*
|
||||
* Orchestrates driver registration status operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class DriverRegistrationService {
|
||||
constructor(
|
||||
private readonly apiClient: DriversApiClient,
|
||||
private readonly statusPresenter: DriverRegistrationStatusPresenter
|
||||
) {}
|
||||
|
||||
export async function registerDriver(input: any): Promise<any> {
|
||||
// TODO: implement
|
||||
return {};
|
||||
/**
|
||||
* Get driver registration status for a specific race
|
||||
*/
|
||||
async getDriverRegistrationStatus(
|
||||
driverId: string,
|
||||
raceId: string
|
||||
): Promise<DriverRegistrationStatusViewModel> {
|
||||
const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
|
||||
return this.statusPresenter.present(dto);
|
||||
}
|
||||
}
|
||||
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 { presentDriversLeaderboard } from '../../presenters';
|
||||
import { DriverLeaderboardViewModel } from '../../view-models';
|
||||
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
|
||||
import type { DriversLeaderboardPresenter } from '../../presenters/DriversLeaderboardPresenter';
|
||||
import type { DriverPresenter } from '../../presenters/DriverPresenter';
|
||||
import type { CompleteOnboardingPresenter } from '../../presenters/CompleteOnboardingPresenter';
|
||||
import type { DriverLeaderboardViewModel } from '../../view-models';
|
||||
import type { DriverViewModel } from '../../view-models/DriverViewModel';
|
||||
import type { CompleteOnboardingViewModel } from '../../view-models/CompleteOnboardingViewModel';
|
||||
import type { CompleteOnboardingInputDto } from '../../dtos';
|
||||
|
||||
/**
|
||||
* Driver Service
|
||||
*
|
||||
* Handles driver-related operations including profiles, leaderboards, and onboarding.
|
||||
* Orchestrates driver operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class DriverService {
|
||||
constructor(
|
||||
private readonly apiClient = api.drivers
|
||||
private readonly apiClient: DriversApiClient,
|
||||
private readonly leaderboardPresenter: DriversLeaderboardPresenter,
|
||||
private readonly driverPresenter: DriverPresenter,
|
||||
private readonly onboardingPresenter: CompleteOnboardingPresenter
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get driver leaderboard with presentation transformation
|
||||
*/
|
||||
async getDriverLeaderboard(): Promise<DriverLeaderboardViewModel> {
|
||||
const dto = await this.apiClient.getLeaderboard();
|
||||
return presentDriversLeaderboard(dto);
|
||||
return this.leaderboardPresenter.present(dto);
|
||||
}
|
||||
|
||||
async completeDriverOnboarding(input: any): Promise<any> {
|
||||
return await this.apiClient.completeOnboarding(input);
|
||||
/**
|
||||
* Complete driver onboarding with presentation transformation
|
||||
*/
|
||||
async completeDriverOnboarding(input: CompleteOnboardingInputDto): Promise<CompleteOnboardingViewModel> {
|
||||
const dto = await this.apiClient.completeOnboarding(input);
|
||||
return this.onboardingPresenter.present(dto);
|
||||
}
|
||||
|
||||
async getCurrentDriver(): Promise<any> {
|
||||
return await this.apiClient.getCurrent();
|
||||
/**
|
||||
* Get current driver with presentation transformation
|
||||
*/
|
||||
async getCurrentDriver(): Promise<DriverViewModel | null> {
|
||||
const dto = await this.apiClient.getCurrent();
|
||||
if (!dto) {
|
||||
return null;
|
||||
}
|
||||
return this.driverPresenter.present(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const driverService = new DriverService();
|
||||
|
||||
// Backward compatibility functional exports
|
||||
export async function getDriverLeaderboard(): Promise<DriverLeaderboardViewModel> {
|
||||
return driverService.getDriverLeaderboard();
|
||||
}
|
||||
|
||||
export async function completeDriverOnboarding(input: any): Promise<any> {
|
||||
return driverService.completeDriverOnboarding(input);
|
||||
}
|
||||
|
||||
export async function getCurrentDriver(): Promise<any> {
|
||||
return driverService.getCurrentDriver();
|
||||
}
|
||||
@@ -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 { presentLeagueMember } from '../../presenters';
|
||||
import { LeagueMemberViewModel } from '../../view-models';
|
||||
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import type { LeagueMemberViewModel } from '../../view-models';
|
||||
import type { LeagueMembersPresenter } from '../../presenters/LeagueMembersPresenter';
|
||||
|
||||
export async function getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
|
||||
const dto = await api.leagues.getMemberships(leagueId);
|
||||
return dto.members.map(m => presentLeagueMember(m, currentUserId));
|
||||
}
|
||||
/**
|
||||
* League Membership Service
|
||||
*
|
||||
* Orchestrates league membership operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueMembershipService {
|
||||
constructor(
|
||||
private readonly apiClient: LeaguesApiClient,
|
||||
private readonly leagueMembersPresenter: LeagueMembersPresenter
|
||||
) {}
|
||||
|
||||
export async function removeLeagueMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<void> {
|
||||
await api.leagues.removeMember(leagueId, performerDriverId, targetDriverId);
|
||||
/**
|
||||
* Get league memberships with presentation transformation
|
||||
*/
|
||||
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
|
||||
const dto = await this.apiClient.getMemberships(leagueId);
|
||||
return this.leagueMembersPresenter.present(dto, currentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from league
|
||||
*/
|
||||
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
|
||||
return await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
|
||||
}
|
||||
}
|
||||
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 { presentLeagueSummaries, presentLeagueStandings } from '../../presenters';
|
||||
import { LeagueSummaryViewModel, LeagueStandingsViewModel } from '../../view-models';
|
||||
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
|
||||
import type { LeagueSummaryPresenter } from '../../presenters/LeagueSummaryPresenter';
|
||||
import type { LeagueStandingsPresenter } from '../../presenters/LeagueStandingsPresenter';
|
||||
import type { LeagueSummaryViewModel, LeagueStandingsViewModel } from '../../view-models';
|
||||
import type { CreateLeagueInputDto, CreateLeagueOutputDto, LeagueStatsDto, LeagueScheduleDto, LeagueMembershipsDto } from '../../dtos';
|
||||
|
||||
export async function getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
|
||||
const dto = await api.leagues.getAllWithCapacity();
|
||||
return presentLeagueSummaries(dto.leagues);
|
||||
}
|
||||
/**
|
||||
* League Service
|
||||
*
|
||||
* Orchestrates league operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class LeagueService {
|
||||
constructor(
|
||||
private readonly apiClient: LeaguesApiClient,
|
||||
private readonly leagueSummaryPresenter: LeagueSummaryPresenter,
|
||||
private readonly leagueStandingsPresenter: LeagueStandingsPresenter
|
||||
) {}
|
||||
|
||||
export async function getLeagueStandings(leagueId: string, currentUserId?: string): Promise<LeagueStandingsViewModel> {
|
||||
const dto = await api.leagues.getStandings(leagueId);
|
||||
// TODO: include drivers and memberships in dto
|
||||
const dtoWithExtras = {
|
||||
...dto,
|
||||
drivers: [], // TODO: fetch drivers
|
||||
memberships: [], // TODO: fetch memberships
|
||||
};
|
||||
return presentLeagueStandings(dtoWithExtras, currentUserId || '');
|
||||
}
|
||||
/**
|
||||
* Get all leagues with presentation transformation
|
||||
*/
|
||||
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
|
||||
const dto = await this.apiClient.getAllWithCapacity();
|
||||
return this.leagueSummaryPresenter.present(dto);
|
||||
}
|
||||
|
||||
export async function createLeague(input: any): Promise<any> {
|
||||
return await api.leagues.create(input);
|
||||
}
|
||||
/**
|
||||
* Get league standings with presentation transformation
|
||||
*/
|
||||
async getLeagueStandings(leagueId: string, currentUserId: string): Promise<LeagueStandingsViewModel> {
|
||||
const dto = await this.apiClient.getStandings(leagueId);
|
||||
// TODO: include drivers and memberships in dto
|
||||
const dtoWithExtras = {
|
||||
...dto,
|
||||
drivers: [], // TODO: fetch drivers
|
||||
memberships: [], // TODO: fetch memberships
|
||||
};
|
||||
return this.leagueStandingsPresenter.present(dtoWithExtras, currentUserId);
|
||||
}
|
||||
|
||||
export async function getLeagueAdminView(leagueId: string): Promise<any> {
|
||||
// TODO: implement
|
||||
return {};
|
||||
/**
|
||||
* Get league statistics
|
||||
*/
|
||||
async getLeagueStats(): Promise<LeagueStatsDto> {
|
||||
return await this.apiClient.getTotal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league schedule
|
||||
*/
|
||||
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDto> {
|
||||
return await this.apiClient.getSchedule(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get league memberships
|
||||
*/
|
||||
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDto> {
|
||||
return await this.apiClient.getMemberships(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new league
|
||||
*/
|
||||
async createLeague(input: CreateLeagueInputDto): Promise<CreateLeagueOutputDto> {
|
||||
return await this.apiClient.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from league
|
||||
*/
|
||||
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
|
||||
return await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
|
||||
}
|
||||
}
|
||||
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
|
||||
return {};
|
||||
/**
|
||||
* Media Service
|
||||
*
|
||||
* Orchestrates media operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class MediaService {
|
||||
constructor(
|
||||
private readonly apiClient: MediaApiClient,
|
||||
private readonly presenter: MediaPresenter
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upload media file with presentation transformation
|
||||
*/
|
||||
async uploadMedia(input: UploadMediaInputDto): Promise<UploadMediaViewModel> {
|
||||
try {
|
||||
const dto = await this.apiClient.uploadMedia(input);
|
||||
return this.presenter.presentUpload(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media by ID with presentation transformation
|
||||
*/
|
||||
async getMedia(mediaId: string): Promise<MediaViewModel> {
|
||||
try {
|
||||
const dto = await this.apiClient.getMedia(mediaId);
|
||||
return this.presenter.presentMedia(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media by ID with presentation transformation
|
||||
*/
|
||||
async deleteMedia(mediaId: string): Promise<DeleteMediaViewModel> {
|
||||
try {
|
||||
const dto = await this.apiClient.deleteMedia(mediaId);
|
||||
return this.presenter.presentDelete(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,27 @@
|
||||
import { api as api } from '../../api';
|
||||
import { presentMembershipFee } from '../../presenters';
|
||||
import { MembershipFeeViewModel } from '../../view-models';
|
||||
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
|
||||
import { presentMembershipFee } from '../../presenters/MembershipFeePresenter';
|
||||
import type { MembershipFeeViewModel } from '../../view-models';
|
||||
|
||||
export async function getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
|
||||
const dto = await api.payments.getMembershipFees(leagueId);
|
||||
return dto.fees.map(f => presentMembershipFee(f));
|
||||
/**
|
||||
* Membership Fee Service
|
||||
*
|
||||
* Orchestrates membership fee operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class MembershipFeeService {
|
||||
constructor(
|
||||
private readonly apiClient: PaymentsApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get membership fees by league ID with presentation transformation
|
||||
*/
|
||||
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
|
||||
try {
|
||||
const dto = await this.apiClient.getMembershipFees(leagueId);
|
||||
return dto.fees.map(presentMembershipFee);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 { presentPayment } from '../../presenters';
|
||||
import { PaymentViewModel } from '../../view-models';
|
||||
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
|
||||
import type { PaymentListPresenter } from '../../presenters/PaymentListPresenter';
|
||||
import type {
|
||||
CreatePaymentInputDto,
|
||||
CreatePaymentOutputDto,
|
||||
} from '../../dtos';
|
||||
import type {
|
||||
PaymentViewModel,
|
||||
MembershipFeeViewModel,
|
||||
PrizeViewModel,
|
||||
WalletViewModel,
|
||||
} from '../../view-models';
|
||||
|
||||
export async function getPayments(leagueId?: string, driverId?: string): Promise<PaymentViewModel[]> {
|
||||
const dto = await api.payments.getPayments(leagueId, driverId);
|
||||
return dto.payments.map(p => presentPayment(p));
|
||||
}
|
||||
/**
|
||||
* Payment Service
|
||||
*
|
||||
* Orchestrates payment operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class PaymentService {
|
||||
constructor(
|
||||
private readonly apiClient: PaymentsApiClient,
|
||||
private readonly paymentListPresenter: PaymentListPresenter,
|
||||
private readonly presentPayment: (dto: any) => PaymentViewModel,
|
||||
private readonly presentMembershipFee: (dto: any) => MembershipFeeViewModel,
|
||||
private readonly presentPrize: (dto: any) => PrizeViewModel,
|
||||
private readonly presentWallet: (dto: any) => WalletViewModel
|
||||
) {}
|
||||
|
||||
export async function createPayment(input: any): Promise<any> {
|
||||
return await api.payments.createPayment(input);
|
||||
/**
|
||||
* Get all payments with optional filters
|
||||
*/
|
||||
async getPayments(leagueId?: string, driverId?: string): Promise<PaymentViewModel[]> {
|
||||
try {
|
||||
const dto = await this.apiClient.getPayments(leagueId, driverId);
|
||||
return this.paymentListPresenter.present(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single payment by ID
|
||||
*/
|
||||
async getPayment(paymentId: string): Promise<PaymentViewModel> {
|
||||
try {
|
||||
// Note: Assuming the API returns a single payment from the list
|
||||
const dto = await this.apiClient.getPayments();
|
||||
const payment = dto.payments.find(p => p.id === paymentId);
|
||||
if (!payment) {
|
||||
throw new Error(`Payment with ID ${paymentId} not found`);
|
||||
}
|
||||
return this.presentPayment(payment);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new payment
|
||||
*/
|
||||
async createPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
|
||||
try {
|
||||
return await this.apiClient.createPayment(input);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get membership fees for a league
|
||||
*/
|
||||
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
|
||||
try {
|
||||
const dto = await this.apiClient.getMembershipFees(leagueId);
|
||||
return dto.fees.map(fee => this.presentMembershipFee(fee));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prizes with optional filters
|
||||
*/
|
||||
async getPrizes(leagueId?: string, seasonId?: string): Promise<PrizeViewModel[]> {
|
||||
try {
|
||||
const dto = await this.apiClient.getPrizes(leagueId, seasonId);
|
||||
return dto.prizes.map(prize => this.presentPrize(prize));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet for a driver
|
||||
*/
|
||||
async getWallet(driverId: string): Promise<WalletViewModel> {
|
||||
try {
|
||||
const dto = await this.apiClient.getWallet(driverId);
|
||||
return this.presentWallet(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a payment (alias for createPayment)
|
||||
*/
|
||||
async processPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
|
||||
try {
|
||||
return await this.createPayment(input);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment history for a user (driver)
|
||||
*/
|
||||
async getPaymentHistory(driverId: string): Promise<PaymentViewModel[]> {
|
||||
try {
|
||||
return await this.getPayments(undefined, driverId);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 { presentWallet } from '../../presenters';
|
||||
import { WalletViewModel } from '../../view-models';
|
||||
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
|
||||
import { presentWallet } from '../../presenters/WalletPresenter';
|
||||
import type { WalletViewModel } from '../../view-models';
|
||||
|
||||
export async function getWallet(driverId: string): Promise<WalletViewModel> {
|
||||
const dto = await api.payments.getWallet(driverId);
|
||||
return presentWallet(dto);
|
||||
/**
|
||||
* Wallet Service
|
||||
*
|
||||
* Orchestrates wallet operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class WalletService {
|
||||
constructor(
|
||||
private readonly apiClient: PaymentsApiClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get wallet by driver ID with presentation transformation
|
||||
*/
|
||||
async getWallet(driverId: string): Promise<WalletViewModel> {
|
||||
try {
|
||||
const dto = await this.apiClient.getWallet(driverId);
|
||||
return presentWallet(dto);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,256 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getRaceResults, getRaceSOF, importRaceResults } from './RaceResultsService';
|
||||
import { RaceResultsService } from './RaceResultsService';
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceResultsDetailPresenter } from '../../presenters/RaceResultsDetailPresenter';
|
||||
import { RaceWithSOFPresenter } from '../../presenters/RaceWithSOFPresenter';
|
||||
import { ImportRaceResultsPresenter } from '../../presenters/ImportRaceResultsPresenter';
|
||||
import type { RaceResultsDetailDto, RaceWithSOFDto, ImportRaceResultsSummaryDto } from '../../dtos';
|
||||
import type { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
|
||||
import type { RaceWithSOFViewModel } from '../../presenters/RaceWithSOFPresenter';
|
||||
import type { ImportRaceResultsSummaryViewModel } from '../../presenters/ImportRaceResultsPresenter';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../../api', () => ({
|
||||
apiClient: {
|
||||
races: {
|
||||
describe('RaceResultsService', () => {
|
||||
let service: RaceResultsService;
|
||||
let mockApiClient: RacesApiClient;
|
||||
let mockResultsDetailPresenter: RaceResultsDetailPresenter;
|
||||
let mockSOFPresenter: RaceWithSOFPresenter;
|
||||
let mockImportPresenter: ImportRaceResultsPresenter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiClient = {
|
||||
getResultsDetail: vi.fn(),
|
||||
getWithSOF: vi.fn(),
|
||||
importResults: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
} as unknown as RacesApiClient;
|
||||
|
||||
// Mock the presenter
|
||||
vi.mock('../../presenters', () => ({
|
||||
presentRaceResultsDetail: vi.fn(),
|
||||
}));
|
||||
mockResultsDetailPresenter = {
|
||||
present: vi.fn(),
|
||||
} as unknown as RaceResultsDetailPresenter;
|
||||
|
||||
import { api } from '../../api';
|
||||
import { presentRaceResultsDetail } from '../../presenters';
|
||||
mockSOFPresenter = {
|
||||
present: vi.fn(),
|
||||
} as unknown as RaceWithSOFPresenter;
|
||||
|
||||
describe('RaceResultsService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockImportPresenter = {
|
||||
present: vi.fn(),
|
||||
} as unknown as ImportRaceResultsPresenter;
|
||||
|
||||
service = new RaceResultsService(
|
||||
mockApiClient,
|
||||
mockResultsDetailPresenter,
|
||||
mockSOFPresenter,
|
||||
mockImportPresenter
|
||||
);
|
||||
});
|
||||
|
||||
describe('getRaceResults', () => {
|
||||
it('should call API and presenter with correct parameters', async () => {
|
||||
const mockDto: RaceResultsDetailDto = {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
results: [],
|
||||
// ... other required fields
|
||||
} as RaceResultsDetailDto;
|
||||
|
||||
const mockViewModel = {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
formattedResults: [],
|
||||
};
|
||||
|
||||
describe('getResultsDetail', () => {
|
||||
it('should fetch race results detail and transform via presenter', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const currentUserId = 'user-456';
|
||||
|
||||
// Mock API call
|
||||
vi.mocked(api.races.getResultsDetail).mockResolvedValue(mockDto);
|
||||
// Mock presenter
|
||||
vi.mocked(presentRaceResultsDetail).mockReturnValue(mockViewModel);
|
||||
|
||||
const result = await getRaceResults(raceId, currentUserId);
|
||||
|
||||
expect(api.races.getResultsDetail).toHaveBeenCalledWith(raceId);
|
||||
expect(presentRaceResultsDetail).toHaveBeenCalledWith(mockDto, currentUserId);
|
||||
expect(result).toBe(mockViewModel);
|
||||
});
|
||||
|
||||
it('should call presenter with undefined currentUserId when not provided', async () => {
|
||||
const mockDto: RaceResultsDetailDto = {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
const mockDto: Partial<RaceResultsDetailDto> = {
|
||||
raceId,
|
||||
results: [],
|
||||
};
|
||||
const mockViewModel: Partial<RaceResultsDetailViewModel> = {
|
||||
raceId,
|
||||
results: [],
|
||||
} as RaceResultsDetailDto;
|
||||
|
||||
const mockViewModel = {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
formattedResults: [],
|
||||
};
|
||||
|
||||
vi.mocked(mockApiClient.getResultsDetail).mockResolvedValue(mockDto as RaceResultsDetailDto);
|
||||
vi.mocked(mockResultsDetailPresenter.present).mockReturnValue(mockViewModel as RaceResultsDetailViewModel);
|
||||
|
||||
// Act
|
||||
const result = await service.getResultsDetail(raceId, currentUserId);
|
||||
|
||||
// Assert
|
||||
expect(mockApiClient.getResultsDetail).toHaveBeenCalledWith(raceId);
|
||||
expect(mockResultsDetailPresenter.present).toHaveBeenCalledWith(mockDto, currentUserId);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
});
|
||||
|
||||
it('should fetch race results detail without currentUserId', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const mockDto: Partial<RaceResultsDetailDto> = {
|
||||
raceId,
|
||||
results: [],
|
||||
};
|
||||
const mockViewModel: Partial<RaceResultsDetailViewModel> = {
|
||||
raceId,
|
||||
results: [],
|
||||
};
|
||||
|
||||
vi.mocked(api.races.getResultsDetail).mockResolvedValue(mockDto);
|
||||
vi.mocked(presentRaceResultsDetail).mockReturnValue(mockViewModel);
|
||||
vi.mocked(mockApiClient.getResultsDetail).mockResolvedValue(mockDto as RaceResultsDetailDto);
|
||||
vi.mocked(mockResultsDetailPresenter.present).mockReturnValue(mockViewModel as RaceResultsDetailViewModel);
|
||||
|
||||
await getRaceResults(raceId);
|
||||
// Act
|
||||
const result = await service.getResultsDetail(raceId);
|
||||
|
||||
expect(presentRaceResultsDetail).toHaveBeenCalledWith(mockDto, undefined);
|
||||
// Assert
|
||||
expect(mockApiClient.getResultsDetail).toHaveBeenCalledWith(raceId);
|
||||
expect(mockResultsDetailPresenter.present).toHaveBeenCalledWith(mockDto, undefined);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
});
|
||||
|
||||
it('should propagate errors from API client', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const error = new Error('API Error');
|
||||
vi.mocked(mockApiClient.getResultsDetail).mockRejectedValue(error);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.getResultsDetail(raceId)).rejects.toThrow('API Error');
|
||||
expect(mockApiClient.getResultsDetail).toHaveBeenCalledWith(raceId);
|
||||
expect(mockResultsDetailPresenter.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRaceSOF', () => {
|
||||
it('should call API and return DTO directly', async () => {
|
||||
describe('getWithSOF', () => {
|
||||
it('should fetch race with SOF and transform via presenter', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const mockDto: RaceWithSOFDto = {
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
sof: 1500,
|
||||
// ... other fields
|
||||
} as RaceWithSOFDto;
|
||||
id: raceId,
|
||||
track: 'Spa-Francorchamps',
|
||||
strengthOfField: 2500,
|
||||
};
|
||||
const mockViewModel: RaceWithSOFViewModel = {
|
||||
id: raceId,
|
||||
track: 'Spa-Francorchamps',
|
||||
strengthOfField: 2500,
|
||||
};
|
||||
|
||||
vi.mocked(mockApiClient.getWithSOF).mockResolvedValue(mockDto);
|
||||
vi.mocked(mockSOFPresenter.present).mockReturnValue(mockViewModel);
|
||||
|
||||
// Act
|
||||
const result = await service.getWithSOF(raceId);
|
||||
|
||||
// Assert
|
||||
expect(mockApiClient.getWithSOF).toHaveBeenCalledWith(raceId);
|
||||
expect(mockSOFPresenter.present).toHaveBeenCalledWith(mockDto);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
});
|
||||
|
||||
it('should handle null strengthOfField', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const mockDto: RaceWithSOFDto = {
|
||||
id: raceId,
|
||||
track: 'Spa-Francorchamps',
|
||||
strengthOfField: null,
|
||||
};
|
||||
const mockViewModel: RaceWithSOFViewModel = {
|
||||
id: raceId,
|
||||
track: 'Spa-Francorchamps',
|
||||
strengthOfField: null,
|
||||
};
|
||||
|
||||
vi.mocked(api.races.getWithSOF).mockResolvedValue(mockDto);
|
||||
vi.mocked(mockApiClient.getWithSOF).mockResolvedValue(mockDto);
|
||||
vi.mocked(mockSOFPresenter.present).mockReturnValue(mockViewModel);
|
||||
|
||||
const result = await getRaceSOF(raceId);
|
||||
// Act
|
||||
const result = await service.getWithSOF(raceId);
|
||||
|
||||
expect(api.races.getWithSOF).toHaveBeenCalledWith(raceId);
|
||||
expect(result).toBe(mockDto);
|
||||
// Assert
|
||||
expect(mockApiClient.getWithSOF).toHaveBeenCalledWith(raceId);
|
||||
expect(mockSOFPresenter.present).toHaveBeenCalledWith(mockDto);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
});
|
||||
|
||||
it('should propagate errors from API client', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const error = new Error('SOF calculation failed');
|
||||
vi.mocked(mockApiClient.getWithSOF).mockRejectedValue(error);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.getWithSOF(raceId)).rejects.toThrow('SOF calculation failed');
|
||||
expect(mockApiClient.getWithSOF).toHaveBeenCalledWith(raceId);
|
||||
expect(mockSOFPresenter.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('importRaceResults', () => {
|
||||
it('should call API with correct parameters and return result', async () => {
|
||||
const mockInput = { results: [] };
|
||||
const mockSummary: ImportRaceResultsSummaryDto = {
|
||||
totalImported: 10,
|
||||
errors: [],
|
||||
describe('importResults', () => {
|
||||
it('should import race results and transform via presenter', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const input = {
|
||||
sessionId: 'session-456',
|
||||
results: [
|
||||
{ position: 1, driverId: 'driver-1', finishTime: 120000 },
|
||||
{ position: 2, driverId: 'driver-2', finishTime: 121000 },
|
||||
],
|
||||
};
|
||||
const mockDto: ImportRaceResultsSummaryDto = {
|
||||
success: true,
|
||||
raceId,
|
||||
driversProcessed: 2,
|
||||
resultsRecorded: 2,
|
||||
};
|
||||
const mockViewModel: ImportRaceResultsSummaryViewModel = {
|
||||
success: true,
|
||||
raceId,
|
||||
driversProcessed: 2,
|
||||
resultsRecorded: 2,
|
||||
};
|
||||
|
||||
vi.mocked(mockApiClient.importResults).mockResolvedValue(mockDto);
|
||||
vi.mocked(mockImportPresenter.present).mockReturnValue(mockViewModel);
|
||||
|
||||
// Act
|
||||
const result = await service.importResults(raceId, input);
|
||||
|
||||
// Assert
|
||||
expect(mockApiClient.importResults).toHaveBeenCalledWith(raceId, input);
|
||||
expect(mockImportPresenter.present).toHaveBeenCalledWith(mockDto);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
});
|
||||
|
||||
it('should handle import with errors', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const input = { sessionId: 'session-456', results: [] };
|
||||
const mockDto: ImportRaceResultsSummaryDto = {
|
||||
success: false,
|
||||
raceId,
|
||||
driversProcessed: 5,
|
||||
resultsRecorded: 3,
|
||||
errors: ['Driver not found: driver-99', 'Invalid time for driver-88'],
|
||||
};
|
||||
const mockViewModel: ImportRaceResultsSummaryViewModel = {
|
||||
success: false,
|
||||
raceId,
|
||||
driversProcessed: 5,
|
||||
resultsRecorded: 3,
|
||||
errors: ['Driver not found: driver-99', 'Invalid time for driver-88'],
|
||||
};
|
||||
|
||||
vi.mocked(api.races.importResults).mockResolvedValue(mockSummary);
|
||||
vi.mocked(mockApiClient.importResults).mockResolvedValue(mockDto);
|
||||
vi.mocked(mockImportPresenter.present).mockReturnValue(mockViewModel);
|
||||
|
||||
const result = await importRaceResults(raceId, mockInput);
|
||||
// Act
|
||||
const result = await service.importResults(raceId, input);
|
||||
|
||||
expect(api.races.importResults).toHaveBeenCalledWith(raceId, mockInput);
|
||||
expect(result).toBe(mockSummary);
|
||||
// Assert
|
||||
expect(mockApiClient.importResults).toHaveBeenCalledWith(raceId, input);
|
||||
expect(mockImportPresenter.present).toHaveBeenCalledWith(mockDto);
|
||||
expect(result).toEqual(mockViewModel);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should propagate errors from API client', async () => {
|
||||
// Arrange
|
||||
const raceId = 'race-123';
|
||||
const input = { sessionId: 'session-456', results: [] };
|
||||
const error = new Error('Import failed');
|
||||
vi.mocked(mockApiClient.importResults).mockRejectedValue(error);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.importResults(raceId, input)).rejects.toThrow('Import failed');
|
||||
expect(mockApiClient.importResults).toHaveBeenCalledWith(raceId, input);
|
||||
expect(mockImportPresenter.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,47 @@
|
||||
import { api as api } from '../../api';
|
||||
import { RaceResultsDetailPresenter, RaceWithSOFPresenter, ImportRaceResultsPresenter } from '../../presenters';
|
||||
import { RaceResultsDetailViewModel } from '../../view-models';
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceResultsDetailPresenter } from '../../presenters/RaceResultsDetailPresenter';
|
||||
import { RaceWithSOFPresenter } from '../../presenters/RaceWithSOFPresenter';
|
||||
import type { RaceWithSOFViewModel } from '../../presenters/RaceWithSOFPresenter';
|
||||
import { ImportRaceResultsPresenter } from '../../presenters/ImportRaceResultsPresenter';
|
||||
import type { ImportRaceResultsSummaryViewModel } from '../../presenters/ImportRaceResultsPresenter';
|
||||
import type { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
|
||||
import type { ImportRaceResultsInputDto } from '../../dtos';
|
||||
|
||||
/**
|
||||
* Race Results Service
|
||||
*
|
||||
* Orchestrates race results operations including viewing, importing, and SOF calculations.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class RaceResultsService {
|
||||
constructor(
|
||||
private readonly apiClient = api.races,
|
||||
private readonly resultsDetailPresenter = new RaceResultsDetailPresenter(),
|
||||
private readonly sofPresenter = new RaceWithSOFPresenter(),
|
||||
private readonly importPresenter = new ImportRaceResultsPresenter()
|
||||
private readonly apiClient: RacesApiClient,
|
||||
private readonly resultsDetailPresenter: RaceResultsDetailPresenter,
|
||||
private readonly sofPresenter: RaceWithSOFPresenter,
|
||||
private readonly importPresenter: ImportRaceResultsPresenter
|
||||
) {}
|
||||
|
||||
async importRaceResults(raceId: string, input: any): Promise<any> {
|
||||
const dto = await this.apiClient.importResults(raceId, input);
|
||||
return this.importPresenter.present(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get race results detail with presentation transformation
|
||||
*/
|
||||
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
|
||||
const dto = await this.apiClient.getResultsDetail(raceId);
|
||||
return this.resultsDetailPresenter.present(dto, currentUserId);
|
||||
}
|
||||
|
||||
async getWithSOF(raceId: string): Promise<any> {
|
||||
/**
|
||||
* Get race with strength of field calculation
|
||||
*/
|
||||
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
|
||||
const dto = await this.apiClient.getWithSOF(raceId);
|
||||
return this.sofPresenter.present(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const raceResultsService = new RaceResultsService();
|
||||
|
||||
// Backward compatibility functions
|
||||
export async function getRaceResults(
|
||||
raceId: string,
|
||||
currentUserId?: string
|
||||
): Promise<RaceResultsDetailViewModel> {
|
||||
return raceResultsService.getResultsDetail(raceId, currentUserId);
|
||||
}
|
||||
|
||||
export async function getRaceSOF(raceId: string): Promise<any> {
|
||||
return raceResultsService.getWithSOF(raceId);
|
||||
}
|
||||
|
||||
export async function importRaceResults(raceId: string, input: any): Promise<any> {
|
||||
return raceResultsService.importRaceResults(raceId, input);
|
||||
/**
|
||||
* Import race results and get summary
|
||||
*/
|
||||
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
|
||||
const dto = await this.apiClient.importResults(raceId, input);
|
||||
return this.importPresenter.present(dto);
|
||||
}
|
||||
}
|
||||
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 { RaceDetailPresenter } from '../../presenters';
|
||||
import { RaceDetailViewModel } from '../../view-models';
|
||||
import { RacesApiClient } from '../../api/races/RacesApiClient';
|
||||
import { RaceDetailPresenter } from '../../presenters/RaceDetailPresenter';
|
||||
import type { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
|
||||
import type { RacesPageDataDto, RaceStatsDto } from '../../dtos';
|
||||
|
||||
/**
|
||||
* Race Service
|
||||
*
|
||||
* Orchestrates race operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class RaceService {
|
||||
constructor(
|
||||
private readonly apiClient = api.races,
|
||||
private readonly presenter = new RaceDetailPresenter()
|
||||
private readonly apiClient: RacesApiClient,
|
||||
private readonly raceDetailPresenter: RaceDetailPresenter
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get race detail with presentation transformation
|
||||
*/
|
||||
async getRaceDetail(
|
||||
raceId: string,
|
||||
driverId: string
|
||||
): Promise<RaceDetailViewModel> {
|
||||
const dto = await this.apiClient.getDetail(raceId, driverId);
|
||||
return this.presenter.present(dto);
|
||||
return this.raceDetailPresenter.present(dto);
|
||||
}
|
||||
|
||||
async getRacesPageData(): Promise<any> {
|
||||
const dto = await this.apiClient.getPageData();
|
||||
// TODO: use presenter
|
||||
return dto;
|
||||
/**
|
||||
* Get races page data
|
||||
* TODO: Add presenter transformation when presenter is available
|
||||
*/
|
||||
async getRacesPageData(): Promise<RacesPageDataDto> {
|
||||
return this.apiClient.getPageData();
|
||||
}
|
||||
|
||||
async getRacesTotal(): Promise<any> {
|
||||
const dto = await this.apiClient.getTotal();
|
||||
return dto;
|
||||
/**
|
||||
* Get total races statistics
|
||||
* TODO: Add presenter transformation when presenter is available
|
||||
*/
|
||||
async getRacesTotal(): Promise<RaceStatsDto> {
|
||||
return this.apiClient.getTotal();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const raceService = new RaceService();
|
||||
|
||||
// Backward compatibility functions
|
||||
export async function getRaceDetail(
|
||||
raceId: string,
|
||||
driverId: string
|
||||
): Promise<RaceDetailViewModel> {
|
||||
return raceService.getRaceDetail(raceId, driverId);
|
||||
}
|
||||
|
||||
export async function getRacesPageData(): Promise<any> {
|
||||
return raceService.getRacesPageData();
|
||||
}
|
||||
|
||||
export async function getRacesTotal(): Promise<any> {
|
||||
return raceService.getRacesTotal();
|
||||
}
|
||||
@@ -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 { presentSponsor } from '../../presenters';
|
||||
import { SponsorViewModel } from '../../view-models';
|
||||
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
|
||||
import type { SponsorListPresenter } from '../../presenters/SponsorListPresenter';
|
||||
import type { SponsorDashboardPresenter } from '../../presenters/SponsorDashboardPresenter';
|
||||
import type { SponsorSponsorshipsPresenter } from '../../presenters/SponsorSponsorshipsPresenter';
|
||||
import type { SponsorViewModel, SponsorDashboardViewModel, SponsorSponsorshipsViewModel } from '../../view-models';
|
||||
import type { CreateSponsorInputDto, CreateSponsorOutputDto, GetEntitySponsorshipPricingResultDto } from '../../dtos';
|
||||
|
||||
export async function getAllSponsors(): Promise<SponsorViewModel[]> {
|
||||
const dto = await api.sponsors.getAll();
|
||||
return dto.sponsors.map(s => presentSponsor(s));
|
||||
}
|
||||
/**
|
||||
* Sponsor Service
|
||||
*
|
||||
* Orchestrates sponsor operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class SponsorService {
|
||||
constructor(
|
||||
private readonly apiClient: SponsorsApiClient,
|
||||
private readonly sponsorListPresenter: SponsorListPresenter,
|
||||
private readonly sponsorDashboardPresenter: SponsorDashboardPresenter,
|
||||
private readonly sponsorSponsorshipsPresenter: SponsorSponsorshipsPresenter
|
||||
) {}
|
||||
|
||||
export async function createSponsor(input: any): Promise<any> {
|
||||
return await api.sponsors.create(input);
|
||||
}
|
||||
/**
|
||||
* Get all sponsors with presentation transformation
|
||||
*/
|
||||
async getAllSponsors(): Promise<SponsorViewModel[]> {
|
||||
const dto = await this.apiClient.getAll();
|
||||
return this.sponsorListPresenter.present(dto);
|
||||
}
|
||||
|
||||
export async function getSponsorDashboard(sponsorId: string): Promise<any> {
|
||||
const dto = await api.sponsors.getDashboard(sponsorId);
|
||||
return dto;
|
||||
/**
|
||||
* Get sponsor dashboard with presentation transformation
|
||||
*/
|
||||
async getSponsorDashboard(sponsorId: string): Promise<SponsorDashboardViewModel | null> {
|
||||
const dto = await this.apiClient.getDashboard(sponsorId);
|
||||
if (!dto) {
|
||||
return null;
|
||||
}
|
||||
return this.sponsorDashboardPresenter.present(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sponsor sponsorships with presentation transformation
|
||||
*/
|
||||
async getSponsorSponsorships(sponsorId: string): Promise<SponsorSponsorshipsViewModel | null> {
|
||||
const dto = await this.apiClient.getSponsorships(sponsorId);
|
||||
if (!dto) {
|
||||
return null;
|
||||
}
|
||||
return this.sponsorSponsorshipsPresenter.present(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new sponsor
|
||||
*/
|
||||
async createSponsor(input: CreateSponsorInputDto): Promise<CreateSponsorOutputDto> {
|
||||
return await this.apiClient.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sponsorship pricing
|
||||
*/
|
||||
async getSponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDto> {
|
||||
return await this.apiClient.getPricing();
|
||||
}
|
||||
}
|
||||
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();
|
||||
return dto;
|
||||
}
|
||||
/**
|
||||
* Sponsorship Service
|
||||
*
|
||||
* Orchestrates sponsorship operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class SponsorshipService {
|
||||
constructor(
|
||||
private readonly apiClient: SponsorsApiClient,
|
||||
private readonly sponsorshipPricingPresenter: SponsorshipPricingPresenter,
|
||||
private readonly sponsorSponsorshipsPresenter: SponsorSponsorshipsPresenter
|
||||
) {}
|
||||
|
||||
export async function getSponsorSponsorships(sponsorId: string): Promise<any> {
|
||||
const dto = await api.sponsors.getSponsorships(sponsorId);
|
||||
return dto;
|
||||
/**
|
||||
* Get sponsorship pricing with presentation transformation
|
||||
*/
|
||||
async getSponsorshipPricing(): Promise<SponsorshipPricingViewModel> {
|
||||
const dto = await this.apiClient.getPricing();
|
||||
return this.sponsorshipPricingPresenter.present(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sponsor sponsorships with presentation transformation
|
||||
*/
|
||||
async getSponsorSponsorships(sponsorId: string): Promise<SponsorSponsorshipsViewModel | null> {
|
||||
const dto = await this.apiClient.getSponsorships(sponsorId);
|
||||
if (!dto) {
|
||||
return null;
|
||||
}
|
||||
return this.sponsorSponsorshipsPresenter.present(dto);
|
||||
}
|
||||
}
|
||||
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 { presentTeamJoinRequest } from '../../presenters';
|
||||
import { TeamJoinRequestViewModel } from '../../view-models';
|
||||
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
|
||||
import type { TeamJoinRequestPresenter } from '../../presenters/TeamJoinRequestPresenter';
|
||||
import type { TeamJoinRequestViewModel } from '../../view-models';
|
||||
|
||||
export async function getTeamJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
|
||||
const dto = await api.teams.getJoinRequests(teamId);
|
||||
return dto.requests.map(r => presentTeamJoinRequest(r, currentUserId, isOwner));
|
||||
}
|
||||
/**
|
||||
* Team Join Service
|
||||
*
|
||||
* Orchestrates team join/leave operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class TeamJoinService {
|
||||
constructor(
|
||||
private readonly apiClient: TeamsApiClient,
|
||||
private readonly teamJoinRequestPresenter: TeamJoinRequestPresenter
|
||||
) {}
|
||||
|
||||
export async function approveTeamJoinRequest(teamId: string, requestId: string): Promise<void> {
|
||||
// TODO: implement API call
|
||||
}
|
||||
/**
|
||||
* Get team join requests with presentation transformation
|
||||
*/
|
||||
async getJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
|
||||
try {
|
||||
const dto = await this.apiClient.getJoinRequests(teamId);
|
||||
return dto.requests.map(r => this.teamJoinRequestPresenter.present(r, currentUserId, isOwner));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectTeamJoinRequest(teamId: string, requestId: string): Promise<void> {
|
||||
// TODO: implement API call
|
||||
/**
|
||||
* Approve a team join request
|
||||
*/
|
||||
async approveJoinRequest(teamId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
// TODO: implement API call when endpoint is available
|
||||
throw new Error('Not implemented: API endpoint for approving join requests');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a team join request
|
||||
*/
|
||||
async rejectJoinRequest(teamId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
// TODO: implement API call when endpoint is available
|
||||
throw new Error('Not implemented: API endpoint for rejecting join requests');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 { presentTeamDetails, presentTeamMember, presentTeamSummary } from '../../presenters';
|
||||
import { TeamDetailsViewModel, TeamMemberViewModel, TeamSummaryViewModel } from '../../view-models';
|
||||
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
|
||||
import type { TeamDetailsPresenter } from '../../presenters/TeamDetailsPresenter';
|
||||
import type { TeamListPresenter } from '../../presenters/TeamListPresenter';
|
||||
import type { TeamMembersPresenter } from '../../presenters/TeamMembersPresenter';
|
||||
import type { TeamSummaryViewModel, TeamDetailsViewModel, TeamMemberViewModel } from '../../view-models';
|
||||
import type { CreateTeamInputDto, CreateTeamOutputDto, UpdateTeamInputDto, UpdateTeamOutputDto, DriverTeamDto } from '../../dtos';
|
||||
|
||||
export async function getAllTeams(): Promise<TeamSummaryViewModel[]> {
|
||||
const dto = await api.teams.getAll();
|
||||
return dto.teams.map(t => presentTeamSummary(t));
|
||||
}
|
||||
/**
|
||||
* Team Service
|
||||
*
|
||||
* Orchestrates team operations by coordinating API calls and presentation logic.
|
||||
* All dependencies are injected via constructor.
|
||||
*/
|
||||
export class TeamService {
|
||||
constructor(
|
||||
private readonly apiClient: TeamsApiClient,
|
||||
private readonly teamListPresenter: TeamListPresenter,
|
||||
private readonly teamDetailsPresenter: TeamDetailsPresenter,
|
||||
private readonly teamMembersPresenter: TeamMembersPresenter
|
||||
) {}
|
||||
|
||||
export async function getTeamDetails(teamId: string): Promise<TeamDetailsViewModel | null> {
|
||||
const dto = await api.teams.getDetails(teamId);
|
||||
return dto ? presentTeamDetails(dto) : null;
|
||||
}
|
||||
/**
|
||||
* Get all teams with presentation transformation
|
||||
*/
|
||||
async getAllTeams(): Promise<TeamSummaryViewModel[]> {
|
||||
const dto = await this.apiClient.getAll();
|
||||
return this.teamListPresenter.present(dto);
|
||||
}
|
||||
|
||||
export async function getTeamMembers(teamId: string): Promise<TeamMemberViewModel[]> {
|
||||
const dto = await api.teams.getMembers(teamId);
|
||||
return dto.members.map(m => presentTeamMember(m));
|
||||
}
|
||||
/**
|
||||
* Get team details with presentation transformation
|
||||
*/
|
||||
async getTeamDetails(teamId: string, currentUserId: string): Promise<TeamDetailsViewModel | null> {
|
||||
const dto = await this.apiClient.getDetails(teamId);
|
||||
if (!dto) {
|
||||
return null;
|
||||
}
|
||||
return this.teamDetailsPresenter.present(dto, currentUserId);
|
||||
}
|
||||
|
||||
export async function createTeam(input: any): Promise<any> {
|
||||
return await api.teams.create(input);
|
||||
}
|
||||
/**
|
||||
* Get team members with presentation transformation
|
||||
*/
|
||||
async getTeamMembers(teamId: string, currentUserId: string, teamOwnerId: string): Promise<TeamMemberViewModel[]> {
|
||||
const dto = await this.apiClient.getMembers(teamId);
|
||||
return this.teamMembersPresenter.present(dto, currentUserId, teamOwnerId);
|
||||
}
|
||||
|
||||
export async function updateTeam(teamId: string, input: any): Promise<any> {
|
||||
return await api.teams.update(teamId, input);
|
||||
}
|
||||
/**
|
||||
* Create a new team
|
||||
*/
|
||||
async createTeam(input: CreateTeamInputDto): Promise<CreateTeamOutputDto> {
|
||||
return await this.apiClient.create(input);
|
||||
}
|
||||
|
||||
export async function getDriverTeam(driverId: string): Promise<any> {
|
||||
return await api.teams.getDriverTeam(driverId);
|
||||
/**
|
||||
* Update team
|
||||
*/
|
||||
async updateTeam(teamId: string, input: UpdateTeamInputDto): Promise<UpdateTeamOutputDto> {
|
||||
return await this.apiClient.update(teamId, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get driver's team
|
||||
*/
|
||||
async getDriverTeam(driverId: string): Promise<DriverTeamDto | null> {
|
||||
return await this.apiClient.getDriverTeam(driverId);
|
||||
}
|
||||
}
|
||||
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
|
||||
export { SponsorViewModel } from './SponsorViewModel';
|
||||
export { SponsorDashboardViewModel } from './SponsorDashboardViewModel';
|
||||
export { SponsorSponsorshipsViewModel } from './SponsorSponsorshipsViewModel';
|
||||
export { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
|
||||
export { SponsorshipPricingViewModel } from './SponsorshipPricingViewModel';
|
||||
|
||||
// Team ViewModels
|
||||
export { TeamDetailsViewModel } from './TeamDetailsViewModel';
|
||||
|
||||
Reference in New Issue
Block a user