diff --git a/apps/website/lib/api/analytics/AnalyticsApiClient.ts b/apps/website/lib/api/analytics/AnalyticsApiClient.ts index 93bda97e7..9de69a6c8 100644 --- a/apps/website/lib/api/analytics/AnalyticsApiClient.ts +++ b/apps/website/lib/api/analytics/AnalyticsApiClient.ts @@ -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 { return this.post('/analytics/engagement', input); } + + /** Get analytics dashboard data */ + getDashboardData(): Promise { + return this.get('/analytics/dashboard'); + } + + /** Get analytics metrics */ + getAnalyticsMetrics(): Promise { + return this.get('/analytics/metrics'); + } } \ No newline at end of file diff --git a/apps/website/lib/api/drivers/DriversApiClient.ts b/apps/website/lib/api/drivers/DriversApiClient.ts index f74e2cfbc..44b5575a3 100644 --- a/apps/website/lib/api/drivers/DriversApiClient.ts +++ b/apps/website/lib/api/drivers/DriversApiClient.ts @@ -4,6 +4,7 @@ import type { CompleteOnboardingInputDto, CompleteOnboardingOutputDto, DriverDto, + DriverRegistrationStatusDto, } from '../../dtos'; /** @@ -26,4 +27,9 @@ export class DriversApiClient extends BaseApiClient { getCurrent(): Promise { return this.get('/drivers/current'); } + + /** Get driver registration status for a specific race */ + getRegistrationStatus(driverId: string, raceId: string): Promise { + return this.get(`/drivers/${driverId}/races/${raceId}/registration-status`); + } } \ No newline at end of file diff --git a/apps/website/lib/api/media/MediaApiClient.ts b/apps/website/lib/api/media/MediaApiClient.ts index 6499187f4..d96665667 100644 --- a/apps/website/lib/api/media/MediaApiClient.ts +++ b/apps/website/lib/api/media/MediaApiClient.ts @@ -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 { + 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('/media/upload', formData); + } + + /** Get media by ID */ + getMedia(mediaId: string): Promise { + return this.get(`/media/${mediaId}`); + } + + /** Delete media by ID */ + deleteMedia(mediaId: string): Promise { + return this.delete(`/media/${mediaId}`); + } + /** Request avatar generation */ requestAvatarGeneration(input: RequestAvatarGenerationInputDto): Promise { return this.post('/media/avatar/generate', input); } + + /** Get avatar for driver */ + getAvatar(driverId: string): Promise { + return this.get(`/media/avatar/${driverId}`); + } + + /** Update avatar for driver */ + updateAvatar(input: UpdateAvatarInputDto): Promise { + return this.put(`/media/avatar/${input.driverId}`, { avatarUrl: input.avatarUrl }); + } } \ No newline at end of file diff --git a/apps/website/lib/api/payments/PaymentsApiClient.ts b/apps/website/lib/api/payments/PaymentsApiClient.ts index e55d9cead..ae9863d99 100644 --- a/apps/website/lib/api/payments/PaymentsApiClient.ts +++ b/apps/website/lib/api/payments/PaymentsApiClient.ts @@ -6,6 +6,11 @@ import type { GetMembershipFeesOutputDto, GetPrizesOutputDto, GetWalletOutputDto, + ProcessWalletTransactionInputDto, + ProcessWalletTransactionOutputDto, + UpdateMemberPaymentInputDto, + UpdateMemberPaymentOutputDto, + GetWalletTransactionsOutputDto, } from '../../dtos'; /** diff --git a/apps/website/lib/dtos/AnalyticsDashboardDto.ts b/apps/website/lib/dtos/AnalyticsDashboardDto.ts new file mode 100644 index 000000000..4cabc95f3 --- /dev/null +++ b/apps/website/lib/dtos/AnalyticsDashboardDto.ts @@ -0,0 +1,6 @@ +export interface AnalyticsDashboardDto { + totalUsers: number; + activeUsers: number; + totalRaces: number; + totalLeagues: number; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/AnalyticsMetricsDto.ts b/apps/website/lib/dtos/AnalyticsMetricsDto.ts new file mode 100644 index 000000000..50e2f301e --- /dev/null +++ b/apps/website/lib/dtos/AnalyticsMetricsDto.ts @@ -0,0 +1,6 @@ +export interface AnalyticsMetricsDto { + pageViews: number; + uniqueVisitors: number; + averageSessionDuration: number; + bounceRate: number; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/DeleteMediaOutputDto.ts b/apps/website/lib/dtos/DeleteMediaOutputDto.ts new file mode 100644 index 000000000..fba43476f --- /dev/null +++ b/apps/website/lib/dtos/DeleteMediaOutputDto.ts @@ -0,0 +1,8 @@ +/** + * Delete media output data transfer object + * Output from deleting media + */ +export interface DeleteMediaOutputDto { + success: boolean; + error?: string; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/GetAvatarOutputDto.ts b/apps/website/lib/dtos/GetAvatarOutputDto.ts new file mode 100644 index 000000000..ef5d25834 --- /dev/null +++ b/apps/website/lib/dtos/GetAvatarOutputDto.ts @@ -0,0 +1,9 @@ +/** + * Get avatar output data transfer object + * Output from getting avatar information + */ +export interface GetAvatarOutputDto { + driverId: string; + avatarUrl?: string; + hasAvatar: boolean; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/GetMediaOutputDto.ts b/apps/website/lib/dtos/GetMediaOutputDto.ts new file mode 100644 index 000000000..1f00809d2 --- /dev/null +++ b/apps/website/lib/dtos/GetMediaOutputDto.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/UpdateAvatarInputDto.ts b/apps/website/lib/dtos/UpdateAvatarInputDto.ts new file mode 100644 index 000000000..aea0fe9b3 --- /dev/null +++ b/apps/website/lib/dtos/UpdateAvatarInputDto.ts @@ -0,0 +1,8 @@ +/** + * Update avatar input data transfer object + * Input for updating driver avatar + */ +export interface UpdateAvatarInputDto { + driverId: string; + avatarUrl: string; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/UpdateAvatarOutputDto.ts b/apps/website/lib/dtos/UpdateAvatarOutputDto.ts new file mode 100644 index 000000000..448c5d0b6 --- /dev/null +++ b/apps/website/lib/dtos/UpdateAvatarOutputDto.ts @@ -0,0 +1,8 @@ +/** + * Update avatar output data transfer object + * Output from updating avatar + */ +export interface UpdateAvatarOutputDto { + success: boolean; + error?: string; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/UploadMediaInputDto.ts b/apps/website/lib/dtos/UploadMediaInputDto.ts new file mode 100644 index 000000000..19e283910 --- /dev/null +++ b/apps/website/lib/dtos/UploadMediaInputDto.ts @@ -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'; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/UploadMediaOutputDto.ts b/apps/website/lib/dtos/UploadMediaOutputDto.ts new file mode 100644 index 000000000..5b4699514 --- /dev/null +++ b/apps/website/lib/dtos/UploadMediaOutputDto.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/dtos/index.ts b/apps/website/lib/dtos/index.ts deleted file mode 100644 index e181d0922..000000000 --- a/apps/website/lib/dtos/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/apps/website/lib/presenters/AnalyticsDashboardPresenter.ts b/apps/website/lib/presenters/AnalyticsDashboardPresenter.ts new file mode 100644 index 000000000..5deb526dc --- /dev/null +++ b/apps/website/lib/presenters/AnalyticsDashboardPresenter.ts @@ -0,0 +1,8 @@ +import { AnalyticsDashboardDto } from '../dtos'; +import { AnalyticsDashboardViewModel } from '../view-models'; + +export class AnalyticsDashboardPresenter { + present(dto: AnalyticsDashboardDto): AnalyticsDashboardViewModel { + return new AnalyticsDashboardViewModel(dto); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/AnalyticsMetricsPresenter.ts b/apps/website/lib/presenters/AnalyticsMetricsPresenter.ts new file mode 100644 index 000000000..dec0ce818 --- /dev/null +++ b/apps/website/lib/presenters/AnalyticsMetricsPresenter.ts @@ -0,0 +1,8 @@ +import { AnalyticsMetricsDto } from '../dtos'; +import { AnalyticsMetricsViewModel } from '../view-models'; + +export class AnalyticsMetricsPresenter { + present(dto: AnalyticsMetricsDto): AnalyticsMetricsViewModel { + return new AnalyticsMetricsViewModel(dto); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/AvatarPresenter.ts b/apps/website/lib/presenters/AvatarPresenter.ts new file mode 100644 index 000000000..6ef1f5796 --- /dev/null +++ b/apps/website/lib/presenters/AvatarPresenter.ts @@ -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, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/CompleteOnboardingPresenter.ts b/apps/website/lib/presenters/CompleteOnboardingPresenter.ts new file mode 100644 index 000000000..d630db6e5 --- /dev/null +++ b/apps/website/lib/presenters/CompleteOnboardingPresenter.ts @@ -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, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/DriverPresenter.ts b/apps/website/lib/presenters/DriverPresenter.ts new file mode 100644 index 000000000..59bfdb8ae --- /dev/null +++ b/apps/website/lib/presenters/DriverPresenter.ts @@ -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, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/DriverRegistrationStatusPresenter.ts b/apps/website/lib/presenters/DriverRegistrationStatusPresenter.ts index 3a6ce7f7e..068350943 100644 --- a/apps/website/lib/presenters/DriverRegistrationStatusPresenter.ts +++ b/apps/website/lib/presenters/DriverRegistrationStatusPresenter.ts @@ -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); }; \ No newline at end of file diff --git a/apps/website/lib/presenters/DriversLeaderboardPresenter.ts b/apps/website/lib/presenters/DriversLeaderboardPresenter.ts index 139ef3abd..8a69c14ef 100644 --- a/apps/website/lib/presenters/DriversLeaderboardPresenter.ts +++ b/apps/website/lib/presenters/DriversLeaderboardPresenter.ts @@ -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); }; \ No newline at end of file diff --git a/apps/website/lib/presenters/ImportRaceResultsPresenter.ts b/apps/website/lib/presenters/ImportRaceResultsPresenter.ts index 7cf8a5fa6..fdb885530 100644 --- a/apps/website/lib/presenters/ImportRaceResultsPresenter.ts +++ b/apps/website/lib/presenters/ImportRaceResultsPresenter.ts @@ -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, + }; } } \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueMembersPresenter.ts b/apps/website/lib/presenters/LeagueMembersPresenter.ts new file mode 100644 index 000000000..b0551be8d --- /dev/null +++ b/apps/website/lib/presenters/LeagueMembersPresenter.ts @@ -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)); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueStandingsPresenter.ts b/apps/website/lib/presenters/LeagueStandingsPresenter.ts index 29e4f4fa8..687475f32 100644 --- a/apps/website/lib/presenters/LeagueStandingsPresenter.ts +++ b/apps/website/lib/presenters/LeagueStandingsPresenter.ts @@ -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); }; \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueSummaryPresenter.ts b/apps/website/lib/presenters/LeagueSummaryPresenter.ts index 3a563773e..4b83aa8ef 100644 --- a/apps/website/lib/presenters/LeagueSummaryPresenter.ts +++ b/apps/website/lib/presenters/LeagueSummaryPresenter.ts @@ -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); }; \ No newline at end of file diff --git a/apps/website/lib/presenters/MediaPresenter.ts b/apps/website/lib/presenters/MediaPresenter.ts new file mode 100644 index 000000000..691c96f31 --- /dev/null +++ b/apps/website/lib/presenters/MediaPresenter.ts @@ -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, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/PaymentListPresenter.ts b/apps/website/lib/presenters/PaymentListPresenter.ts new file mode 100644 index 000000000..5b665d7c4 --- /dev/null +++ b/apps/website/lib/presenters/PaymentListPresenter.ts @@ -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)); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/RaceWithSOFPresenter.ts b/apps/website/lib/presenters/RaceWithSOFPresenter.ts index a582c90bc..ad85d2ce7 100644 --- a/apps/website/lib/presenters/RaceWithSOFPresenter.ts +++ b/apps/website/lib/presenters/RaceWithSOFPresenter.ts @@ -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, }; } } \ No newline at end of file diff --git a/apps/website/lib/presenters/SessionPresenter.ts b/apps/website/lib/presenters/SessionPresenter.ts new file mode 100644 index 000000000..7341fa99e --- /dev/null +++ b/apps/website/lib/presenters/SessionPresenter.ts @@ -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); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/SponsorDashboardPresenter.ts b/apps/website/lib/presenters/SponsorDashboardPresenter.ts index ed9287d24..49694ed10 100644 --- a/apps/website/lib/presenters/SponsorDashboardPresenter.ts +++ b/apps/website/lib/presenters/SponsorDashboardPresenter.ts @@ -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); } } \ No newline at end of file diff --git a/apps/website/lib/presenters/SponsorListPresenter.ts b/apps/website/lib/presenters/SponsorListPresenter.ts new file mode 100644 index 000000000..8c9f9f719 --- /dev/null +++ b/apps/website/lib/presenters/SponsorListPresenter.ts @@ -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)); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/SponsorPresenter.ts b/apps/website/lib/presenters/SponsorPresenter.ts index 3a6963bef..4b9073c4e 100644 --- a/apps/website/lib/presenters/SponsorPresenter.ts +++ b/apps/website/lib/presenters/SponsorPresenter.ts @@ -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); -}; \ No newline at end of file +/** + * Sponsor Presenter + * + * Transforms sponsor DTOs into view models. + */ +export class SponsorPresenter { + present(dto: SponsorDto): SponsorViewModel { + return new SponsorViewModel(dto); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/SponsorSponsorshipsPresenter.ts b/apps/website/lib/presenters/SponsorSponsorshipsPresenter.ts index f7bf0c971..4a6b2bec1 100644 --- a/apps/website/lib/presenters/SponsorSponsorshipsPresenter.ts +++ b/apps/website/lib/presenters/SponsorSponsorshipsPresenter.ts @@ -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); } } \ No newline at end of file diff --git a/apps/website/lib/presenters/SponsorshipPricingPresenter.ts b/apps/website/lib/presenters/SponsorshipPricingPresenter.ts new file mode 100644 index 000000000..2edd97109 --- /dev/null +++ b/apps/website/lib/presenters/SponsorshipPricingPresenter.ts @@ -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); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamDetailsPresenter.ts b/apps/website/lib/presenters/TeamDetailsPresenter.ts index c05463141..8635da63c 100644 --- a/apps/website/lib/presenters/TeamDetailsPresenter.ts +++ b/apps/website/lib/presenters/TeamDetailsPresenter.ts @@ -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); -}; \ No newline at end of file +/** + * Team Details Presenter + * Transforms TeamDetailsDto to TeamDetailsViewModel + */ +export class TeamDetailsPresenter { + present(dto: TeamDetailsDto, currentUserId: string): TeamDetailsViewModel { + return new TeamDetailsViewModel(dto, currentUserId); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamJoinRequestPresenter.ts b/apps/website/lib/presenters/TeamJoinRequestPresenter.ts index de6824d30..cb523e814 100644 --- a/apps/website/lib/presenters/TeamJoinRequestPresenter.ts +++ b/apps/website/lib/presenters/TeamJoinRequestPresenter.ts @@ -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); }; \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamListPresenter.ts b/apps/website/lib/presenters/TeamListPresenter.ts new file mode 100644 index 000000000..bb850dfda --- /dev/null +++ b/apps/website/lib/presenters/TeamListPresenter.ts @@ -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)); + } +} \ No newline at end of file diff --git a/apps/website/lib/presenters/TeamMembersPresenter.ts b/apps/website/lib/presenters/TeamMembersPresenter.ts index f67839806..39a499937 100644 --- a/apps/website/lib/presenters/TeamMembersPresenter.ts +++ b/apps/website/lib/presenters/TeamMembersPresenter.ts @@ -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)); } } \ No newline at end of file diff --git a/apps/website/lib/presenters/index.ts b/apps/website/lib/presenters/index.ts deleted file mode 100644 index 6b21333d0..000000000 --- a/apps/website/lib/presenters/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/apps/website/lib/services/ServiceFactory.test.ts b/apps/website/lib/services/ServiceFactory.test.ts new file mode 100644 index 000000000..2f289e2a2 --- /dev/null +++ b/apps/website/lib/services/ServiceFactory.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/ServiceFactory.ts b/apps/website/lib/services/ServiceFactory.ts new file mode 100644 index 000000000..3f87db761 --- /dev/null +++ b/apps/website/lib/services/ServiceFactory.ts @@ -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 + ); + } +} \ No newline at end of file diff --git a/apps/website/lib/services/analytics/AnalyticsService.test.ts b/apps/website/lib/services/analytics/AnalyticsService.test.ts new file mode 100644 index 000000000..0f0f3762f --- /dev/null +++ b/apps/website/lib/services/analytics/AnalyticsService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/analytics/AnalyticsService.ts b/apps/website/lib/services/analytics/AnalyticsService.ts index aece2d12f..207612c57 100644 --- a/apps/website/lib/services/analytics/AnalyticsService.ts +++ b/apps/website/lib/services/analytics/AnalyticsService.ts @@ -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 { - 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 { - return await api.analytics.recordEngagement(input); + /** + * Record a page view + */ + async recordPageView(input: RecordPageViewInputDto): Promise { + try { + return await this.apiClient.recordPageView(input); + } catch (error) { + throw error; + } + } + + /** + * Record an engagement event + */ + async recordEngagement(input: RecordEngagementInputDto): Promise { + try { + return await this.apiClient.recordEngagement(input); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/analytics/DashboardService.test.ts b/apps/website/lib/services/analytics/DashboardService.test.ts new file mode 100644 index 000000000..ccbd83b85 --- /dev/null +++ b/apps/website/lib/services/analytics/DashboardService.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/analytics/DashboardService.ts b/apps/website/lib/services/analytics/DashboardService.ts index b31505b46..1045cbb28 100644 --- a/apps/website/lib/services/analytics/DashboardService.ts +++ b/apps/website/lib/services/analytics/DashboardService.ts @@ -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 { - // 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 { + 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 { + 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 { + return this.getDashboardData(); + } } \ No newline at end of file diff --git a/apps/website/lib/services/auth/AuthService.test.ts b/apps/website/lib/services/auth/AuthService.test.ts new file mode 100644 index 000000000..654e8171b --- /dev/null +++ b/apps/website/lib/services/auth/AuthService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/auth/AuthService.ts b/apps/website/lib/services/auth/AuthService.ts index f8b82a8d5..da0a8005c 100644 --- a/apps/website/lib/services/auth/AuthService.ts +++ b/apps/website/lib/services/auth/AuthService.ts @@ -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 { - 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 { - return await api.auth.login(params); -} + /** + * Sign up a new user + */ + async signup(params: SignupParamsDto): Promise { + try { + return await this.apiClient.signup(params); + } catch (error) { + throw error; + } + } -export async function logout(): Promise { - await api.auth.logout(); -} + /** + * Log in an existing user + */ + async login(params: LoginParamsDto): Promise { + 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 { + try { + await this.apiClient.logout(); + } catch (error) { + throw error; + } + } + + /** + * Get iRacing authentication URL + */ + getIracingAuthUrl(returnTo?: string): string { + return this.apiClient.getIracingAuthUrl(returnTo); + } } \ No newline at end of file diff --git a/apps/website/lib/services/auth/SessionService.test.ts b/apps/website/lib/services/auth/SessionService.test.ts new file mode 100644 index 000000000..f579b1bfc --- /dev/null +++ b/apps/website/lib/services/auth/SessionService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/auth/SessionService.ts b/apps/website/lib/services/auth/SessionService.ts index 061866aa1..4a0476169 100644 --- a/apps/website/lib/services/auth/SessionService.ts +++ b/apps/website/lib/services/auth/SessionService.ts @@ -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 { - 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 { + try { + const dto = await this.apiClient.getSession(); + return this.presenter.presentSession(dto); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverRegistrationService.test.ts b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts new file mode 100644 index 000000000..32350d993 --- /dev/null +++ b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts @@ -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 + ); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverRegistrationService.ts b/apps/website/lib/services/drivers/DriverRegistrationService.ts index 1650f4ad1..df2806b41 100644 --- a/apps/website/lib/services/drivers/DriverRegistrationService.ts +++ b/apps/website/lib/services/drivers/DriverRegistrationService.ts @@ -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 { - // 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 { - // TODO: implement - return {}; + /** + * Get driver registration status for a specific race + */ + async getDriverRegistrationStatus( + driverId: string, + raceId: string + ): Promise { + const dto = await this.apiClient.getRegistrationStatus(driverId, raceId); + return this.statusPresenter.present(dto); + } } \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverService.test.ts b/apps/website/lib/services/drivers/DriverService.test.ts new file mode 100644 index 000000000..7bf8f8b16 --- /dev/null +++ b/apps/website/lib/services/drivers/DriverService.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverService.ts b/apps/website/lib/services/drivers/DriverService.ts index bfee52798..50941ec78 100644 --- a/apps/website/lib/services/drivers/DriverService.ts +++ b/apps/website/lib/services/drivers/DriverService.ts @@ -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 { const dto = await this.apiClient.getLeaderboard(); - return presentDriversLeaderboard(dto); + return this.leaderboardPresenter.present(dto); } - async completeDriverOnboarding(input: any): Promise { - return await this.apiClient.completeOnboarding(input); + /** + * Complete driver onboarding with presentation transformation + */ + async completeDriverOnboarding(input: CompleteOnboardingInputDto): Promise { + const dto = await this.apiClient.completeOnboarding(input); + return this.onboardingPresenter.present(dto); } - async getCurrentDriver(): Promise { - return await this.apiClient.getCurrent(); + /** + * Get current driver with presentation transformation + */ + async getCurrentDriver(): Promise { + 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 { - return driverService.getDriverLeaderboard(); -} - -export async function completeDriverOnboarding(input: any): Promise { - return driverService.completeDriverOnboarding(input); -} - -export async function getCurrentDriver(): Promise { - return driverService.getCurrentDriver(); } \ No newline at end of file diff --git a/apps/website/lib/services/drivers/index.ts b/apps/website/lib/services/drivers/index.ts deleted file mode 100644 index e35bfa9e3..000000000 --- a/apps/website/lib/services/drivers/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/apps/website/lib/services/index.ts b/apps/website/lib/services/index.ts deleted file mode 100644 index 6091025c9..000000000 --- a/apps/website/lib/services/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.test.ts b/apps/website/lib/services/leagues/LeagueMembershipService.test.ts new file mode 100644 index 000000000..300ec0fc0 --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueMembershipService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.ts b/apps/website/lib/services/leagues/LeagueMembershipService.ts index de5fe3400..bcb9eeaf5 100644 --- a/apps/website/lib/services/leagues/LeagueMembershipService.ts +++ b/apps/website/lib/services/leagues/LeagueMembershipService.ts @@ -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 { - 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 { - await api.leagues.removeMember(leagueId, performerDriverId, targetDriverId); + /** + * Get league memberships with presentation transformation + */ + async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { + 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); + } } \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueService.test.ts b/apps/website/lib/services/leagues/LeagueService.test.ts new file mode 100644 index 000000000..462ab97b1 --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index 4d615d403..fcab7f348 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -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 { - 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 { - 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 { + const dto = await this.apiClient.getAllWithCapacity(); + return this.leagueSummaryPresenter.present(dto); + } -export async function createLeague(input: any): Promise { - return await api.leagues.create(input); -} + /** + * Get league standings with presentation transformation + */ + async getLeagueStandings(leagueId: string, currentUserId: string): Promise { + 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 { - // TODO: implement - return {}; + /** + * Get league statistics + */ + async getLeagueStats(): Promise { + return await this.apiClient.getTotal(); + } + + /** + * Get league schedule + */ + async getLeagueSchedule(leagueId: string): Promise { + return await this.apiClient.getSchedule(leagueId); + } + + /** + * Get league memberships + */ + async getLeagueMemberships(leagueId: string): Promise { + return await this.apiClient.getMemberships(leagueId); + } + + /** + * Create a new league + */ + async createLeague(input: CreateLeagueInputDto): Promise { + 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); + } } \ No newline at end of file diff --git a/apps/website/lib/services/media/AvatarService.test.ts b/apps/website/lib/services/media/AvatarService.test.ts new file mode 100644 index 000000000..d7da1c217 --- /dev/null +++ b/apps/website/lib/services/media/AvatarService.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/media/AvatarService.ts b/apps/website/lib/services/media/AvatarService.ts index d6154da42..d4226c801 100644 --- a/apps/website/lib/services/media/AvatarService.ts +++ b/apps/website/lib/services/media/AvatarService.ts @@ -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 { - 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 { + 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 { + 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 { + try { + const dto = await this.apiClient.updateAvatar(input); + return this.presenter.presentUpdate(dto); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/media/MediaService.test.ts b/apps/website/lib/services/media/MediaService.test.ts new file mode 100644 index 000000000..dd719be51 --- /dev/null +++ b/apps/website/lib/services/media/MediaService.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/media/MediaService.ts b/apps/website/lib/services/media/MediaService.ts index 8abe790cb..7bf4f3fdd 100644 --- a/apps/website/lib/services/media/MediaService.ts +++ b/apps/website/lib/services/media/MediaService.ts @@ -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 { - // 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 { + 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 { + 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 { + try { + const dto = await this.apiClient.deleteMedia(mediaId); + return this.presenter.presentDelete(dto); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/payments/MembershipFeeService.ts b/apps/website/lib/services/payments/MembershipFeeService.ts index 41838bfe5..b1665c5b9 100644 --- a/apps/website/lib/services/payments/MembershipFeeService.ts +++ b/apps/website/lib/services/payments/MembershipFeeService.ts @@ -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 { - 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 { + try { + const dto = await this.apiClient.getMembershipFees(leagueId); + return dto.fees.map(presentMembershipFee); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/payments/PaymentService.test.ts b/apps/website/lib/services/payments/PaymentService.test.ts new file mode 100644 index 000000000..e38c30d4f --- /dev/null +++ b/apps/website/lib/services/payments/PaymentService.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/payments/PaymentService.ts b/apps/website/lib/services/payments/PaymentService.ts index 8543b304c..c07db6919 100644 --- a/apps/website/lib/services/payments/PaymentService.ts +++ b/apps/website/lib/services/payments/PaymentService.ts @@ -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 { - 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 { - return await api.payments.createPayment(input); + /** + * Get all payments with optional filters + */ + async getPayments(leagueId?: string, driverId?: string): Promise { + 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 { + 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 { + try { + return await this.apiClient.createPayment(input); + } catch (error) { + throw error; + } + } + + /** + * Get membership fees for a league + */ + async getMembershipFees(leagueId: string): Promise { + 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 { + 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 { + 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 { + try { + return await this.createPayment(input); + } catch (error) { + throw error; + } + } + + /** + * Get payment history for a user (driver) + */ + async getPaymentHistory(driverId: string): Promise { + try { + return await this.getPayments(undefined, driverId); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/payments/WalletService.test.ts b/apps/website/lib/services/payments/WalletService.test.ts new file mode 100644 index 000000000..c96911d5e --- /dev/null +++ b/apps/website/lib/services/payments/WalletService.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/payments/WalletService.ts b/apps/website/lib/services/payments/WalletService.ts index 110e3a9bd..423196324 100644 --- a/apps/website/lib/services/payments/WalletService.ts +++ b/apps/website/lib/services/payments/WalletService.ts @@ -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 { - 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 { + try { + const dto = await this.apiClient.getWallet(driverId); + return presentWallet(dto); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceResultsService.test.ts b/apps/website/lib/services/races/RaceResultsService.test.ts index 12faa803e..d3c7bce87 100644 --- a/apps/website/lib/services/races/RaceResultsService.test.ts +++ b/apps/website/lib/services/races/RaceResultsService.test.ts @@ -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 = { + raceId, + results: [], + }; + const mockViewModel: Partial = { + 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 = { + raceId, + results: [], + }; + const mockViewModel: Partial = { + 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(); }); }); }); \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceResultsService.ts b/apps/website/lib/services/races/RaceResultsService.ts index 29766744e..c299d5fd7 100644 --- a/apps/website/lib/services/races/RaceResultsService.ts +++ b/apps/website/lib/services/races/RaceResultsService.ts @@ -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 { - 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 { const dto = await this.apiClient.getResultsDetail(raceId); return this.resultsDetailPresenter.present(dto, currentUserId); } - async getWithSOF(raceId: string): Promise { + /** + * Get race with strength of field calculation + */ + async getWithSOF(raceId: string): Promise { 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 { - return raceResultsService.getResultsDetail(raceId, currentUserId); -} - -export async function getRaceSOF(raceId: string): Promise { - return raceResultsService.getWithSOF(raceId); -} - -export async function importRaceResults(raceId: string, input: any): Promise { - return raceResultsService.importRaceResults(raceId, input); + /** + * Import race results and get summary + */ + async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise { + const dto = await this.apiClient.importResults(raceId, input); + return this.importPresenter.present(dto); + } } \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.test.ts b/apps/website/lib/services/races/RaceService.test.ts new file mode 100644 index 000000000..f59e56182 --- /dev/null +++ b/apps/website/lib/services/races/RaceService.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.ts b/apps/website/lib/services/races/RaceService.ts index b7d74d6ef..32d163456 100644 --- a/apps/website/lib/services/races/RaceService.ts +++ b/apps/website/lib/services/races/RaceService.ts @@ -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 { const dto = await this.apiClient.getDetail(raceId, driverId); - return this.presenter.present(dto); + return this.raceDetailPresenter.present(dto); } - async getRacesPageData(): Promise { - 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 { + return this.apiClient.getPageData(); } - async getRacesTotal(): Promise { - const dto = await this.apiClient.getTotal(); - return dto; + /** + * Get total races statistics + * TODO: Add presenter transformation when presenter is available + */ + async getRacesTotal(): Promise { + return this.apiClient.getTotal(); } -} - -// Singleton instance -export const raceService = new RaceService(); - -// Backward compatibility functions -export async function getRaceDetail( - raceId: string, - driverId: string -): Promise { - return raceService.getRaceDetail(raceId, driverId); -} - -export async function getRacesPageData(): Promise { - return raceService.getRacesPageData(); -} - -export async function getRacesTotal(): Promise { - return raceService.getRacesTotal(); } \ No newline at end of file diff --git a/apps/website/lib/services/races/index.ts b/apps/website/lib/services/races/index.ts deleted file mode 100644 index 0b5931fd1..000000000 --- a/apps/website/lib/services/races/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorService.test.ts b/apps/website/lib/services/sponsors/SponsorService.test.ts new file mode 100644 index 000000000..93922eefb --- /dev/null +++ b/apps/website/lib/services/sponsors/SponsorService.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorService.ts b/apps/website/lib/services/sponsors/SponsorService.ts index cc0c92d2a..6f0fddef2 100644 --- a/apps/website/lib/services/sponsors/SponsorService.ts +++ b/apps/website/lib/services/sponsors/SponsorService.ts @@ -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 { - 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 { - return await api.sponsors.create(input); -} + /** + * Get all sponsors with presentation transformation + */ + async getAllSponsors(): Promise { + const dto = await this.apiClient.getAll(); + return this.sponsorListPresenter.present(dto); + } -export async function getSponsorDashboard(sponsorId: string): Promise { - const dto = await api.sponsors.getDashboard(sponsorId); - return dto; + /** + * Get sponsor dashboard with presentation transformation + */ + async getSponsorDashboard(sponsorId: string): Promise { + 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 { + 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 { + return await this.apiClient.create(input); + } + + /** + * Get sponsorship pricing + */ + async getSponsorshipPricing(): Promise { + return await this.apiClient.getPricing(); + } } \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorshipService.test.ts b/apps/website/lib/services/sponsors/SponsorshipService.test.ts new file mode 100644 index 000000000..99e7496eb --- /dev/null +++ b/apps/website/lib/services/sponsors/SponsorshipService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorshipService.ts b/apps/website/lib/services/sponsors/SponsorshipService.ts index 31b94bbd4..033b17cb7 100644 --- a/apps/website/lib/services/sponsors/SponsorshipService.ts +++ b/apps/website/lib/services/sponsors/SponsorshipService.ts @@ -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 { - 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 { - const dto = await api.sponsors.getSponsorships(sponsorId); - return dto; + /** + * Get sponsorship pricing with presentation transformation + */ + async getSponsorshipPricing(): Promise { + const dto = await this.apiClient.getPricing(); + return this.sponsorshipPricingPresenter.present(dto); + } + + /** + * Get sponsor sponsorships with presentation transformation + */ + async getSponsorSponsorships(sponsorId: string): Promise { + const dto = await this.apiClient.getSponsorships(sponsorId); + if (!dto) { + return null; + } + return this.sponsorSponsorshipsPresenter.present(dto); + } } \ No newline at end of file diff --git a/apps/website/lib/services/teams/TeamJoinService.test.ts b/apps/website/lib/services/teams/TeamJoinService.test.ts new file mode 100644 index 000000000..eaa151420 --- /dev/null +++ b/apps/website/lib/services/teams/TeamJoinService.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/teams/TeamJoinService.ts b/apps/website/lib/services/teams/TeamJoinService.ts index 01c336bcc..1a9d17151 100644 --- a/apps/website/lib/services/teams/TeamJoinService.ts +++ b/apps/website/lib/services/teams/TeamJoinService.ts @@ -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 { - 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 { - // TODO: implement API call -} + /** + * Get team join requests with presentation transformation + */ + async getJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise { + 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 { - // TODO: implement API call + /** + * Approve a team join request + */ + async approveJoinRequest(teamId: string, requestId: string): Promise { + 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 { + try { + // TODO: implement API call when endpoint is available + throw new Error('Not implemented: API endpoint for rejecting join requests'); + } catch (error) { + throw error; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/teams/TeamService.test.ts b/apps/website/lib/services/teams/TeamService.test.ts new file mode 100644 index 000000000..f12698c5d --- /dev/null +++ b/apps/website/lib/services/teams/TeamService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/teams/TeamService.ts b/apps/website/lib/services/teams/TeamService.ts index 2d3bccc35..c3cba61ce 100644 --- a/apps/website/lib/services/teams/TeamService.ts +++ b/apps/website/lib/services/teams/TeamService.ts @@ -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 { - 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 { - const dto = await api.teams.getDetails(teamId); - return dto ? presentTeamDetails(dto) : null; -} + /** + * Get all teams with presentation transformation + */ + async getAllTeams(): Promise { + const dto = await this.apiClient.getAll(); + return this.teamListPresenter.present(dto); + } -export async function getTeamMembers(teamId: string): Promise { - 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 { + const dto = await this.apiClient.getDetails(teamId); + if (!dto) { + return null; + } + return this.teamDetailsPresenter.present(dto, currentUserId); + } -export async function createTeam(input: any): Promise { - return await api.teams.create(input); -} + /** + * Get team members with presentation transformation + */ + async getTeamMembers(teamId: string, currentUserId: string, teamOwnerId: string): Promise { + const dto = await this.apiClient.getMembers(teamId); + return this.teamMembersPresenter.present(dto, currentUserId, teamOwnerId); + } -export async function updateTeam(teamId: string, input: any): Promise { - return await api.teams.update(teamId, input); -} + /** + * Create a new team + */ + async createTeam(input: CreateTeamInputDto): Promise { + return await this.apiClient.create(input); + } -export async function getDriverTeam(driverId: string): Promise { - return await api.teams.getDriverTeam(driverId); + /** + * Update team + */ + async updateTeam(teamId: string, input: UpdateTeamInputDto): Promise { + return await this.apiClient.update(teamId, input); + } + + /** + * Get driver's team + */ + async getDriverTeam(driverId: string): Promise { + return await this.apiClient.getDriverTeam(driverId); + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/AvatarViewModel.ts b/apps/website/lib/view-models/AvatarViewModel.ts new file mode 100644 index 000000000..e6769ecb1 --- /dev/null +++ b/apps/website/lib/view-models/AvatarViewModel.ts @@ -0,0 +1,10 @@ +/** + * Avatar View Model + * + * Represents avatar information for the UI layer + */ +export interface AvatarViewModel { + driverId: string; + avatarUrl?: string; + hasAvatar: boolean; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/CompleteOnboardingViewModel.ts b/apps/website/lib/view-models/CompleteOnboardingViewModel.ts new file mode 100644 index 000000000..10e349108 --- /dev/null +++ b/apps/website/lib/view-models/CompleteOnboardingViewModel.ts @@ -0,0 +1,8 @@ +/** + * Complete onboarding view model + * UI representation of onboarding completion result + */ +export interface CompleteOnboardingViewModel { + driverId: string; + success: boolean; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/DeleteMediaViewModel.ts b/apps/website/lib/view-models/DeleteMediaViewModel.ts new file mode 100644 index 000000000..f66e95a82 --- /dev/null +++ b/apps/website/lib/view-models/DeleteMediaViewModel.ts @@ -0,0 +1,9 @@ +/** + * Delete Media View Model + * + * Represents the result of a media deletion operation + */ +export interface DeleteMediaViewModel { + success: boolean; + error?: string; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverViewModel.ts b/apps/website/lib/view-models/DriverViewModel.ts new file mode 100644 index 000000000..60fd30ba0 --- /dev/null +++ b/apps/website/lib/view-models/DriverViewModel.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/MediaViewModel.ts b/apps/website/lib/view-models/MediaViewModel.ts new file mode 100644 index 000000000..47486f6a3 --- /dev/null +++ b/apps/website/lib/view-models/MediaViewModel.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts new file mode 100644 index 000000000..ac6920fbe --- /dev/null +++ b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorDashboardViewModel.ts b/apps/website/lib/view-models/SponsorDashboardViewModel.ts new file mode 100644 index 000000000..e93fdbb53 --- /dev/null +++ b/apps/website/lib/view-models/SponsorDashboardViewModel.ts @@ -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`; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts new file mode 100644 index 000000000..58f2ffda5 --- /dev/null +++ b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts @@ -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()}`; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorshipPricingViewModel.ts b/apps/website/lib/view-models/SponsorshipPricingViewModel.ts new file mode 100644 index 000000000..c62f1fa01 --- /dev/null +++ b/apps/website/lib/view-models/SponsorshipPricingViewModel.ts @@ -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); + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/UpdateAvatarViewModel.ts b/apps/website/lib/view-models/UpdateAvatarViewModel.ts new file mode 100644 index 000000000..c14dc6e22 --- /dev/null +++ b/apps/website/lib/view-models/UpdateAvatarViewModel.ts @@ -0,0 +1,9 @@ +/** + * Update Avatar View Model + * + * Represents the result of an avatar update operation + */ +export interface UpdateAvatarViewModel { + success: boolean; + error?: string; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/UploadMediaViewModel.ts b/apps/website/lib/view-models/UploadMediaViewModel.ts new file mode 100644 index 000000000..42d9ff1be --- /dev/null +++ b/apps/website/lib/view-models/UploadMediaViewModel.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/index.ts b/apps/website/lib/view-models/index.ts index 23b40d092..950346555 100644 --- a/apps/website/lib/view-models/index.ts +++ b/apps/website/lib/view-models/index.ts @@ -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';