From 656ec624264da6c656f51344e028b37d78427964 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 20 Dec 2025 00:31:31 +0100 Subject: [PATCH] view models --- .../view-models/ActivityItemViewModel.test.ts | 80 +++++ .../AnalyticsDashboardViewModel.test.ts | 53 ++++ .../AnalyticsMetricsViewModel.test.ts | 52 +++ .../AvailableLeaguesViewModel.test.ts | 78 +++++ .../lib/view-models/BillingViewModel.test.ts | 186 +++++++++++ .../CompleteOnboardingViewModel.test.ts | 33 +- .../view-models/CreateLeagueViewModel.test.ts | 36 +++ .../view-models/CreateTeamViewModel.test.ts | 29 ++ .../DashboardOverviewViewModel.test.ts | 146 +++++++++ .../DriverLeaderboardItemViewModel.test.ts | 44 +-- .../DriverLeaderboardViewModel.test.ts | 61 ++++ .../view-models/DriverLeaderboardViewModel.ts | 4 +- .../DriverProfileViewModel.test.ts | 127 ++++++++ .../DriverRegistrationStatusViewModel.test.ts | 41 +++ .../DriverSummaryViewModel.test.ts | 45 +++ .../view-models/DriverTeamViewModel.test.ts | 68 ++++ .../HomeDiscoveryViewModel.test.ts | 48 +++ .../ImportRaceResultsSummaryViewModel.test.ts | 35 ++ .../view-models/LeagueCardViewModel.test.ts | 50 +++ .../LeagueDetailPageViewModel.test.ts | 234 ++++++++++++++ .../view-models/LeagueDetailViewModel.test.ts | 97 ++++++ .../LeagueJoinRequestViewModel.test.ts | 47 +++ .../view-models/LeagueMemberViewModel.test.ts | 65 ++++ .../LeagueMembershipsViewModel.test.ts | 55 ++++ .../LeagueScheduleViewModel.test.ts | 29 ++ .../LeagueScoringPresetsViewModel.test.ts | 38 +++ .../LeagueSettingsViewModel.test.ts | 63 ++++ .../LeagueStandingsViewModel.test.ts | 12 +- .../view-models/LeagueStatsViewModel.test.ts | 34 ++ .../LeagueStewardingViewModel.test.ts | 96 ++++++ .../LeagueSummaryViewModel.test.ts | 59 ++++ .../view-models/LeagueWalletViewModel.test.ts | 87 +++++ .../lib/view-models/MediaViewModel.test.ts | 6 +- .../MembershipFeeViewModel.test.ts | 74 +++++ .../lib/view-models/PaymentViewModel.test.ts | 74 +++++ .../lib/view-models/PrizeViewModel.test.ts | 88 ++++++ .../ProtestDriverViewModel.test.ts | 32 ++ .../lib/view-models/ProtestViewModel.test.ts | 123 ++------ .../RaceResultsDetailViewModel.test.ts | 130 ++++++++ .../view-models/RaceStatsViewModel.test.ts | 28 ++ .../RaceStewardingViewModel.test.ts | 140 ++++++++ .../lib/view-models/RaceViewModel.test.ts | 42 +++ .../view-models/RaceWithSOFViewModel.test.ts | 20 ++ .../view-models/RacesPageViewModel.test.ts | 298 +++++++----------- .../RecordEngagementInputViewModel.test.ts | 47 +++ .../RecordEngagementOutputViewModel.test.ts | 33 ++ .../RecordPageViewInputViewModel.test.ts | 29 ++ .../RecordPageViewOutputViewModel.test.ts | 18 ++ .../view-models/RemoveMemberViewModel.test.ts | 34 ++ .../view-models/RenewalAlertViewModel.test.ts | 62 ++++ .../RequestAvatarGenerationViewModel.test.ts | 52 +++ .../lib/view-models/SessionViewModel.test.ts | 64 ++++ .../SponsorDashboardViewModel.test.ts | 165 ++++++++++ .../SponsorSettingsViewModel.test.ts | 74 +++++ .../SponsorSponsorshipsViewModel.test.ts | 58 ++++ .../lib/view-models/SponsorViewModel.test.ts | 46 +++ .../SponsorshipDetailViewModel.test.ts | 56 ++++ .../SponsorshipPricingViewModel.test.ts | 39 +++ .../SponsorshipRequestViewModel.test.ts | 60 ++++ .../view-models/SponsorshipViewModel.test.ts | 123 ++++++++ .../lib/view-models/StandingEntryViewModel.ts | 8 +- .../lib/view-models/TeamCardViewModel.test.ts | 44 +++ .../view-models/TeamDetailsViewModel.test.ts | 88 ++++++ .../lib/view-models/TeamDetailsViewModel.ts | 6 +- .../TeamJoinRequestViewModel.test.ts | 61 ++++ .../view-models/TeamMemberViewModel.test.ts | 78 +++++ .../view-models/TeamSummaryViewModel.test.ts | 61 ++++ .../UpcomingRaceCardViewModel.test.ts | 28 ++ .../view-models/UpdateAvatarViewModel.test.ts | 44 +++ .../view-models/UpdateTeamViewModel.test.ts | 28 ++ .../view-models/UploadMediaViewModel.test.ts | 55 ++++ .../view-models/UserProfileViewModel.test.ts | 80 +++++ .../WalletTransactionViewModel.test.ts | 52 +++ .../lib/view-models/WalletViewModel.test.ts | 78 +++++ 74 files changed, 4511 insertions(+), 347 deletions(-) create mode 100644 apps/website/lib/view-models/ActivityItemViewModel.test.ts create mode 100644 apps/website/lib/view-models/AnalyticsDashboardViewModel.test.ts create mode 100644 apps/website/lib/view-models/AnalyticsMetricsViewModel.test.ts create mode 100644 apps/website/lib/view-models/AvailableLeaguesViewModel.test.ts create mode 100644 apps/website/lib/view-models/BillingViewModel.test.ts create mode 100644 apps/website/lib/view-models/CreateLeagueViewModel.test.ts create mode 100644 apps/website/lib/view-models/CreateTeamViewModel.test.ts create mode 100644 apps/website/lib/view-models/DashboardOverviewViewModel.test.ts create mode 100644 apps/website/lib/view-models/DriverLeaderboardViewModel.test.ts create mode 100644 apps/website/lib/view-models/DriverProfileViewModel.test.ts create mode 100644 apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts create mode 100644 apps/website/lib/view-models/DriverSummaryViewModel.test.ts create mode 100644 apps/website/lib/view-models/DriverTeamViewModel.test.ts create mode 100644 apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts create mode 100644 apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueCardViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueDetailPageViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueDetailViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueJoinRequestViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueMemberViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueMembershipsViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueScheduleViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueScoringPresetsViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueSettingsViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueStatsViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueStewardingViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueSummaryViewModel.test.ts create mode 100644 apps/website/lib/view-models/LeagueWalletViewModel.test.ts create mode 100644 apps/website/lib/view-models/MembershipFeeViewModel.test.ts create mode 100644 apps/website/lib/view-models/PaymentViewModel.test.ts create mode 100644 apps/website/lib/view-models/PrizeViewModel.test.ts create mode 100644 apps/website/lib/view-models/ProtestDriverViewModel.test.ts create mode 100644 apps/website/lib/view-models/RaceResultsDetailViewModel.test.ts create mode 100644 apps/website/lib/view-models/RaceStatsViewModel.test.ts create mode 100644 apps/website/lib/view-models/RaceStewardingViewModel.test.ts create mode 100644 apps/website/lib/view-models/RaceViewModel.test.ts create mode 100644 apps/website/lib/view-models/RaceWithSOFViewModel.test.ts create mode 100644 apps/website/lib/view-models/RecordEngagementInputViewModel.test.ts create mode 100644 apps/website/lib/view-models/RecordEngagementOutputViewModel.test.ts create mode 100644 apps/website/lib/view-models/RecordPageViewInputViewModel.test.ts create mode 100644 apps/website/lib/view-models/RecordPageViewOutputViewModel.test.ts create mode 100644 apps/website/lib/view-models/RemoveMemberViewModel.test.ts create mode 100644 apps/website/lib/view-models/RenewalAlertViewModel.test.ts create mode 100644 apps/website/lib/view-models/RequestAvatarGenerationViewModel.test.ts create mode 100644 apps/website/lib/view-models/SessionViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorDashboardViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorSettingsViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorSponsorshipsViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorshipDetailViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorshipPricingViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorshipRequestViewModel.test.ts create mode 100644 apps/website/lib/view-models/SponsorshipViewModel.test.ts create mode 100644 apps/website/lib/view-models/TeamCardViewModel.test.ts create mode 100644 apps/website/lib/view-models/TeamDetailsViewModel.test.ts create mode 100644 apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts create mode 100644 apps/website/lib/view-models/TeamMemberViewModel.test.ts create mode 100644 apps/website/lib/view-models/TeamSummaryViewModel.test.ts create mode 100644 apps/website/lib/view-models/UpcomingRaceCardViewModel.test.ts create mode 100644 apps/website/lib/view-models/UpdateAvatarViewModel.test.ts create mode 100644 apps/website/lib/view-models/UpdateTeamViewModel.test.ts create mode 100644 apps/website/lib/view-models/UploadMediaViewModel.test.ts create mode 100644 apps/website/lib/view-models/UserProfileViewModel.test.ts create mode 100644 apps/website/lib/view-models/WalletTransactionViewModel.test.ts create mode 100644 apps/website/lib/view-models/WalletViewModel.test.ts diff --git a/apps/website/lib/view-models/ActivityItemViewModel.test.ts b/apps/website/lib/view-models/ActivityItemViewModel.test.ts new file mode 100644 index 000000000..f186a1335 --- /dev/null +++ b/apps/website/lib/view-models/ActivityItemViewModel.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { ActivityItemViewModel } from './ActivityItemViewModel'; + +describe('ActivityItemViewModel', () => { + it('maps basic properties from input data', () => { + const data = { + id: 'activity-1', + type: 'race' as const, + message: 'Test activity', + time: '2025-01-01T12:00:00Z', + impressions: 1234, + }; + + const viewModel = new ActivityItemViewModel(data); + + expect(viewModel.id).toBe('activity-1'); + expect(viewModel.type).toBe('race'); + expect(viewModel.message).toBe('Test activity'); + expect(viewModel.time).toBe('2025-01-01T12:00:00Z'); + expect(viewModel.impressions).toBe(1234); + }); + + it('returns the correct typeColor for each supported type', () => { + const race = new ActivityItemViewModel({ id: '1', type: 'race', message: '', time: '' }); + const league = new ActivityItemViewModel({ id: '2', type: 'league', message: '', time: '' }); + const team = new ActivityItemViewModel({ id: '3', type: 'team', message: '', time: '' }); + const driver = new ActivityItemViewModel({ id: '4', type: 'driver', message: '', time: '' }); + const platform = new ActivityItemViewModel({ id: '5', type: 'platform', message: '', time: '' }); + + expect(race.typeColor).toBe('bg-warning-amber'); + expect(league.typeColor).toBe('bg-primary-blue'); + expect(team.typeColor).toBe('bg-purple-400'); + expect(driver.typeColor).toBe('bg-performance-green'); + expect(platform.typeColor).toBe('bg-racing-red'); + }); + + it('falls back to a default typeColor for unknown types', () => { + const unknown = new ActivityItemViewModel({ + id: 'unknown', + type: 'unknown', + message: '', + time: '', + } as any); + + expect(unknown.typeColor).toBe('bg-gray-500'); + }); + + it('formats impressions when provided', () => { + const impressions = 1234; + const viewModel = new ActivityItemViewModel({ + id: 'activity-2', + type: 'race', + message: '', + time: '', + impressions, + }); + + expect(viewModel.formattedImpressions).toBe(impressions.toLocaleString()); + }); + + it('returns null for formattedImpressions when impressions is undefined or zero', () => { + const noImpressions = new ActivityItemViewModel({ + id: 'activity-3', + type: 'race', + message: '', + time: '', + }); + + const zeroImpressions = new ActivityItemViewModel({ + id: 'activity-4', + type: 'race', + message: '', + time: '', + impressions: 0, + }); + + expect(noImpressions.formattedImpressions).toBeNull(); + expect(zeroImpressions.formattedImpressions).toBeNull(); + }); +}); diff --git a/apps/website/lib/view-models/AnalyticsDashboardViewModel.test.ts b/apps/website/lib/view-models/AnalyticsDashboardViewModel.test.ts new file mode 100644 index 000000000..6a8bb8699 --- /dev/null +++ b/apps/website/lib/view-models/AnalyticsDashboardViewModel.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { AnalyticsDashboardViewModel } from './AnalyticsDashboardViewModel'; + +describe('AnalyticsDashboardViewModel', () => { + it('maps core fields from data', () => { + const vm = new AnalyticsDashboardViewModel({ + totalUsers: 100, + activeUsers: 40, + totalRaces: 10, + totalLeagues: 5, + }); + + expect(vm.totalUsers).toBe(100); + expect(vm.activeUsers).toBe(40); + expect(vm.totalRaces).toBe(10); + expect(vm.totalLeagues).toBe(5); + }); + + it('computes engagement rate and formatted engagement rate', () => { + const vm = new AnalyticsDashboardViewModel({ + totalUsers: 200, + activeUsers: 50, + totalRaces: 0, + totalLeagues: 0, + }); + + expect(vm.userEngagementRate).toBeCloseTo(25); + expect(vm.formattedEngagementRate).toBe('25.0%'); + }); + + it('handles zero users safely', () => { + const vm = new AnalyticsDashboardViewModel({ + totalUsers: 0, + activeUsers: 0, + totalRaces: 0, + totalLeagues: 0, + }); + + expect(vm.userEngagementRate).toBe(0); + expect(vm.formattedEngagementRate).toBe('0.0%'); + expect(vm.activityLevel).toBe('Low'); + }); + + it('derives activity level buckets from engagement rate', () => { + const low = new AnalyticsDashboardViewModel({ totalUsers: 100, activeUsers: 30, totalRaces: 0, totalLeagues: 0 }); + const medium = new AnalyticsDashboardViewModel({ totalUsers: 100, activeUsers: 50, totalRaces: 0, totalLeagues: 0 }); + const high = new AnalyticsDashboardViewModel({ totalUsers: 100, activeUsers: 90, totalRaces: 0, totalLeagues: 0 }); + + expect(low.activityLevel).toBe('Low'); + expect(medium.activityLevel).toBe('Medium'); + expect(high.activityLevel).toBe('High'); + }); +}); diff --git a/apps/website/lib/view-models/AnalyticsMetricsViewModel.test.ts b/apps/website/lib/view-models/AnalyticsMetricsViewModel.test.ts new file mode 100644 index 000000000..46a52feb7 --- /dev/null +++ b/apps/website/lib/view-models/AnalyticsMetricsViewModel.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { AnalyticsMetricsViewModel } from './AnalyticsMetricsViewModel'; + +describe('AnalyticsMetricsViewModel', () => { + it('maps raw metrics fields from data', () => { + const vm = new AnalyticsMetricsViewModel({ + pageViews: 1234, + uniqueVisitors: 567, + averageSessionDuration: 180, + bounceRate: 42.5, + }); + + expect(vm.pageViews).toBe(1234); + expect(vm.uniqueVisitors).toBe(567); + expect(vm.averageSessionDuration).toBe(180); + expect(vm.bounceRate).toBe(42.5); + }); + + it('formats counts using locale formatting helpers', () => { + const vm = new AnalyticsMetricsViewModel({ + pageViews: 1200, + uniqueVisitors: 3500, + averageSessionDuration: 75, + bounceRate: 10, + }); + + expect(vm.formattedPageViews).toBe((1200).toLocaleString()); + expect(vm.formattedUniqueVisitors).toBe((3500).toLocaleString()); + }); + + it('formats session duration as mm:ss', () => { + const vm = new AnalyticsMetricsViewModel({ + pageViews: 0, + uniqueVisitors: 0, + averageSessionDuration: 125, + bounceRate: 0, + }); + + expect(vm.formattedSessionDuration).toBe('2:05'); + }); + + it('formats bounce rate as percentage with one decimal', () => { + const vm = new AnalyticsMetricsViewModel({ + pageViews: 0, + uniqueVisitors: 0, + averageSessionDuration: 0, + bounceRate: 37.345, + }); + + expect(vm.formattedBounceRate).toBe('37.3%'); + }); +}); diff --git a/apps/website/lib/view-models/AvailableLeaguesViewModel.test.ts b/apps/website/lib/view-models/AvailableLeaguesViewModel.test.ts new file mode 100644 index 000000000..3859cd338 --- /dev/null +++ b/apps/website/lib/view-models/AvailableLeaguesViewModel.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { AvailableLeaguesViewModel, AvailableLeagueViewModel } from './AvailableLeaguesViewModel'; + +describe('AvailableLeaguesViewModel', () => { + const baseLeague = { + id: 'league-1', + name: 'Pro Series', + game: 'iRacing', + drivers: 24, + avgViewsPerRace: 12_500, + mainSponsorSlot: { available: true, price: 5_000 }, + secondarySlots: { available: 2, total: 3, price: 1_500 }, + rating: 4.7, + tier: 'premium' as const, + nextRace: 'Next Sunday', + seasonStatus: 'active' as const, + description: 'Competitive league for serious drivers', + }; + + it('maps league array into view models', () => { + const vm = new AvailableLeaguesViewModel([baseLeague]); + + expect(vm.leagues).toHaveLength(1); + expect(vm.leagues[0]).toBeInstanceOf(AvailableLeagueViewModel); + expect(vm.leagues[0].id).toBe(baseLeague.id); + expect(vm.leagues[0].name).toBe(baseLeague.name); + expect(vm.leagues[0].avgViewsPerRace).toBe(baseLeague.avgViewsPerRace); + }); + + it('exposes formatted average views and CPM for main sponsor slot', () => { + const leagueVm = new AvailableLeagueViewModel(baseLeague); + + expect(leagueVm.formattedAvgViews).toBe(`${(baseLeague.avgViewsPerRace / 1000).toFixed(1)}k`); + + const expectedCpm = Math.round((baseLeague.mainSponsorSlot.price / baseLeague.avgViewsPerRace) * 1000); + expect(leagueVm.cpm).toBe(expectedCpm); + expect(leagueVm.formattedCpm).toBe(`$${expectedCpm}`); + }); + + it('detects available sponsor slots from main or secondary slots', () => { + const withBothAvailable = new AvailableLeagueViewModel(baseLeague); + expect(withBothAvailable.hasAvailableSlots).toBe(true); + + const onlySecondary = new AvailableLeagueViewModel({ + ...baseLeague, + mainSponsorSlot: { available: false, price: 5_000 }, + secondarySlots: { available: 1, total: 3, price: 1_500 }, + }); + expect(onlySecondary.hasAvailableSlots).toBe(true); + + const noneAvailable = new AvailableLeagueViewModel({ + ...baseLeague, + mainSponsorSlot: { available: false, price: 5_000 }, + secondarySlots: { available: 0, total: 3, price: 1_500 }, + }); + expect(noneAvailable.hasAvailableSlots).toBe(false); + }); + + it('returns tier configuration for badge styling', () => { + const premium = new AvailableLeagueViewModel({ ...baseLeague, tier: 'premium' }); + const standard = new AvailableLeagueViewModel({ ...baseLeague, tier: 'standard' }); + const starter = new AvailableLeagueViewModel({ ...baseLeague, tier: 'starter' }); + + expect(premium.tierConfig.icon).toBe('⭐'); + expect(standard.tierConfig.icon).toBe('🏆'); + expect(starter.tierConfig.icon).toBe('🚀'); + }); + + it('returns status configuration for season state', () => { + const active = new AvailableLeagueViewModel({ ...baseLeague, seasonStatus: 'active' }); + const upcoming = new AvailableLeagueViewModel({ ...baseLeague, seasonStatus: 'upcoming' }); + const completed = new AvailableLeagueViewModel({ ...baseLeague, seasonStatus: 'completed' }); + + expect(active.statusConfig.label).toBe('Active Season'); + expect(upcoming.statusConfig.label).toBe('Starting Soon'); + expect(completed.statusConfig.label).toBe('Season Ended'); + }); +}); diff --git a/apps/website/lib/view-models/BillingViewModel.test.ts b/apps/website/lib/view-models/BillingViewModel.test.ts new file mode 100644 index 000000000..d46831497 --- /dev/null +++ b/apps/website/lib/view-models/BillingViewModel.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { BillingViewModel, PaymentMethodViewModel, InvoiceViewModel, BillingStatsViewModel } from './BillingViewModel'; + +describe('BillingViewModel', () => { + it('maps arrays of payment methods, invoices and stats into view models', () => { + const data = { + paymentMethods: [ + { id: 'pm-1', type: 'card', last4: '4242', brand: 'Visa', isDefault: true, expiryMonth: 12, expiryYear: 2030 }, + ], + invoices: [ + { + id: 'inv-1', + invoiceNumber: 'INV-1', + date: '2024-01-01', + dueDate: '2024-02-01', + amount: 100, + vatAmount: 19, + totalAmount: 119, + status: 'pending', + description: 'Sponsorship', + sponsorshipType: 'league', + pdfUrl: 'https://example.com/invoice.pdf', + }, + ], + stats: { + totalSpent: 1000, + pendingAmount: 200, + nextPaymentDate: '2024-03-01', + nextPaymentAmount: 50, + activeSponsorships: 3, + averageMonthlySpend: 250, + }, + } as any; + + const vm = new BillingViewModel(data); + + expect(vm.paymentMethods).toHaveLength(1); + expect(vm.paymentMethods[0]).toBeInstanceOf(PaymentMethodViewModel); + expect(vm.invoices).toHaveLength(1); + expect(vm.invoices[0]).toBeInstanceOf(InvoiceViewModel); + expect(vm.stats).toBeInstanceOf(BillingStatsViewModel); + }); +}); + +describe('PaymentMethodViewModel', () => { + it('builds displayLabel based on type and bankName/brand', () => { + const card = new PaymentMethodViewModel({ + id: 'pm-1', + type: 'card', + last4: '4242', + brand: 'Visa', + isDefault: true, + }); + + const sepa = new PaymentMethodViewModel({ + id: 'pm-2', + type: 'sepa', + last4: '1337', + bankName: 'Test Bank', + isDefault: false, + }); + + expect(card.displayLabel).toBe('Visa •••• 4242'); + expect(sepa.displayLabel).toBe('Test Bank •••• 1337'); + }); + + it('returns expiryDisplay when month and year are provided', () => { + const withExpiry = new PaymentMethodViewModel({ + id: 'pm-1', + type: 'card', + last4: '4242', + brand: 'Visa', + isDefault: true, + expiryMonth: 3, + expiryYear: 2030, + }); + + const withoutExpiry = new PaymentMethodViewModel({ + id: 'pm-2', + type: 'card', + last4: '9999', + brand: 'Mastercard', + isDefault: false, + }); + + expect(withExpiry.expiryDisplay).toBe('03/2030'); + expect(withoutExpiry.expiryDisplay).toBeNull(); + }); +}); + +describe('InvoiceViewModel', () => { + it('formats monetary amounts and dates', () => { + const dto = { + id: 'inv-1', + invoiceNumber: 'INV-1', + date: '2024-01-15', + dueDate: '2024-02-01', + amount: 100, + vatAmount: 19, + totalAmount: 119, + status: 'paid', + description: 'Sponsorship', + sponsorshipType: 'league', + pdfUrl: 'https://example.com/invoice.pdf', + } as any; + + const vm = new InvoiceViewModel(dto); + + expect(vm.formattedTotalAmount).toBe(`€${(119).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`); + expect(vm.formattedVatAmount).toBe(`€${(19).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`); + expect(typeof vm.formattedDate).toBe('string'); + }); + + it('detects overdue when status is overdue or pending past due date', () => { + const now = new Date(); + const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + + const overdue = new InvoiceViewModel({ + id: 'inv-1', + invoiceNumber: 'INV-1', + date: pastDate, + dueDate: pastDate, + amount: 0, + vatAmount: 0, + totalAmount: 0, + status: 'overdue', + description: '', + sponsorshipType: 'league', + pdfUrl: '', + } as any); + + const pendingPastDue = new InvoiceViewModel({ + id: 'inv-2', + invoiceNumber: 'INV-2', + date: pastDate, + dueDate: pastDate, + amount: 0, + vatAmount: 0, + totalAmount: 0, + status: 'pending', + description: '', + sponsorshipType: 'league', + pdfUrl: '', + } as any); + + const pendingFuture = new InvoiceViewModel({ + id: 'inv-3', + invoiceNumber: 'INV-3', + date: pastDate, + dueDate: futureDate, + amount: 0, + vatAmount: 0, + totalAmount: 0, + status: 'pending', + description: '', + sponsorshipType: 'league', + pdfUrl: '', + } as any); + + expect(overdue.isOverdue).toBe(true); + expect(pendingPastDue.isOverdue).toBe(true); + expect(pendingFuture.isOverdue).toBe(false); + }); +}); + +describe('BillingStatsViewModel', () => { + it('formats monetary fields and next payment date', () => { + const dto = { + totalSpent: 1234, + pendingAmount: 56.78, + nextPaymentDate: '2024-03-01', + nextPaymentAmount: 42, + activeSponsorships: 2, + averageMonthlySpend: 321, + } as any; + + const vm = new BillingStatsViewModel(dto); + + expect(vm.formattedTotalSpent).toBe(`€${(1234).toLocaleString('de-DE')}`); + expect(vm.formattedPendingAmount).toBe(`€${(56.78).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`); + expect(vm.formattedNextPaymentAmount).toBe(`€${(42).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`); + expect(vm.formattedAverageMonthlySpend).toBe(`€${(321).toLocaleString('de-DE')}`); + expect(typeof vm.formattedNextPaymentDate).toBe('string'); + }); +}); diff --git a/apps/website/lib/view-models/CompleteOnboardingViewModel.test.ts b/apps/website/lib/view-models/CompleteOnboardingViewModel.test.ts index 3b4e3f999..19f409500 100644 --- a/apps/website/lib/view-models/CompleteOnboardingViewModel.test.ts +++ b/apps/website/lib/view-models/CompleteOnboardingViewModel.test.ts @@ -3,22 +3,19 @@ import { CompleteOnboardingViewModel } from './CompleteOnboardingViewModel'; import type { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO'; describe('CompleteOnboardingViewModel', () => { - it('should create instance with success and driverId', () => { - const dto: CompleteOnboardingOutputDTO & { driverId: string } = { + it('should create instance with success flag', () => { + const dto: CompleteOnboardingOutputDTO = { success: true, - driverId: 'driver-123', }; const viewModel = new CompleteOnboardingViewModel(dto); expect(viewModel.success).toBe(true); - expect(viewModel.driverId).toBe('driver-123'); }); - it('should return true for isSuccessful when success is true', () => { - const dto: CompleteOnboardingOutputDTO & { driverId: string } = { + it('should expose isSuccessful as true when success is true', () => { + const dto: CompleteOnboardingOutputDTO = { success: true, - driverId: 'driver-123', }; const viewModel = new CompleteOnboardingViewModel(dto); @@ -26,31 +23,13 @@ describe('CompleteOnboardingViewModel', () => { expect(viewModel.isSuccessful).toBe(true); }); - it('should return false for isSuccessful when success is false', () => { - const dto: CompleteOnboardingOutputDTO & { driverId: string } = { + it('should expose isSuccessful as false when success is false', () => { + const dto: CompleteOnboardingOutputDTO = { success: false, - driverId: 'driver-123', }; const viewModel = new CompleteOnboardingViewModel(dto); expect(viewModel.isSuccessful).toBe(false); }); - - it('should preserve driverId regardless of success status', () => { - const successDto: CompleteOnboardingOutputDTO & { driverId: string } = { - success: true, - driverId: 'driver-success', - }; - const failDto: CompleteOnboardingOutputDTO & { driverId: string } = { - success: false, - driverId: 'driver-fail', - }; - - const successViewModel = new CompleteOnboardingViewModel(successDto); - const failViewModel = new CompleteOnboardingViewModel(failDto); - - expect(successViewModel.driverId).toBe('driver-success'); - expect(failViewModel.driverId).toBe('driver-fail'); - }); }); \ No newline at end of file diff --git a/apps/website/lib/view-models/CreateLeagueViewModel.test.ts b/apps/website/lib/view-models/CreateLeagueViewModel.test.ts new file mode 100644 index 000000000..4f4297484 --- /dev/null +++ b/apps/website/lib/view-models/CreateLeagueViewModel.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { CreateLeagueViewModel } from './CreateLeagueViewModel'; +import type { CreateLeagueOutputDTO } from '../types/generated/CreateLeagueOutputDTO'; + +const createDto = (overrides: Partial = {}): CreateLeagueOutputDTO => ({ + leagueId: 'league-1', + success: true, + ...overrides, +} as CreateLeagueOutputDTO); + +describe('CreateLeagueViewModel', () => { + it('maps leagueId and success from DTO', () => { + const dto = createDto({ leagueId: 'league-123', success: true }); + + const vm = new CreateLeagueViewModel(dto); + + expect(vm.leagueId).toBe('league-123'); + expect(vm.success).toBe(true); + }); + + it('returns success successMessage when creation succeeded', () => { + const dto = createDto({ success: true }); + + const vm = new CreateLeagueViewModel(dto); + + expect(vm.successMessage).toBe('League created successfully!'); + }); + + it('returns failure successMessage when creation failed', () => { + const dto = createDto({ success: false }); + + const vm = new CreateLeagueViewModel(dto); + + expect(vm.successMessage).toBe('Failed to create league.'); + }); +}); diff --git a/apps/website/lib/view-models/CreateTeamViewModel.test.ts b/apps/website/lib/view-models/CreateTeamViewModel.test.ts new file mode 100644 index 000000000..de4b36259 --- /dev/null +++ b/apps/website/lib/view-models/CreateTeamViewModel.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { CreateTeamViewModel } from './CreateTeamViewModel'; + +describe('CreateTeamViewModel', () => { + it('maps id and success from DTO', () => { + const dto = { id: 'team-123', success: true }; + + const vm = new CreateTeamViewModel(dto); + + expect(vm.id).toBe('team-123'); + expect(vm.success).toBe(true); + }); + + it('returns success successMessage when creation succeeded', () => { + const dto = { id: 'team-1', success: true }; + + const vm = new CreateTeamViewModel(dto); + + expect(vm.successMessage).toBe('Team created successfully!'); + }); + + it('returns failure successMessage when creation failed', () => { + const dto = { id: 'team-1', success: false }; + + const vm = new CreateTeamViewModel(dto); + + expect(vm.successMessage).toBe('Failed to create team.'); + }); +}); diff --git a/apps/website/lib/view-models/DashboardOverviewViewModel.test.ts b/apps/website/lib/view-models/DashboardOverviewViewModel.test.ts new file mode 100644 index 000000000..286b2a7d3 --- /dev/null +++ b/apps/website/lib/view-models/DashboardOverviewViewModel.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import { + DashboardOverviewViewModel, + DriverViewModel, + RaceViewModel, + LeagueStandingViewModel, + DashboardFeedItemSummaryViewModel, + FriendViewModel, +} from './DashboardOverviewViewModel'; +import type { DashboardOverviewDto } from '../api/dashboard/DashboardApiClient'; + +const createDashboardOverviewDto = (): DashboardOverviewDto => ({ + currentDriver: { + id: 'driver-1', + name: 'Test Driver', + avatarUrl: 'https://example.com/avatar.jpg', + country: 'DE', + totalRaces: 10, + wins: 3, + podiums: 5, + rating: 2500, + globalRank: 42, + consistency: 88, + }, + nextRace: { + id: 'race-1', + track: 'Spa-Francorchamps', + car: 'GT3', + scheduledAt: '2025-01-01T12:00:00Z', + isMyLeague: true, + leagueName: 'Pro League', + }, + upcomingRaces: [ + { + id: 'race-2', + track: 'Nürburgring', + car: 'GT4', + scheduledAt: '2025-01-02T12:00:00Z', + isMyLeague: false, + leagueName: undefined, + }, + ], + leagueStandings: [ + { + leagueId: 'league-1', + leagueName: 'Pro League', + position: 1, + points: 120, + totalDrivers: 50, + }, + ], + feedItems: [ + { + id: 'feed-1', + type: 'news', + headline: 'Big race announced', + body: 'Details about the big race.', + timestamp: '2025-01-01T10:00:00Z', + ctaHref: '/races/race-1', + ctaLabel: 'View race', + }, + ], + friends: [ + { + id: 'friend-1', + name: 'Racing Buddy', + avatarUrl: 'https://example.com/friend.jpg', + country: 'US', + }, + ], + activeLeaguesCount: 3, +}); + +describe('DashboardOverviewViewModel', () => { + it('wraps the current driver DTO in a DriverViewModel', () => { + const dto = createDashboardOverviewDto(); + const viewModel = new DashboardOverviewViewModel(dto); + + const currentDriver = viewModel.currentDriver; + + expect(currentDriver).toBeInstanceOf(DriverViewModel); + expect(currentDriver.id).toBe('driver-1'); + expect(currentDriver.name).toBe('Test Driver'); + expect(currentDriver.avatarUrl).toBe('https://example.com/avatar.jpg'); + expect(currentDriver.country).toBe('DE'); + expect(currentDriver.totalRaces).toBe(10); + expect(currentDriver.wins).toBe(3); + expect(currentDriver.podiums).toBe(5); + expect(currentDriver.rating).toBe(2500); + expect(currentDriver.globalRank).toBe(42); + expect(currentDriver.consistency).toBe(88); + }); + + it('wraps nextRace DTO into a RaceViewModel and returns null when absent', () => { + const dtoWithRace = createDashboardOverviewDto(); + const viewModelWithRace = new DashboardOverviewViewModel(dtoWithRace); + + const nextRace = viewModelWithRace.nextRace; + + expect(nextRace).toBeInstanceOf(RaceViewModel); + expect(nextRace?.id).toBe('race-1'); + expect(nextRace?.track).toBe('Spa-Francorchamps'); + expect(nextRace?.car).toBe('GT3'); + expect(nextRace?.isMyLeague).toBe(true); + expect(nextRace?.leagueName).toBe('Pro League'); + expect(nextRace?.scheduledAt).toBeInstanceOf(Date); + + const dtoWithoutRace: DashboardOverviewDto = { + ...dtoWithRace, + nextRace: null, + }; + + const viewModelWithoutRace = new DashboardOverviewViewModel(dtoWithoutRace); + + expect(viewModelWithoutRace.nextRace).toBeNull(); + }); + + it('maps upcoming races, league standings, feed items and friends into their respective view models', () => { + const dto = createDashboardOverviewDto(); + const viewModel = new DashboardOverviewViewModel(dto); + + expect(viewModel.upcomingRaces).toHaveLength(1); + expect(viewModel.upcomingRaces[0]).toBeInstanceOf(RaceViewModel); + expect(viewModel.upcomingRaces[0].id).toBe('race-2'); + + expect(viewModel.leagueStandings).toHaveLength(1); + expect(viewModel.leagueStandings[0]).toBeInstanceOf(LeagueStandingViewModel); + expect(viewModel.leagueStandings[0].leagueId).toBe('league-1'); + + expect(viewModel.feedItems).toHaveLength(1); + expect(viewModel.feedItems[0]).toBeInstanceOf(DashboardFeedItemSummaryViewModel); + expect(viewModel.feedItems[0].id).toBe('feed-1'); + expect(viewModel.feedItems[0].timestamp).toBeInstanceOf(Date); + + expect(viewModel.friends).toHaveLength(1); + expect(viewModel.friends[0]).toBeInstanceOf(FriendViewModel); + expect(viewModel.friends[0].id).toBe('friend-1'); + }); + + it('exposes the activeLeaguesCount from the DTO', () => { + const dto = createDashboardOverviewDto(); + const viewModel = new DashboardOverviewViewModel(dto); + + expect(viewModel.activeLeaguesCount).toBe(3); + }); +}); diff --git a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.test.ts b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.test.ts index 7c9cd1840..fa9605ff8 100644 --- a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.test.ts +++ b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.test.ts @@ -3,7 +3,7 @@ import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO'; describe('DriverLeaderboardItemViewModel', () => { - const mockDTO: DriverLeaderboardItemDTO = { + const baseDto: DriverLeaderboardItemDTO & { avatarUrl: string } = { id: '1', name: 'Test Driver', rating: 1500, @@ -13,63 +13,65 @@ describe('DriverLeaderboardItemViewModel', () => { wins: 10, podiums: 25, isActive: true, - rank: 5 + rank: 5, + avatarUrl: 'https://example.com/avatar.jpg', }; - it('should create instance from DTO', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1); - + it('should create instance from DTO with avatar', () => { + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1); + expect(viewModel.id).toBe('1'); expect(viewModel.name).toBe('Test Driver'); expect(viewModel.position).toBe(1); + expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg'); }); it('should calculate win rate correctly', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1); - + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1); + expect(viewModel.winRate).toBe(20); // 10/50 * 100 }); it('should format win rate as percentage', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1); - + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1); + expect(viewModel.winRateFormatted).toBe('20.0%'); }); it('should return correct skill level color', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1); - + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1); + expect(viewModel.skillLevelColor).toBe('orange'); // advanced = orange }); it('should return correct skill level icon', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1); - + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1); + expect(viewModel.skillLevelIcon).toBe('🥇'); // advanced = 🥇 }); it('should detect rating trend up', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400); - + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400); + expect(viewModel.ratingTrend).toBe('up'); }); it('should detect rating trend down', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1600); - + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1600); + expect(viewModel.ratingTrend).toBe('down'); }); it('should show rating change indicator', () => { - const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400); - + const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400); + expect(viewModel.ratingChangeIndicator).toBe('+100'); }); it('should handle zero races for win rate', () => { - const dto = { ...mockDTO, racesCompleted: 0, wins: 0 }; + const dto = { ...baseDto, racesCompleted: 0, wins: 0 }; const viewModel = new DriverLeaderboardItemViewModel(dto, 1); - + expect(viewModel.winRate).toBe(0); }); }); \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverLeaderboardViewModel.test.ts b/apps/website/lib/view-models/DriverLeaderboardViewModel.test.ts new file mode 100644 index 000000000..9f74b4000 --- /dev/null +++ b/apps/website/lib/view-models/DriverLeaderboardViewModel.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { DriverLeaderboardViewModel } from './DriverLeaderboardViewModel'; +import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel'; +import type { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO'; + +const createDriver = (overrides: Partial = {}): DriverLeaderboardItemDTO & { avatarUrl: string } => ({ + id: 'driver-1', + name: 'Driver One', + rating: 1500, + skillLevel: 'advanced', + nationality: 'DE', + racesCompleted: 10, + wins: 3, + podiums: 5, + isActive: true, + rank: 1, + avatarUrl: 'https://example.com/avatar-1.jpg', + ...overrides, +}); + +describe('DriverLeaderboardViewModel', () => { + it('wraps DTO drivers into DriverLeaderboardItemViewModel instances', () => { + const drivers = [createDriver({ id: 'driver-1' }), createDriver({ id: 'driver-2', name: 'Driver Two' })]; + const viewModel = new DriverLeaderboardViewModel({ drivers }); + + expect(viewModel.drivers).toHaveLength(2); + expect(viewModel.drivers[0]).toBeInstanceOf(DriverLeaderboardItemViewModel); + expect(viewModel.drivers[0].position).toBe(1); + expect(viewModel.drivers[1].position).toBe(2); + }); + + it('computes aggregate totals and active count', () => { + const drivers = [ + createDriver({ id: 'driver-1', racesCompleted: 10, wins: 3, isActive: true }), + createDriver({ id: 'driver-2', racesCompleted: 5, wins: 1, isActive: false }), + ]; + + const viewModel = new DriverLeaderboardViewModel({ drivers }); + + expect(viewModel.totalRaces).toBe(15); + expect(viewModel.totalWins).toBe(4); + expect(viewModel.activeCount).toBe(1); + }); + + it('passes previous rating to items when provided', () => { + const currentDrivers = [ + createDriver({ id: 'driver-1', rating: 1550 }), + createDriver({ id: 'driver-2', rating: 1400 }), + ]; + + const previousDrivers: (DriverLeaderboardItemDTO & { avatarUrl: string })[] = [ + { ...createDriver({ id: 'driver-1', rating: 1500 }) }, + { ...createDriver({ id: 'driver-2', rating: 1450 }) }, + ]; + + const viewModel = new DriverLeaderboardViewModel({ drivers: currentDrivers }, previousDrivers); + + expect(viewModel.drivers[0].ratingTrend).toBe('up'); + expect(viewModel.drivers[1].ratingTrend).toBe('down'); + }); +}); diff --git a/apps/website/lib/view-models/DriverLeaderboardViewModel.ts b/apps/website/lib/view-models/DriverLeaderboardViewModel.ts index 0c1f53d58..854553968 100644 --- a/apps/website/lib/view-models/DriverLeaderboardViewModel.ts +++ b/apps/website/lib/view-models/DriverLeaderboardViewModel.ts @@ -4,7 +4,7 @@ import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel export class DriverLeaderboardViewModel { drivers: DriverLeaderboardItemViewModel[]; - constructor(dto: { drivers: DriverLeaderboardItemDTO[] }, previousDrivers?: DriverLeaderboardItemDTO[]) { + constructor(dto: { drivers: (DriverLeaderboardItemDTO & { avatarUrl: string })[] }, previousDrivers?: (DriverLeaderboardItemDTO & { avatarUrl: string })[]) { this.drivers = dto.drivers.map((driver, index) => { const previous = previousDrivers?.find(p => p.id === driver.id); return new DriverLeaderboardItemViewModel(driver, index + 1, previous?.rating); @@ -13,7 +13,7 @@ export class DriverLeaderboardViewModel { /** UI-specific: Total races across all drivers */ get totalRaces(): number { - return this.drivers.reduce((sum, driver) => sum + driver.races, 0); + return this.drivers.reduce((sum, driver) => sum + driver.racesCompleted, 0); } /** UI-specific: Total wins across all drivers */ diff --git a/apps/website/lib/view-models/DriverProfileViewModel.test.ts b/apps/website/lib/view-models/DriverProfileViewModel.test.ts new file mode 100644 index 000000000..a8d6b7b09 --- /dev/null +++ b/apps/website/lib/view-models/DriverProfileViewModel.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { DriverProfileViewModel } from './DriverProfileViewModel'; + +const createDriverProfileDto = (): DriverProfileViewModel => ({ + currentDriver: { + id: 'driver-1', + name: 'Test Driver', + country: 'DE', + avatarUrl: 'https://example.com/avatar.jpg', + iracingId: 'ir-123', + joinedAt: '2024-01-01T00:00:00Z', + rating: 2500, + globalRank: 42, + consistency: 88, + bio: 'Test bio', + totalDrivers: 1000, + }, + stats: { + totalRaces: 10, + wins: 3, + podiums: 5, + dnfs: 1, + avgFinish: 4.2, + bestFinish: 1, + worstFinish: 15, + finishRate: 90, + winRate: 30, + podiumRate: 50, + percentile: 95, + rating: 2500, + consistency: 88, + overallRank: 10, + }, + finishDistribution: { + totalRaces: 10, + wins: 3, + podiums: 5, + topTen: 8, + dnfs: 1, + other: 1, + }, + teamMemberships: [ + { + teamId: 'team-1', + teamName: 'Test Team', + teamTag: 'TT', + role: 'driver', + joinedAt: '2023-01-01T00:00:00Z', + isCurrent: true, + }, + ], + socialSummary: { + friendsCount: 5, + friends: [ + { + id: 'friend-1', + name: 'Friend 1', + country: 'US', + avatarUrl: 'https://example.com/friend.jpg', + }, + ], + }, + extendedProfile: { + socialHandles: [ + { + platform: 'twitter', + handle: '@test', + url: 'https://twitter.com/test', + }, + ], + achievements: [ + { + id: 'ach-1', + title: 'Champion', + description: 'Won a championship', + icon: 'trophy', + rarity: 'epic', + earnedAt: '2024-01-10T00:00:00Z', + }, + ], + racingStyle: 'Aggressive', + favoriteTrack: 'Spa', + favoriteCar: 'F3', + timezone: 'Europe/Berlin', + availableHours: 'Evenings', + lookingForTeam: false, + openToRequests: true, + }, +}); + +describe('DriverProfileViewModel', () => { + it('exposes profile sections from DTO', () => { + const dto = createDriverProfileDto(); + const viewModel = new DriverProfileViewModel(dto); + + expect(viewModel.currentDriver).toEqual(dto.currentDriver); + expect(viewModel.stats).toEqual(dto.stats); + expect(viewModel.finishDistribution).toEqual(dto.finishDistribution); + expect(viewModel.teamMemberships).toEqual(dto.teamMemberships); + expect(viewModel.socialSummary).toEqual(dto.socialSummary); + expect(viewModel.extendedProfile).toEqual(dto.extendedProfile); + }); + + it('supports nullable sections', () => { + const dto: DriverProfileViewModel = { + ...createDriverProfileDto(), + currentDriver: null, + stats: null, + finishDistribution: null, + extendedProfile: null, + }; + + const viewModel = new DriverProfileViewModel(dto); + + expect(viewModel.currentDriver).toBeNull(); + expect(viewModel.stats).toBeNull(); + expect(viewModel.finishDistribution).toBeNull(); + expect(viewModel.extendedProfile).toBeNull(); + }); + + it('returns original DTO from toDTO', () => { + const dto = createDriverProfileDto(); + const viewModel = new DriverProfileViewModel(dto); + + expect(viewModel.toDTO()).toBe(dto); + }); +}); diff --git a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts new file mode 100644 index 000000000..b3076e45a --- /dev/null +++ b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel'; +import type { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO'; + +const createStatusDto = (overrides: Partial = {}): DriverRegistrationStatusDTO => ({ + isRegistered: true, + raceId: 'race-1', + driverId: 'driver-1', + ...overrides, +}); + +describe('DriverRegistrationStatusViewModel', () => { + it('maps basic registration status fields from DTO', () => { + const dto = createStatusDto({ isRegistered: true }); + const viewModel = new DriverRegistrationStatusViewModel(dto); + + expect(viewModel.isRegistered).toBe(true); + expect(viewModel.raceId).toBe('race-1'); + expect(viewModel.driverId).toBe('driver-1'); + }); + + it('derives UI fields when registered', () => { + const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: true })); + + expect(viewModel.statusMessage).toBe('Registered for this race'); + expect(viewModel.statusColor).toBe('green'); + expect(viewModel.statusBadgeVariant).toBe('success'); + expect(viewModel.registrationButtonText).toBe('Withdraw'); + expect(viewModel.canRegister).toBe(false); + }); + + it('derives UI fields when not registered', () => { + const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: false })); + + expect(viewModel.statusMessage).toBe('Not registered'); + expect(viewModel.statusColor).toBe('red'); + expect(viewModel.statusBadgeVariant).toBe('warning'); + expect(viewModel.registrationButtonText).toBe('Register'); + expect(viewModel.canRegister).toBe(true); + }); +}); diff --git a/apps/website/lib/view-models/DriverSummaryViewModel.test.ts b/apps/website/lib/view-models/DriverSummaryViewModel.test.ts new file mode 100644 index 000000000..be9efae3d --- /dev/null +++ b/apps/website/lib/view-models/DriverSummaryViewModel.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { DriverSummaryViewModel } from './DriverSummaryViewModel'; +import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO'; + +const driverDto: GetDriverOutputDTO = { + id: 'driver-1', + iracingId: 'ir-123', + name: 'Test Driver', + country: 'DE', + joinedAt: '2024-01-01T00:00:00Z', +}; + +describe('DriverSummaryViewModel', () => { + it('maps driver and optional fields from DTO', () => { + const viewModel = new DriverSummaryViewModel({ + driver: driverDto, + rating: 2500, + rank: 10, + }); + + expect(viewModel.driver).toBe(driverDto); + expect(viewModel.rating).toBe(2500); + expect(viewModel.rank).toBe(10); + }); + + it('defaults nullable rating and rank when undefined', () => { + const viewModel = new DriverSummaryViewModel({ + driver: driverDto, + }); + + expect(viewModel.rating).toBeNull(); + expect(viewModel.rank).toBeNull(); + }); + + it('keeps explicit null rating and rank', () => { + const viewModel = new DriverSummaryViewModel({ + driver: driverDto, + rating: null, + rank: null, + }); + + expect(viewModel.rating).toBeNull(); + expect(viewModel.rank).toBeNull(); + }); +}); diff --git a/apps/website/lib/view-models/DriverTeamViewModel.test.ts b/apps/website/lib/view-models/DriverTeamViewModel.test.ts new file mode 100644 index 000000000..022e1529a --- /dev/null +++ b/apps/website/lib/view-models/DriverTeamViewModel.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { DriverTeamViewModel } from './DriverTeamViewModel'; +import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO'; + +const createTeamDto = (overrides: Partial = {}): GetDriverTeamOutputDTO => ({ + team: { + id: 'team-1', + name: 'Test Team', + tag: 'TT', + description: 'Test team description', + ownerId: 'owner-1', + leagues: ['league-1'], + createdAt: '2024-01-01T00:00:00Z', + specialization: 'mixed', + region: 'EU', + languages: ['en'], + }, + membership: { + role: 'manager', + joinedAt: '2024-01-01T00:00:00Z', + isActive: true, + }, + isOwner: false, + canManage: true, + ...overrides, +}); + +describe('DriverTeamViewModel', () => { + it('maps team and membership fields from DTO', () => { + const dto = createTeamDto(); + const viewModel = new DriverTeamViewModel(dto); + + expect(viewModel.teamId).toBe('team-1'); + expect(viewModel.teamName).toBe('Test Team'); + expect(viewModel.tag).toBe('TT'); + expect(viewModel.role).toBe('manager'); + expect(viewModel.isOwner).toBe(false); + expect(viewModel.canManage).toBe(true); + }); + + it('derives displayRole with capitalized first letter', () => { + const dto = createTeamDto({ + membership: { + role: 'owner', + joinedAt: '2024-01-01T00:00:00Z', + isActive: true, + }, + }); + + const viewModel = new DriverTeamViewModel(dto); + + expect(viewModel.displayRole).toBe('Owner'); + }); + + it('handles lower-case role strings consistently', () => { + const dto = createTeamDto({ + membership: { + role: 'member', + joinedAt: '2024-01-01T00:00:00Z', + isActive: true, + }, + }); + + const viewModel = new DriverTeamViewModel(dto); + + expect(viewModel.displayRole).toBe('Member'); + }); +}); diff --git a/apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts b/apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts new file mode 100644 index 000000000..92db7e4fb --- /dev/null +++ b/apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { HomeDiscoveryViewModel } from './HomeDiscoveryViewModel'; +import { LeagueCardViewModel } from './LeagueCardViewModel'; +import { TeamCardViewModel } from './TeamCardViewModel'; +import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel'; + +describe('HomeDiscoveryViewModel', () => { + it('exposes the top leagues, teams and upcoming races from the DTO', () => { + const topLeagues = [ + new LeagueCardViewModel({ + id: 'league-1', + name: 'Pro League', + description: 'Top-tier league', + memberCount: 100, + isMember: false, + } as any), + ]; + + const teams = [ + new TeamCardViewModel({ + id: 'team-1', + name: 'Team Alpha', + tag: 'ALPHA', + memberCount: 10, + isMember: true, + } as any), + ]; + + const upcomingRaces = [ + new UpcomingRaceCardViewModel({ + id: 'race-1', + track: 'Spa-Francorchamps', + car: 'GT3', + scheduledAt: '2025-01-01T12:00:00Z', + } as any), + ]; + + const viewModel = new HomeDiscoveryViewModel({ + topLeagues, + teams, + upcomingRaces, + }); + + expect(viewModel.topLeagues).toBe(topLeagues); + expect(viewModel.teams).toBe(teams); + expect(viewModel.upcomingRaces).toBe(upcomingRaces); + }); +}); diff --git a/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts new file mode 100644 index 000000000..b79379150 --- /dev/null +++ b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { ImportRaceResultsSummaryViewModel } from './ImportRaceResultsSummaryViewModel'; + +describe('ImportRaceResultsSummaryViewModel', () => { + it('maps DTO fields including errors', () => { + const dto = { + success: true, + raceId: 'race-1', + driversProcessed: 10, + resultsRecorded: 8, + errors: ['Driver missing', 'Invalid lap time'], + }; + + const viewModel = new ImportRaceResultsSummaryViewModel(dto); + + expect(viewModel.success).toBe(true); + expect(viewModel.raceId).toBe('race-1'); + expect(viewModel.driversProcessed).toBe(10); + expect(viewModel.resultsRecorded).toBe(8); + expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']); + }); + + it('defaults errors to an empty array when not provided', () => { + const dto = { + success: false, + raceId: 'race-2', + driversProcessed: 0, + resultsRecorded: 0, + }; + + const viewModel = new ImportRaceResultsSummaryViewModel(dto); + + expect(viewModel.errors).toEqual([]); + }); +}); diff --git a/apps/website/lib/view-models/LeagueCardViewModel.test.ts b/apps/website/lib/view-models/LeagueCardViewModel.test.ts new file mode 100644 index 000000000..e374d0fb7 --- /dev/null +++ b/apps/website/lib/view-models/LeagueCardViewModel.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueCardViewModel } from './LeagueCardViewModel'; +import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO'; + +const createSummaryDto = (overrides: Partial = {}): LeagueSummaryDTO & { description?: string } => ({ + id: 'league-1', + name: 'Test League', + description: 'Custom description', + ...overrides, +}); + +describe('LeagueCardViewModel', () => { + it('maps id, name and description from simple DTO', () => { + const dto = { id: 'simple-id', name: 'Simple League', description: 'Simple desc' }; + + const vm = new LeagueCardViewModel(dto); + + expect(vm.id).toBe('simple-id'); + expect(vm.name).toBe('Simple League'); + expect(vm.description).toBe('Simple desc'); + }); + + it('supports LeagueSummaryDTO without description and applies default description', () => { + const dto: LeagueSummaryDTO = { id: 'sum-1', name: 'Summary League' }; + + const vm = new LeagueCardViewModel(dto); + + expect(vm.id).toBe('sum-1'); + expect(vm.name).toBe('Summary League'); + expect(vm.description).toBe('Competitive iRacing league'); + }); + + it('prefers provided description when using LeagueSummaryDTO with extra description', () => { + const dto = createSummaryDto({ id: 'sum-2', name: 'Summary With Description', description: 'Provided description' }); + + const vm = new LeagueCardViewModel(dto); + + expect(vm.id).toBe('sum-2'); + expect(vm.name).toBe('Summary With Description'); + expect(vm.description).toBe('Provided description'); + }); + + it('falls back to default description when description is undefined', () => { + const dto = createSummaryDto({ description: undefined }); + + const vm = new LeagueCardViewModel(dto); + + expect(vm.description).toBe('Competitive iRacing league'); + }); +}); diff --git a/apps/website/lib/view-models/LeagueDetailPageViewModel.test.ts b/apps/website/lib/view-models/LeagueDetailPageViewModel.test.ts new file mode 100644 index 000000000..e5a63f2bd --- /dev/null +++ b/apps/website/lib/view-models/LeagueDetailPageViewModel.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueDetailPageViewModel, type SponsorInfo } from './LeagueDetailPageViewModel'; +import type { LeagueWithCapacityDTO } from '../types/generated/LeagueWithCapacityDTO'; +import type { LeagueStatsDTO } from '../types/generated/LeagueStatsDTO'; +import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO'; +import type { LeagueMembershipsDTO } from '../types/generated/LeagueMembershipsDTO'; +import type { LeagueScheduleDTO } from '../types/generated/LeagueScheduleDTO'; +import { RaceViewModel } from './RaceViewModel'; + +describe('LeagueDetailPageViewModel', () => { + const league: LeagueWithCapacityDTO = { + id: 'league-1', + name: 'Pro Series', + // extra fields used by view-model but not in generated type yet + description: 'Top tier competition', + ownerId: 'owner-1', + createdAt: '2025-01-01T00:00:00Z', + maxDrivers: 40, + socialLinks: { + discordUrl: 'https://discord.gg/example', + youtubeUrl: 'https://youtube.com/example', + websiteUrl: 'https://example.com', + }, + } as any; + + const owner: GetDriverOutputDTO = { + id: 'owner-1', + iracingId: '100', + name: 'Owner Driver', + country: 'US', + joinedAt: '2024-01-01T00:00:00Z', + }; + + const drivers: GetDriverOutputDTO[] = [ + owner, + { + id: 'driver-2', + iracingId: '101', + name: 'Second Driver', + country: 'DE', + joinedAt: '2024-02-01T00:00:00Z', + }, + ]; + + const memberships: LeagueMembershipsDTO = { + memberships: [ + { + driverId: 'owner-1', + role: 'owner', + status: 'active', + joinedAt: '2024-01-01T00:00:00Z', + }, + { + driverId: 'driver-2', + role: 'admin', + status: 'active', + joinedAt: '2024-02-01T00:00:00Z', + }, + { + driverId: 'driver-3', + role: 'steward', + status: 'inactive', + joinedAt: '2024-03-01T00:00:00Z', + }, + ], + } as any; + + const schedule: LeagueScheduleDTO = { + races: [ + { id: 'race-1', name: 'Round 1', date: '2025-01-10T20:00:00Z' }, + { id: 'race-2', name: 'Round 2', date: '2025-01-17T20:00:00Z' }, + ], + } as any; + + const allRaces: RaceViewModel[] = schedule.races.map(r => new RaceViewModel({ ...r, views: 12000, status: 'completed' })); + + const leagueStats: LeagueStatsDTO = { + totalMembers: 2, + totalRaces: 10, + averageRating: 3_200, + // view-model also expects averageSOF and completedRaces + averageSOF: 3200, + completedRaces: 4, + } as any; + + const sponsors: SponsorInfo[] = [ + { id: 's1', name: 'Main Sponsor', tier: 'main', logoUrl: '/logo-main.png', tagline: 'Primary partner' }, + { id: 's2', name: 'Secondary Sponsor', tier: 'secondary', logoUrl: '/logo-sec.png' }, + ]; + + it('maps core league, owner, drivers, memberships and races', () => { + const vm = new LeagueDetailPageViewModel( + league, + owner, + null, + drivers, + memberships, + allRaces, + leagueStats, + sponsors, + ); + + expect(vm.id).toBe(league.id); + expect(vm.name).toBe(league.name); + expect(vm.description).toBe(league.description); + expect(vm.ownerId).toBe(league.ownerId); + expect(vm.settings.maxDrivers).toBe(league.maxDrivers); + expect(vm.socialLinks?.discordUrl).toBe(league.socialLinks?.discordUrl); + + expect(vm.owner).toEqual(owner); + expect(vm.scoringConfig).toBeNull(); + + expect(vm.drivers).toHaveLength(drivers.length); + expect(vm.memberships).toHaveLength(memberships.memberships.length); + + expect(vm.allRaces).toHaveLength(allRaces.length); + expect(vm.runningRaces.every(r => r.status === 'running')).toBe(true); + }); + + it('computes sponsor insights from member count and stats', () => { + const vm = new LeagueDetailPageViewModel( + league, + owner, + null, + drivers, + memberships, + allRaces, + leagueStats, + sponsors, + ); + + const memberCount = memberships.memberships.length; + const mainSponsorTaken = sponsors.some(s => s.tier === 'main'); + const secondaryTaken = sponsors.filter(s => s.tier === 'secondary').length; + + expect(vm.sponsorInsights.avgViewsPerRace).toBe(5400 + memberCount * 50); + expect(vm.sponsorInsights.totalImpressions).toBe(45000 + memberCount * 500); + expect(vm.sponsorInsights.engagementRate).toBe((3.5 + memberCount / 50).toFixed(1)); + expect(vm.sponsorInsights.estimatedReach).toBe(memberCount * 150); + expect(vm.sponsorInsights.mainSponsorAvailable).toBe(!mainSponsorTaken); + expect(vm.sponsorInsights.secondarySlotsAvailable).toBe(Math.max(0, 2 - secondaryTaken)); + expect(vm.sponsorInsights.mainSponsorPrice).toBe(800 + Math.floor(memberCount * 10)); + expect(vm.sponsorInsights.secondaryPrice).toBe(250 + Math.floor(memberCount * 3)); + expect(vm.sponsorInsights.discordMembers).toBe(memberCount * 3); + expect(vm.sponsorInsights.monthlyActivity).toBe(Math.min(100, 40 + (leagueStats as any).completedRaces * 2)); + }); + + it('derives sponsor tier and trustScore from averageSOF and completedRaces', () => { + const highSofStats = { ...leagueStats, averageSOF: 3500 } as any; + const vmHigh = new LeagueDetailPageViewModel( + league, + owner, + null, + drivers, + memberships, + allRaces, + highSofStats, + sponsors, + ); + + expect(vmHigh.sponsorInsights.tier).toBe('premium'); + + const midSofStats = { ...leagueStats, averageSOF: 2500 } as any; + const vmMid = new LeagueDetailPageViewModel( + league, + owner, + null, + drivers, + memberships, + allRaces, + midSofStats, + sponsors, + ); + + expect(vmMid.sponsorInsights.tier).toBe('standard'); + + const lowSofStats = { ...leagueStats, averageSOF: 1500 } as any; + const vmLow = new LeagueDetailPageViewModel( + league, + owner, + null, + drivers, + memberships, + allRaces, + lowSofStats, + sponsors, + ); + + expect(vmLow.sponsorInsights.tier).toBe('starter'); + + expect(vmHigh.sponsorInsights.trustScore).toBe( + Math.min(100, 60 + memberships.memberships.length + (leagueStats as any).completedRaces), + ); + }); + + it('builds owner, admin and steward summaries from memberships', () => { + const vm = new LeagueDetailPageViewModel( + league, + owner, + null, + drivers, + memberships, + allRaces, + leagueStats, + sponsors, + ); + + expect(vm.ownerSummary?.driver.id).toBe('owner-1'); + expect(vm.ownerSummary?.rating).toBeNull(); + expect(vm.ownerSummary?.rank).toBeNull(); + + expect(vm.adminSummaries.length).toBeGreaterThanOrEqual(1); + expect(vm.adminSummaries[0].driver.id).toBe('driver-2'); + + expect(Array.isArray(vm.stewardSummaries)).toBe(true); + }); + + it('returns falsey sponsor/membership helpers by default', () => { + const vm = new LeagueDetailPageViewModel( + league, + owner, + null, + drivers, + memberships, + allRaces, + leagueStats, + sponsors, + ); + + expect(vm.isSponsorMode).toBe(false); + expect(vm.currentUserMembership).toBeNull(); + expect(vm.canEndRaces).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/LeagueDetailViewModel.test.ts b/apps/website/lib/view-models/LeagueDetailViewModel.test.ts new file mode 100644 index 000000000..a88a204b4 --- /dev/null +++ b/apps/website/lib/view-models/LeagueDetailViewModel.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueDetailViewModel, LeagueViewModel, DriverViewModel, RaceViewModel } from './LeagueDetailViewModel'; + +describe('LeagueDetailViewModel', () => { + const baseLeague = { + id: 'league-1', + name: 'Pro Series', + game: 'iRacing', + tier: 'premium' as const, + season: '2025 Season 1', + description: 'High level competition', + drivers: 24, + races: 10, + completedRaces: 4, + totalImpressions: 100_000, + avgViewsPerRace: 10_000, + engagement: 3.5, + rating: 4.7, + seasonStatus: 'active' as const, + seasonDates: { start: '2025-01-01', end: '2025-04-01' }, + nextRace: { name: 'Round 5', date: '2025-02-01' }, + sponsorSlots: { + main: { available: true, price: 5_000, benefits: ['Branding', 'Mentions'] }, + secondary: { available: 2, total: 3, price: 2_000, benefits: ['Branding'] }, + }, + }; + + const drivers = [ + { id: 'd1', name: 'Alice', country: 'US', position: 1, races: 4, impressions: 30_000, team: 'Alpha' }, + { id: 'd2', name: 'Bob', country: 'DE', position: 2, races: 4, impressions: 25_000, team: 'Beta' }, + ]; + + const races = [ + { id: 'r1', name: 'Round 1', date: '2025-01-01', views: 12_000, status: 'completed' as const }, + { id: 'r2', name: 'Round 2', date: '2025-01-08', views: 11_000, status: 'upcoming' as const }, + ]; + + it('maps nested league, drivers and races into view models', () => { + const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races }); + + expect(vm.league).toBeInstanceOf(LeagueViewModel); + expect(vm.drivers).toHaveLength(2); + expect(vm.drivers[0]).toBeInstanceOf(DriverViewModel); + expect(vm.races).toHaveLength(2); + expect(vm.races[0]).toBeInstanceOf(RaceViewModel); + }); + + it('keeps core league metrics available on LeagueViewModel', () => { + const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races }); + + expect(vm.league.id).toBe('league-1'); + expect(vm.league.drivers).toBe(24); + expect(vm.league.races).toBe(10); + expect(vm.league.completedRaces).toBe(4); + expect(vm.league.totalImpressions).toBe(100_000); + expect(vm.league.avgViewsPerRace).toBe(10_000); + }); + + it('exposes formatted impression and views statistics', () => { + const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races }); + + expect(vm.league.formattedTotalImpressions).toBe(baseLeague.totalImpressions.toLocaleString()); + expect(vm.league.formattedAvgViewsPerRace).toBe(baseLeague.avgViewsPerRace.toLocaleString()); + expect(vm.league.projectedTotalViews).toBe(baseLeague.avgViewsPerRace * baseLeague.races); + expect(vm.league.formattedProjectedTotal).toBe(vm.league.projectedTotalViews.toLocaleString()); + }); + + it('calculates CPM for main sponsor from projected total views', () => { + const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races }); + + const expectedCpm = Math.round((baseLeague.sponsorSlots.main.price / vm.league.projectedTotalViews) * 1000); + + expect(vm.league.mainSponsorCpm).toBe(expectedCpm); + expect(vm.league.formattedMainSponsorCpm).toBe(`$${expectedCpm.toFixed(2)}`); + }); + + it('derives races left and tier configuration', () => { + const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races }); + + expect(vm.league.racesLeft).toBe(baseLeague.races - baseLeague.completedRaces); + const tierConfig = vm.league.tierConfig; + expect(tierConfig.color).toBeDefined(); + expect(tierConfig.bgColor).toBeDefined(); + expect(tierConfig.border).toBeDefined(); + }); + + it('formats driver impressions and race date/views', () => { + const vm = new LeagueDetailViewModel({ league: baseLeague, drivers, races }); + + expect(vm.drivers[0].formattedImpressions).toBe(drivers[0].impressions.toLocaleString()); + // formattedDate uses a short, locale-specific date string like "Jan 1" + const formattedDate = vm.races[0].formattedDate; + expect(typeof formattedDate).toBe('string'); + expect(formattedDate.length).toBeGreaterThan(0); + expect(vm.races[0].formattedViews).toBe(races[0].views.toLocaleString()); + }); +}); diff --git a/apps/website/lib/view-models/LeagueJoinRequestViewModel.test.ts b/apps/website/lib/view-models/LeagueJoinRequestViewModel.test.ts new file mode 100644 index 000000000..5e8a96944 --- /dev/null +++ b/apps/website/lib/view-models/LeagueJoinRequestViewModel.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel'; +import type { LeagueJoinRequestDTO } from '../types/generated/LeagueJoinRequestDTO'; + +const createLeagueJoinRequestDto = (overrides: Partial = {}): LeagueJoinRequestDTO => ({ + id: 'request-1', + leagueId: 'league-1', + driverId: 'driver-1', + requestedAt: '2024-01-01T12:00:00Z', + ...overrides, +}); + +describe('LeagueJoinRequestViewModel', () => { + it('maps fields from DTO', () => { + const dto = createLeagueJoinRequestDto({ id: 'req-123', driverId: 'driver-123' }); + + const vm = new LeagueJoinRequestViewModel(dto, 'current-user', true); + + expect(vm.id).toBe('req-123'); + expect(vm.leagueId).toBe('league-1'); + expect(vm.driverId).toBe('driver-123'); + expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z'); + }); + + it('formats requestedAt as a localized date-time string', () => { + const dto = createLeagueJoinRequestDto({ requestedAt: '2024-01-01T12:00:00Z' }); + const vm = new LeagueJoinRequestViewModel(dto, 'current-user', true); + + const formatted = vm.formattedRequestedAt; + + expect(typeof formatted).toBe('string'); + expect(formatted.length).toBeGreaterThan(0); + }); + + it('allows approval and rejection only for admins', () => { + const dto = createLeagueJoinRequestDto(); + + const adminVm = new LeagueJoinRequestViewModel(dto, 'admin-user', true); + const nonAdminVm = new LeagueJoinRequestViewModel(dto, 'regular-user', false); + + expect(adminVm.canApprove).toBe(true); + expect(adminVm.canReject).toBe(true); + + expect(nonAdminVm.canApprove).toBe(false); + expect(nonAdminVm.canReject).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/LeagueMemberViewModel.test.ts b/apps/website/lib/view-models/LeagueMemberViewModel.test.ts new file mode 100644 index 000000000..f77d6b690 --- /dev/null +++ b/apps/website/lib/view-models/LeagueMemberViewModel.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueMemberViewModel } from './LeagueMemberViewModel'; +import type { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO'; + +const createLeagueMemberDto = (overrides: Partial = {}): LeagueMemberDTO => ({ + driverId: 'driver-1', + leagueId: 'league-1', + joinedAt: '2024-01-01T00:00:00Z', + role: 'member', + ...overrides, +}); + +describe('LeagueMemberViewModel', () => { + it('maps basic fields from DTO and sets defaults', () => { + const dto = createLeagueMemberDto({ driverId: 'driver-123' }); + + const vm = new LeagueMemberViewModel(dto, 'current-user'); + + expect(vm.driverId).toBe('driver-123'); + expect(vm.role).toBe('member'); + expect(typeof vm.joinedAt).toBe('string'); + }); + + it('computes formattedJoinedAt as a localized date string', () => { + const dto = createLeagueMemberDto({ joinedAt: '2024-01-01T00:00:00Z' }); + const vm = new LeagueMemberViewModel(dto, 'current-user'); + + const formatted = vm.formattedJoinedAt; + + expect(typeof formatted).toBe('string'); + expect(formatted.length).toBeGreaterThan(0); + }); + + it('derives roleBadgeVariant based on current role value', () => { + const vm = new LeagueMemberViewModel(createLeagueMemberDto(), 'current-user'); + + vm.role = 'owner'; + expect(vm.roleBadgeVariant).toBe('primary'); + + vm.role = 'admin'; + expect(vm.roleBadgeVariant).toBe('secondary'); + + vm.role = 'member'; + expect(vm.roleBadgeVariant).toBe('default'); + + vm.role = 'something-else' as any; + expect(vm.roleBadgeVariant).toBe('default'); + }); + + it('identifies owner and current user correctly based on role and driverId', () => { + const dto = createLeagueMemberDto({ driverId: 'driver-1' }); + + const vmForOwner = new LeagueMemberViewModel(dto, 'driver-1'); + vmForOwner.role = 'owner'; + + const vmForAnotherUser = new LeagueMemberViewModel(dto, 'driver-2'); + vmForAnotherUser.role = 'owner'; + + expect(vmForOwner.isOwner).toBe(true); + expect(vmForOwner.isCurrentUser).toBe(true); + + expect(vmForAnotherUser.isOwner).toBe(true); + expect(vmForAnotherUser.isCurrentUser).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/LeagueMembershipsViewModel.test.ts b/apps/website/lib/view-models/LeagueMembershipsViewModel.test.ts new file mode 100644 index 000000000..aa52348a0 --- /dev/null +++ b/apps/website/lib/view-models/LeagueMembershipsViewModel.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueMembershipsViewModel } from './LeagueMembershipsViewModel'; +import { LeagueMemberViewModel } from './LeagueMemberViewModel'; +import type { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO'; + +const createMembershipDto = (overrides: Partial = {}): LeagueMemberDTO => ({ + driverId: 'driver-1', + leagueId: 'league-1', + joinedAt: '2024-01-01T00:00:00Z', + role: 'member', + ...overrides, +}); + +describe('LeagueMembershipsViewModel', () => { + it('maps memberships into LeagueMemberViewModel instances', () => { + const dto = { + memberships: [ + createMembershipDto({ driverId: 'driver-1' }), + createMembershipDto({ driverId: 'driver-2' }), + ], + }; + + const viewModel = new LeagueMembershipsViewModel(dto, 'driver-1'); + + expect(viewModel.memberships).toHaveLength(2); + expect(viewModel.memberships[0]).toBeInstanceOf(LeagueMemberViewModel); + expect(viewModel.memberships[0].driverId).toBe('driver-1'); + expect(viewModel.memberships[1].driverId).toBe('driver-2'); + }); + + it('computes memberCount and hasMembers correctly when there are members', () => { + const dto = { + memberships: [ + createMembershipDto({ driverId: 'driver-1' }), + createMembershipDto({ driverId: 'driver-2' }), + ], + }; + + const viewModel = new LeagueMembershipsViewModel(dto, 'driver-1'); + + expect(viewModel.memberCount).toBe(2); + expect(viewModel.hasMembers).toBe(true); + }); + + it('computes memberCount and hasMembers correctly when empty', () => { + const dto = { + memberships: [] as LeagueMemberDTO[], + }; + + const viewModel = new LeagueMembershipsViewModel(dto, 'driver-1'); + + expect(viewModel.memberCount).toBe(0); + expect(viewModel.hasMembers).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/LeagueScheduleViewModel.test.ts b/apps/website/lib/view-models/LeagueScheduleViewModel.test.ts new file mode 100644 index 000000000..eb89a661c --- /dev/null +++ b/apps/website/lib/view-models/LeagueScheduleViewModel.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueScheduleViewModel } from './LeagueScheduleViewModel'; + +describe('LeagueScheduleViewModel', () => { + it('maps races array from DTO', () => { + const races = [{ id: 'race-1' }, { id: 'race-2' }]; + + const vm = new LeagueScheduleViewModel({ races }); + + expect(vm.races).toBe(races); + expect(vm.raceCount).toBe(2); + }); + + it('derives hasRaces correctly for non-empty schedule', () => { + const races = [{ id: 'race-1' }]; + + const vm = new LeagueScheduleViewModel({ races }); + + expect(vm.raceCount).toBe(1); + expect(vm.hasRaces).toBe(true); + }); + + it('derives hasRaces correctly for empty schedule', () => { + const vm = new LeagueScheduleViewModel({ races: [] }); + + expect(vm.raceCount).toBe(0); + expect(vm.hasRaces).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/LeagueScoringPresetsViewModel.test.ts b/apps/website/lib/view-models/LeagueScoringPresetsViewModel.test.ts new file mode 100644 index 000000000..f0785637e --- /dev/null +++ b/apps/website/lib/view-models/LeagueScoringPresetsViewModel.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueScoringPresetsViewModel } from './LeagueScoringPresetsViewModel'; +import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider'; + +const createPreset = (overrides: Partial = {}): LeagueScoringPresetDTO => ({ + id: 'preset-1', + name: 'Standard scoring', + description: 'Top 15 get points', + gameId: 'iracing', + ...overrides, +} as LeagueScoringPresetDTO); + +describe('LeagueScoringPresetsViewModel', () => { + it('maps presets array from DTO', () => { + const presets = [createPreset(), createPreset({ id: 'preset-2', name: 'Alt scoring' })]; + + const vm = new LeagueScoringPresetsViewModel({ presets }); + + expect(vm.presets).toBe(presets); + expect(vm.totalCount).toBe(2); + }); + + it('uses explicit totalCount when provided', () => { + const presets = [createPreset(), createPreset()]; + + const vm = new LeagueScoringPresetsViewModel({ presets, totalCount: 10 }); + + expect(vm.presets).toBe(presets); + expect(vm.totalCount).toBe(10); + }); + + it('handles empty presets array', () => { + const vm = new LeagueScoringPresetsViewModel({ presets: [] }); + + expect(vm.presets).toEqual([]); + expect(vm.totalCount).toBe(0); + }); +}); diff --git a/apps/website/lib/view-models/LeagueSettingsViewModel.test.ts b/apps/website/lib/view-models/LeagueSettingsViewModel.test.ts new file mode 100644 index 000000000..f742ff970 --- /dev/null +++ b/apps/website/lib/view-models/LeagueSettingsViewModel.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueSettingsViewModel } from './LeagueSettingsViewModel'; +import { DriverSummaryViewModel } from './DriverSummaryViewModel'; +import type { LeagueConfigFormModel } from '@core/racing/application'; +import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider'; + +const createConfig = (overrides: Partial = {}): LeagueConfigFormModel => ({ + name: 'Pro League', + description: 'Top tier competition', + maxDrivers: 40, + maxTeams: 10, + ...overrides, +} as LeagueConfigFormModel); + +const createPreset = (overrides: Partial = {}): LeagueScoringPresetDTO => ({ + id: 'preset-1', + name: 'Standard scoring', + description: 'Top 15 get points', + gameId: 'iracing', + ...overrides, +} as LeagueScoringPresetDTO); + +const createDriverSummary = (id: string, name: string): DriverSummaryViewModel => + new DriverSummaryViewModel({ + driver: { + id, + iracingId: `ir-${id}`, + name, + country: 'DE', + joinedAt: '2024-01-01T00:00:00Z', + }, + }); + +describe('LeagueSettingsViewModel', () => { + it('maps league, config, presets, owner and members from DTO', () => { + const league = { id: 'league-1', name: 'Pro League', ownerId: 'owner-1' }; + const config = createConfig(); + const presets = [createPreset(), createPreset({ id: 'preset-2', name: 'Alt scoring' })]; + const owner = createDriverSummary('driver-1', 'Owner Driver'); + const members = [createDriverSummary('driver-2', 'Member One'), createDriverSummary('driver-3', 'Member Two')]; + + const vm = new LeagueSettingsViewModel({ league, config, presets, owner, members }); + + expect(vm.league).toBe(league); + expect(vm.config).toBe(config); + expect(vm.presets).toBe(presets); + expect(vm.owner).toBe(owner); + expect(vm.members).toBe(members); + }); + + it('allows null owner while keeping members and presets', () => { + const league = { id: 'league-1', name: 'Pro League', ownerId: 'owner-1' }; + const config = createConfig(); + const presets = [createPreset()]; + const members = [createDriverSummary('driver-2', 'Member One')]; + + const vm = new LeagueSettingsViewModel({ league, config, presets, owner: null, members }); + + expect(vm.owner).toBeNull(); + expect(vm.members).toHaveLength(1); + expect(vm.presets).toHaveLength(1); + }); +}); diff --git a/apps/website/lib/view-models/LeagueStandingsViewModel.test.ts b/apps/website/lib/view-models/LeagueStandingsViewModel.test.ts index 4c25e2ea1..d7fdf005b 100644 --- a/apps/website/lib/view-models/LeagueStandingsViewModel.test.ts +++ b/apps/website/lib/view-models/LeagueStandingsViewModel.test.ts @@ -24,7 +24,7 @@ describe('LeagueStandingsViewModel', () => { ]; const viewModel = new LeagueStandingsViewModel( - { standings }, + { standings, drivers: [], memberships: [] }, 'driver-1' ); @@ -46,7 +46,7 @@ describe('LeagueStandingsViewModel', () => { ]; const viewModel = new LeagueStandingsViewModel( - { standings }, + { standings, drivers: [], memberships: [] }, 'driver-1' ); @@ -82,7 +82,7 @@ describe('LeagueStandingsViewModel', () => { ]; const viewModel = new LeagueStandingsViewModel( - { standings }, + { standings, drivers: [], memberships: [] }, 'driver-2' ); @@ -111,7 +111,7 @@ describe('LeagueStandingsViewModel', () => { ]; const viewModel = new LeagueStandingsViewModel( - { standings }, + { standings, drivers: [], memberships: [] }, 'driver-2' ); @@ -121,7 +121,7 @@ describe('LeagueStandingsViewModel', () => { it('should handle empty standings', () => { const viewModel = new LeagueStandingsViewModel( - { standings: [] }, + { standings: [], drivers: [], memberships: [] }, 'driver-1' ); @@ -168,7 +168,7 @@ describe('LeagueStandingsViewModel', () => { ]; const viewModel = new LeagueStandingsViewModel( - { standings }, + { standings, drivers: [], memberships: [] }, 'driver-1', previousStandings ); diff --git a/apps/website/lib/view-models/LeagueStatsViewModel.test.ts b/apps/website/lib/view-models/LeagueStatsViewModel.test.ts new file mode 100644 index 000000000..f12f87ec7 --- /dev/null +++ b/apps/website/lib/view-models/LeagueStatsViewModel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueStatsViewModel } from './LeagueStatsViewModel'; + +const createDto = (overrides: Partial<{ totalLeagues: number }> = {}): { totalLeagues: number } => ({ + totalLeagues: 1234, + ...overrides, +}); + +describe('LeagueStatsViewModel', () => { + it('maps totalLeagues from DTO', () => { + const dto = createDto({ totalLeagues: 42 }); + + const vm = new LeagueStatsViewModel(dto); + + expect(vm.totalLeagues).toBe(42); + }); + + it('formats totalLeagues using locale separators', () => { + const dto = createDto({ totalLeagues: 12345 }); + + const vm = new LeagueStatsViewModel(dto); + + expect(vm.formattedTotalLeagues).toBe((12345).toLocaleString()); + }); + + it('handles zero leagues correctly', () => { + const dto = createDto({ totalLeagues: 0 }); + + const vm = new LeagueStatsViewModel(dto); + + expect(vm.totalLeagues).toBe(0); + expect(vm.formattedTotalLeagues).toBe('0'); + }); +}); diff --git a/apps/website/lib/view-models/LeagueStewardingViewModel.test.ts b/apps/website/lib/view-models/LeagueStewardingViewModel.test.ts new file mode 100644 index 000000000..560482cc9 --- /dev/null +++ b/apps/website/lib/view-models/LeagueStewardingViewModel.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueStewardingViewModel, type RaceWithProtests, type Protest, type Penalty } from './LeagueStewardingViewModel'; + +const createProtest = (id: string, status: string): Protest => ({ + id, + protestingDriverId: `pd-${id}`, + accusedDriverId: `ad-${id}`, + incident: { lap: 1, description: 'Test incident' }, + filedAt: '2024-01-01T21:00:00Z', + status, +}); + +const createPenalty = (id: string): Penalty => ({ + id, + driverId: `d-${id}`, + type: 'time', + value: 5, + reason: 'Test penalty', +}); + +const createRaceWithProtests = (id: string, pendingCount: number, resolvedCount: number, penaltiesCount: number): RaceWithProtests => ({ + race: { + id, + track: 'Spa-Francorchamps', + scheduledAt: new Date('2024-01-01T20:00:00Z'), + }, + pendingProtests: Array.from({ length: pendingCount }, (_, index) => createProtest(`${id}-pending-${index + 1}`, 'pending')), + resolvedProtests: Array.from({ length: resolvedCount }, (_, index) => createProtest(`${id}-resolved-${index + 1}`, 'upheld')), + penalties: Array.from({ length: penaltiesCount }, (_, index) => createPenalty(`${id}-pen-${index + 1}`)), +}); + +describe('LeagueStewardingViewModel', () => { + it('aggregates totals across all races', () => { + const races: RaceWithProtests[] = [ + createRaceWithProtests('race-1', 2, 1, 1), + createRaceWithProtests('race-2', 1, 3, 2), + createRaceWithProtests('race-3', 0, 0, 0), + ]; + + const driverMap = { + d1: { id: 'd1', name: 'Driver 1' }, + d2: { id: 'd2', name: 'Driver 2' }, + }; + + const viewModel = new LeagueStewardingViewModel(races, driverMap); + + expect(viewModel.totalPending).toBe(3); + expect(viewModel.totalResolved).toBe(4); + expect(viewModel.totalPenalties).toBe(3); + }); + + it('filters races into pending and history buckets', () => { + const races: RaceWithProtests[] = [ + createRaceWithProtests('race-1', 2, 0, 0), // pending only + createRaceWithProtests('race-2', 0, 3, 0), // history only (resolved) + createRaceWithProtests('race-3', 0, 0, 2), // history only (penalties) + createRaceWithProtests('race-4', 0, 0, 0), // no activity + ]; + + const viewModel = new LeagueStewardingViewModel(races, {}); + + const pendingIds = viewModel.pendingRaces.map(r => r.race.id).sort(); + const historyIds = viewModel.historyRaces.map(r => r.race.id).sort(); + + expect(pendingIds).toEqual(['race-1']); + expect(historyIds).toEqual(['race-2', 'race-3']); + }); + + it('exposes all drivers as a flat array', () => { + const races: RaceWithProtests[] = [createRaceWithProtests('race-1', 0, 0, 0)]; + + const driverMap = { + d1: { id: 'd1', name: 'Driver 1', avatarUrl: 'avatar-1.png' }, + d2: { id: 'd2', name: 'Driver 2', iracingId: 'ir-2' }, + }; + + const viewModel = new LeagueStewardingViewModel(races, driverMap); + + expect(viewModel.allDrivers).toHaveLength(2); + expect(viewModel.allDrivers).toEqual([ + driverMap.d1, + driverMap.d2, + ]); + }); + + it('handles an empty dataset gracefully', () => { + const viewModel = new LeagueStewardingViewModel([], {}); + + expect(viewModel.totalPending).toBe(0); + expect(viewModel.totalResolved).toBe(0); + expect(viewModel.totalPenalties).toBe(0); + expect(viewModel.pendingRaces).toEqual([]); + expect(viewModel.historyRaces).toEqual([]); + expect(viewModel.allDrivers).toEqual([]); + }); +}); diff --git a/apps/website/lib/view-models/LeagueSummaryViewModel.test.ts b/apps/website/lib/view-models/LeagueSummaryViewModel.test.ts new file mode 100644 index 000000000..3f418d365 --- /dev/null +++ b/apps/website/lib/view-models/LeagueSummaryViewModel.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import type { LeagueSummaryViewModel } from './LeagueSummaryViewModel'; + +describe('LeagueSummaryViewModel shape', () => { + const createSummary = (overrides: Partial = {}): LeagueSummaryViewModel => ({ + id: 'league-1', + name: 'Test League', + description: 'A test league', + ownerId: 'owner-1', + createdAt: '2025-01-01T00:00:00Z', + maxDrivers: 40, + usedDriverSlots: 10, + maxTeams: 10, + usedTeamSlots: 4, + structureSummary: 'Team-based sprint series', + scoringPatternSummary: 'Top 15 points, 1 drop', + timingSummary: 'Weekly, Wednesdays 20:00 UTC', + scoring: { + gameId: 'iracing', + gameName: 'iRacing', + primaryChampionshipType: 'driver', + scoringPresetId: 'preset-1', + scoringPresetName: 'Standard scoring', + dropPolicySummary: '1 drop race', + scoringPatternSummary: 'Top 15 receive points', + }, + ...overrides, + }); + + it('accepts basic league summary structure', () => { + const summary: LeagueSummaryViewModel = createSummary(); + + expect(summary.id).toBe('league-1'); + expect(summary.name).toBe('Test League'); + expect(summary.structureSummary).toBeDefined(); + expect(summary.timingSummary).toBeDefined(); + }); + + it('allows optional team and scoring fields to be omitted', () => { + const summary: LeagueSummaryViewModel = createSummary({ + maxTeams: undefined, + usedTeamSlots: undefined, + scoring: undefined, + }); + + expect(summary.maxTeams).toBeUndefined(); + expect(summary.usedTeamSlots).toBeUndefined(); + expect(summary.scoring).toBeUndefined(); + }); + + it('supports detailed scoring configuration when present', () => { + const summary: LeagueSummaryViewModel = createSummary(); + + expect(summary.scoring?.gameId).toBe('iracing'); + expect(summary.scoring?.primaryChampionshipType).toBe('driver'); + expect(summary.scoring?.dropPolicySummary).toContain('drop'); + expect(summary.scoring?.scoringPatternSummary).toContain('Top 15'); + }); +}); diff --git a/apps/website/lib/view-models/LeagueWalletViewModel.test.ts b/apps/website/lib/view-models/LeagueWalletViewModel.test.ts new file mode 100644 index 000000000..7b5c9cf51 --- /dev/null +++ b/apps/website/lib/view-models/LeagueWalletViewModel.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueWalletViewModel } from './LeagueWalletViewModel'; +import { WalletTransactionViewModel } from './WalletTransactionViewModel'; + +const createTransaction = (overrides: Partial = {}): WalletTransactionViewModel => + new WalletTransactionViewModel({ + id: 'tx-1', + type: 'sponsorship', + description: 'Test transaction', + amount: 100, + fee: 10, + netAmount: 90, + date: new Date('2024-01-01T00:00:00Z'), + status: 'completed', + reference: 'ref-1', + ...(overrides as any), + }); + +describe('LeagueWalletViewModel', () => { + it('maps core wallet fields from DTO', () => { + const transactions = [createTransaction()]; + + const vm = new LeagueWalletViewModel({ + balance: 250.5, + currency: 'USD', + totalRevenue: 1000, + totalFees: 50, + totalWithdrawals: 200, + pendingPayouts: 75, + transactions, + canWithdraw: true, + withdrawalBlockReason: undefined, + }); + + expect(vm.balance).toBe(250.5); + expect(vm.currency).toBe('USD'); + expect(vm.totalRevenue).toBe(1000); + expect(vm.totalFees).toBe(50); + expect(vm.totalWithdrawals).toBe(200); + expect(vm.pendingPayouts).toBe(75); + expect(vm.transactions).toBe(transactions); + expect(vm.canWithdraw).toBe(true); + expect(vm.withdrawalBlockReason).toBeUndefined(); + }); + + it('formats monetary fields as currency strings', () => { + const vm = new LeagueWalletViewModel({ + balance: 250.5, + currency: 'USD', + totalRevenue: 1234.56, + totalFees: 78.9, + totalWithdrawals: 0, + pendingPayouts: 42, + transactions: [], + canWithdraw: false, + }); + + expect(vm.formattedBalance).toBe('$250.50'); + expect(vm.formattedTotalRevenue).toBe('$1234.56'); + expect(vm.formattedTotalFees).toBe('$78.90'); + expect(vm.formattedPendingPayouts).toBe('$42.00'); + }); + + it('filters transactions by type and supports all', () => { + const sponsorshipTx = createTransaction({ type: 'sponsorship' as any }); + const membershipTx = createTransaction({ type: 'membership' as any, id: 'tx-2' }); + const withdrawalTx = createTransaction({ type: 'withdrawal' as any, id: 'tx-3' }); + const prizeTx = createTransaction({ type: 'prize' as any, id: 'tx-4' }); + + const vm = new LeagueWalletViewModel({ + balance: 0, + currency: 'USD', + totalRevenue: 0, + totalFees: 0, + totalWithdrawals: 0, + pendingPayouts: 0, + transactions: [sponsorshipTx, membershipTx, withdrawalTx, prizeTx], + canWithdraw: false, + }); + + expect(vm.getFilteredTransactions('all')).toHaveLength(4); + expect(vm.getFilteredTransactions('sponsorship')).toEqual([sponsorshipTx]); + expect(vm.getFilteredTransactions('membership')).toEqual([membershipTx]); + expect(vm.getFilteredTransactions('withdrawal')).toEqual([withdrawalTx]); + expect(vm.getFilteredTransactions('prize')).toEqual([prizeTx]); + }); +}); diff --git a/apps/website/lib/view-models/MediaViewModel.test.ts b/apps/website/lib/view-models/MediaViewModel.test.ts index e76c55300..bdf64eee2 100644 --- a/apps/website/lib/view-models/MediaViewModel.test.ts +++ b/apps/website/lib/view-models/MediaViewModel.test.ts @@ -65,10 +65,10 @@ describe('MediaViewModel', () => { url: 'https://example.com/image.jpg', type: 'image', uploadedAt: new Date().toISOString(), - size: 2048000, // 2 MB + size: 2048000, // 2 MB in base-10, ~1.95 MB in base-2 }); - - expect(viewModel.formattedSize).toBe('2.00 MB'); + + expect(viewModel.formattedSize).toBe('1.95 MB'); }); it('should handle very small file sizes', () => { diff --git a/apps/website/lib/view-models/MembershipFeeViewModel.test.ts b/apps/website/lib/view-models/MembershipFeeViewModel.test.ts new file mode 100644 index 000000000..7b1f75e8b --- /dev/null +++ b/apps/website/lib/view-models/MembershipFeeViewModel.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { MembershipFeeViewModel } from './MembershipFeeViewModel'; +import type { MembershipFeeDto } from '../types/generated'; + +const createMembershipFeeDto = (overrides: Partial = {}): MembershipFeeDto => ({ + id: 'fee-1', + leagueId: 'league-1', + seasonId: 'season-1', + type: 'season', + amount: 49.99, + enabled: true, + createdAt: new Date('2024-01-01T10:00:00Z'), + updatedAt: new Date('2024-01-02T10:00:00Z'), + ...overrides, +}); + +describe('MembershipFeeViewModel', () => { + it('maps fields from DTO', () => { + const dto = createMembershipFeeDto({ id: 'custom-fee', amount: 19.5 }); + + const vm = new MembershipFeeViewModel(dto); + + expect(vm.id).toBe('custom-fee'); + expect(vm.leagueId).toBe('league-1'); + expect(vm.seasonId).toBe('season-1'); + expect(vm.amount).toBe(19.5); + expect(vm.enabled).toBe(true); + }); + + it('formats amount with euro currency and two decimals', () => { + const dto = createMembershipFeeDto({ amount: 10 }); + const vm = new MembershipFeeViewModel(dto); + + expect(vm.formattedAmount).toBe('€10.00'); + }); + + it('maps type to human-readable display', () => { + const seasonVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'season' })); + const monthlyVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'monthly' })); + const perRaceVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'per_race' })); + const otherVm = new MembershipFeeViewModel(createMembershipFeeDto({ type: 'custom' as any })); + + expect(seasonVm.typeDisplay).toBe('Per Season'); + expect(monthlyVm.typeDisplay).toBe('Monthly'); + expect(perRaceVm.typeDisplay).toBe('Per Race'); + expect(otherVm.typeDisplay).toBe('custom'); + }); + + it('derives statusDisplay and statusColor from enabled flag', () => { + const enabledVm = new MembershipFeeViewModel(createMembershipFeeDto({ enabled: true })); + const disabledVm = new MembershipFeeViewModel(createMembershipFeeDto({ enabled: false })); + + expect(enabledVm.statusDisplay).toBe('Enabled'); + expect(enabledVm.statusColor).toBe('green'); + + expect(disabledVm.statusDisplay).toBe('Disabled'); + expect(disabledVm.statusColor).toBe('red'); + }); + + it('formats createdAt and updatedAt as localized strings', () => { + const dto = createMembershipFeeDto({ + createdAt: new Date('2024-01-01T10:00:00Z'), + updatedAt: new Date('2024-01-02T10:00:00Z'), + }); + + const vm = new MembershipFeeViewModel(dto); + + expect(typeof vm.formattedCreatedAt).toBe('string'); + expect(vm.formattedCreatedAt.length).toBeGreaterThan(0); + + expect(typeof vm.formattedUpdatedAt).toBe('string'); + expect(vm.formattedUpdatedAt.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/website/lib/view-models/PaymentViewModel.test.ts b/apps/website/lib/view-models/PaymentViewModel.test.ts new file mode 100644 index 000000000..0cdc62da5 --- /dev/null +++ b/apps/website/lib/view-models/PaymentViewModel.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { PaymentViewModel } from './PaymentViewModel'; + +const createPaymentDto = (overrides: Partial = {}): any => ({ + id: 'pay-1', + type: 'membership_fee', + amount: 100.5, + platformFee: 10.5, + netAmount: 90, + payerId: 'sponsor-1', + payerType: 'sponsor', + leagueId: 'league-1', + seasonId: 'season-1', + status: 'completed', + createdAt: new Date('2024-01-01T10:00:00Z'), + completedAt: new Date('2024-01-01T11:00:00Z'), + ...overrides, +}); + +describe('PaymentViewModel', () => { + it('maps DTO fields via Object.assign', () => { + const dto = createPaymentDto(); + + const vm = new PaymentViewModel(dto); + + expect(vm.id).toBe('pay-1'); + expect(vm.amount).toBe(100.5); + expect(vm.netAmount).toBe(90); + expect(vm.payerId).toBe('sponsor-1'); + expect(vm.leagueId).toBe('league-1'); + }); + + it('formats amount and netAmount as EUR currency strings', () => { + const vm = new PaymentViewModel(createPaymentDto({ amount: 50, netAmount: 40 })); + + expect(vm.formattedAmount).toBe('€50.00'); + expect(vm.formattedNetAmount).toBe('€40.00'); + }); + + it('maps status to a statusColor', () => { + const completed = new PaymentViewModel(createPaymentDto({ status: 'completed' })); + const pending = new PaymentViewModel(createPaymentDto({ status: 'pending' })); + const failed = new PaymentViewModel(createPaymentDto({ status: 'failed' })); + const refunded = new PaymentViewModel(createPaymentDto({ status: 'refunded' })); + const other = new PaymentViewModel(createPaymentDto({ status: 'unknown' })); + + expect(completed.statusColor).toBe('green'); + expect(pending.statusColor).toBe('yellow'); + expect(failed.statusColor).toBe('red'); + expect(refunded.statusColor).toBe('orange'); + expect(other.statusColor).toBe('gray'); + }); + + it('formats createdAt and completedAt using locale formatting', () => { + const vm = new PaymentViewModel(createPaymentDto()); + + expect(typeof vm.formattedCreatedAt).toBe('string'); + expect(typeof vm.formattedCompletedAt).toBe('string'); + }); + + it('handles missing completedAt with fallback text', () => { + const vm = new PaymentViewModel(createPaymentDto({ completedAt: undefined })); + + expect(vm.formattedCompletedAt).toBe('Not completed'); + }); + + it('derives display labels for status, type and payer type', () => { + const vm = new PaymentViewModel(createPaymentDto({ status: 'pending', type: 'membership_fee', payerType: 'league' })); + + expect(vm.statusDisplay).toBe('Pending'); + expect(vm.typeDisplay).toBe('Membership Fee'); + expect(vm.payerTypeDisplay).toBe('League'); + }); +}); diff --git a/apps/website/lib/view-models/PrizeViewModel.test.ts b/apps/website/lib/view-models/PrizeViewModel.test.ts new file mode 100644 index 000000000..f1fb785db --- /dev/null +++ b/apps/website/lib/view-models/PrizeViewModel.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { PrizeViewModel } from './PrizeViewModel'; + +const createPrizeDto = (overrides: Partial = {}): any => ({ + id: 'prize-1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Main Prize', + amount: 100, + type: 'cash', + description: 'Top prize', + awarded: false, + awardedTo: undefined, + awardedAt: undefined, + createdAt: new Date('2024-01-01T00:00:00Z'), + ...overrides, +}); + +describe('PrizeViewModel', () => { + it('maps DTO fields via Object.assign', () => { + const dto = createPrizeDto(); + + const vm = new PrizeViewModel(dto); + + expect(vm.id).toBe('prize-1'); + expect(vm.leagueId).toBe('league-1'); + expect(vm.position).toBe(1); + expect(vm.amount).toBe(100); + }); + + it('formats amount as EUR currency string', () => { + const vm = new PrizeViewModel(createPrizeDto({ amount: 50.25 })); + + expect(vm.formattedAmount).toBe('€50.25'); + }); + + it('formats positionDisplay based on position', () => { + const first = new PrizeViewModel(createPrizeDto({ position: 1 })); + const second = new PrizeViewModel(createPrizeDto({ position: 2 })); + const third = new PrizeViewModel(createPrizeDto({ position: 3 })); + const other = new PrizeViewModel(createPrizeDto({ position: 4 })); + + expect(first.positionDisplay).toBe('1st Place'); + expect(second.positionDisplay).toBe('2nd Place'); + expect(third.positionDisplay).toBe('3rd Place'); + expect(other.positionDisplay).toBe('4th Place'); + }); + + it('maps type to human readable typeDisplay', () => { + const cash = new PrizeViewModel(createPrizeDto({ type: 'cash' })); + const merchandise = new PrizeViewModel(createPrizeDto({ type: 'merchandise' })); + const other = new PrizeViewModel(createPrizeDto({ type: 'other' })); + const unknown = new PrizeViewModel(createPrizeDto({ type: 'custom' })); + + expect(cash.typeDisplay).toBe('Cash Prize'); + expect(merchandise.typeDisplay).toBe('Merchandise'); + expect(other.typeDisplay).toBe('Other'); + expect(unknown.typeDisplay).toBe('custom'); + }); + + it('derives statusDisplay and statusColor from awarded flag', () => { + const available = new PrizeViewModel(createPrizeDto({ awarded: false })); + const awarded = new PrizeViewModel(createPrizeDto({ awarded: true })); + + expect(available.statusDisplay).toBe('Available'); + expect(available.statusColor).toBe('blue'); + expect(awarded.statusDisplay).toBe('Awarded'); + expect(awarded.statusColor).toBe('green'); + }); + + it('builds prizeDescription from name and amount', () => { + const vm = new PrizeViewModel(createPrizeDto({ name: 'Bonus', amount: 25 })); + + expect(vm.prizeDescription).toBe('Bonus - €25.00'); + }); + + it('formats awardedAt and createdAt timestamps', () => { + const awardedAt = new Date('2024-01-02T00:00:00Z'); + const vm = new PrizeViewModel(createPrizeDto({ awarded: true, awardedAt })); + + expect(typeof vm.formattedAwardedAt).toBe('string'); + expect(typeof vm.formattedCreatedAt).toBe('string'); + + const notAwarded = new PrizeViewModel(createPrizeDto({ awarded: false, awardedAt: undefined })); + expect(notAwarded.formattedAwardedAt).toBe('Not awarded'); + }); +}); diff --git a/apps/website/lib/view-models/ProtestDriverViewModel.test.ts b/apps/website/lib/view-models/ProtestDriverViewModel.test.ts new file mode 100644 index 000000000..99b2a1947 --- /dev/null +++ b/apps/website/lib/view-models/ProtestDriverViewModel.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { ProtestDriverViewModel } from './ProtestDriverViewModel'; +import type { DriverSummaryDTO } from '../types/generated/LeagueAdminProtestsDTO'; + +const createDriverSummary = (overrides: Partial = {}): DriverSummaryDTO => ({ + id: 'driver-1', + name: 'Test Driver', + ...overrides, +}); + +describe('ProtestDriverViewModel', () => { + it('maps id and name from DriverSummaryDTO', () => { + const dto = createDriverSummary({ + id: 'driver-123', + name: 'Jane Doe', + }); + + const viewModel = new ProtestDriverViewModel(dto); + + expect(viewModel.id).toBe('driver-123'); + expect(viewModel.name).toBe('Jane Doe'); + }); + + it('exposes underlying DTO fields as-is', () => { + const dto = createDriverSummary(); + + const viewModel = new ProtestDriverViewModel(dto); + + expect(viewModel.id).toBe(dto.id); + expect(viewModel.name).toBe(dto.name); + }); +}); diff --git a/apps/website/lib/view-models/ProtestViewModel.test.ts b/apps/website/lib/view-models/ProtestViewModel.test.ts index 16edce038..4a53c72ee 100644 --- a/apps/website/lib/view-models/ProtestViewModel.test.ts +++ b/apps/website/lib/view-models/ProtestViewModel.test.ts @@ -2,113 +2,52 @@ import { describe, it, expect } from 'vitest'; import { ProtestViewModel } from './ProtestViewModel'; import type { ProtestDTO } from '../types/generated/ProtestDTO'; +const createProtestDto = (overrides: Partial = {}): ProtestDTO => ({ + id: 'protest-123', + raceId: 'race-456', + protestingDriverId: 'driver-111', + accusedDriverId: 'driver-222', + description: 'Unsafe driving in turn 3', + submittedAt: '2023-01-15T10:30:00Z', + ...overrides, +}); + describe('ProtestViewModel', () => { - it('should create instance with all properties', () => { - const dto: ProtestDTO = { - id: 'protest-123', - raceId: 'race-456', - complainantId: 'driver-111', - defendantId: 'driver-222', - description: 'Unsafe driving in turn 3', - status: 'pending', - createdAt: '2023-01-15T10:30:00Z', - }; + it('maps core fields from ProtestDTO', () => { + const dto: ProtestDTO = createProtestDto(); const viewModel = new ProtestViewModel(dto); expect(viewModel.id).toBe('protest-123'); expect(viewModel.raceId).toBe('race-456'); - expect(viewModel.complainantId).toBe('driver-111'); - expect(viewModel.defendantId).toBe('driver-222'); + expect(viewModel.protestingDriverId).toBe('driver-111'); + expect(viewModel.accusedDriverId).toBe('driver-222'); expect(viewModel.description).toBe('Unsafe driving in turn 3'); + expect(viewModel.submittedAt).toBe('2023-01-15T10:30:00Z'); + }); + + it('defaults status and review fields for new protests', () => { + const viewModel = new ProtestViewModel(createProtestDto()); + expect(viewModel.status).toBe('pending'); - expect(viewModel.createdAt).toBe('2023-01-15T10:30:00Z'); + expect(viewModel.reviewedAt).toBeUndefined(); + expect(viewModel.decisionNotes).toBeUndefined(); }); - it('should format createdAt as locale string', () => { - const dto: ProtestDTO = { - id: 'protest-123', - raceId: 'race-456', - complainantId: 'driver-111', - defendantId: 'driver-222', - description: 'Test', - status: 'pending', - createdAt: '2023-01-15T10:30:00Z', - }; + it('formats submittedAt as a locale string', () => { + const viewModel = new ProtestViewModel(createProtestDto({ + submittedAt: '2023-01-15T10:30:00Z', + })); - const viewModel = new ProtestViewModel(dto); - const formatted = viewModel.formattedCreatedAt; + const formatted = viewModel.formattedSubmittedAt; - expect(formatted).toContain('2023'); - expect(formatted).toContain('1/15'); + expect(typeof formatted).toBe('string'); + expect(formatted.length).toBeGreaterThan(0); }); - it('should capitalize status for display', () => { - const statuses = ['pending', 'approved', 'rejected', 'reviewing']; - - statuses.forEach(status => { - const dto: ProtestDTO = { - id: 'protest-123', - raceId: 'race-456', - complainantId: 'driver-111', - defendantId: 'driver-222', - description: 'Test', - status, - createdAt: '2023-01-15T10:30:00Z', - }; - - const viewModel = new ProtestViewModel(dto); - const expected = status.charAt(0).toUpperCase() + status.slice(1); - - expect(viewModel.statusDisplay).toBe(expected); - }); - }); - - it('should handle already capitalized status', () => { - const dto: ProtestDTO = { - id: 'protest-123', - raceId: 'race-456', - complainantId: 'driver-111', - defendantId: 'driver-222', - description: 'Test', - status: 'Pending', - createdAt: '2023-01-15T10:30:00Z', - }; - - const viewModel = new ProtestViewModel(dto); + it('exposes a fixed status display label for pending protests', () => { + const viewModel = new ProtestViewModel(createProtestDto()); expect(viewModel.statusDisplay).toBe('Pending'); }); - - it('should handle single character status', () => { - const dto: ProtestDTO = { - id: 'protest-123', - raceId: 'race-456', - complainantId: 'driver-111', - defendantId: 'driver-222', - description: 'Test', - status: 'p', - createdAt: '2023-01-15T10:30:00Z', - }; - - const viewModel = new ProtestViewModel(dto); - - expect(viewModel.statusDisplay).toBe('P'); - }); - - it('should handle empty status', () => { - const dto: ProtestDTO = { - id: 'protest-123', - raceId: 'race-456', - complainantId: 'driver-111', - defendantId: 'driver-222', - description: 'Test', - status: '', - createdAt: '2023-01-15T10:30:00Z', - }; - - const viewModel = new ProtestViewModel(dto); - - expect(viewModel.statusDisplay).toBe(''); - }); }); \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceResultsDetailViewModel.test.ts b/apps/website/lib/view-models/RaceResultsDetailViewModel.test.ts new file mode 100644 index 000000000..f02c08e0e --- /dev/null +++ b/apps/website/lib/view-models/RaceResultsDetailViewModel.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { RaceResultsDetailViewModel } from './RaceResultsDetailViewModel'; +import { RaceResultViewModel } from './RaceResultViewModel'; +import type { RaceResultsDetailDTO } from '../types/generated/RaceResultsDetailDTO'; +import type { RaceResultDTO } from '../types/generated/RaceResultDTO'; + +const createResult = (overrides: Partial = {}): RaceResultDTO => ({ + driverId: 'driver-1', + driverName: 'Driver One', + avatarUrl: 'https://example.com/avatar.jpg', + position: 2, + startPosition: 3, + incidents: 1, + fastestLap: 90.5, + positionChange: 1, + isPodium: true, + isClean: true, + ...overrides, +}); + +const createDto = (overrides: Partial = {}): RaceResultsDetailDTO & { results?: RaceResultDTO[] } => ({ + raceId: 'race-1', + track: 'Spa', + ...overrides, +}); + +describe('RaceResultsDetailViewModel', () => { + it('maps basic fields and wraps results into view models', () => { + const dto = createDto({ + results: [createResult({ driverId: 'driver-1' }), createResult({ driverId: 'driver-2' })], + }); + + const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1'); + + expect(viewModel.raceId).toBe('race-1'); + expect(viewModel.track).toBe('Spa'); + expect(viewModel.results).toHaveLength(2); + expect(viewModel.results[0]).toBeInstanceOf(RaceResultViewModel); + }); + + it('sorts results by position', () => { + const dto = createDto({ + results: [ + createResult({ driverId: 'driver-1', position: 3 }), + createResult({ driverId: 'driver-2', position: 1 }), + createResult({ driverId: 'driver-3', position: 2 }), + ], + }); + + const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1'); + + const byPosition = viewModel.resultsByPosition; + + expect(byPosition.map(r => r.position)).toEqual([1, 2, 3]); + }); + + it('sorts results by fastest lap', () => { + const dto = createDto({ + results: [ + createResult({ driverId: 'driver-1', fastestLap: 95 }), + createResult({ driverId: 'driver-2', fastestLap: 90 }), + createResult({ driverId: 'driver-3', fastestLap: 100 }), + ], + }); + + const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1'); + + const byLap = viewModel.resultsByFastestLap; + + expect(byLap.map(r => r.fastestLap)).toEqual([90, 95, 100]); + }); + + it('filters clean drivers', () => { + const dto = createDto({ + results: [ + createResult({ driverId: 'driver-1', isClean: true }), + createResult({ driverId: 'driver-2', isClean: false }), + ], + }); + + const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1'); + + const clean = viewModel.cleanDrivers; + + expect(clean).toHaveLength(1); + expect(clean[0].driverId).toBe('driver-1'); + }); + + it('finds current user result by driver id', () => { + const dto = createDto({ + results: [ + createResult({ driverId: 'driver-1' }), + createResult({ driverId: 'driver-2' }), + ], + }); + + const viewModel = new RaceResultsDetailViewModel(dto, 'driver-2'); + + expect(viewModel.currentUserResult?.driverId).toBe('driver-2'); + }); + + it('computes stats for total drivers, clean rate and average incidents', () => { + const dto = createDto({ + results: [ + createResult({ driverId: 'driver-1', isClean: true, incidents: 0 }), + createResult({ driverId: 'driver-2', isClean: false, incidents: 4 }), + ], + }); + + const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1'); + + const stats = viewModel.stats; + + expect(stats.totalDrivers).toBe(2); + expect(stats.cleanRate).toBeCloseTo(50); + expect(stats.averageIncidents).toBeCloseTo(2); + }); + + it('returns zeroed stats when there are no results', () => { + const dto = createDto({ results: [] }); + + const viewModel = new RaceResultsDetailViewModel(dto, 'driver-1'); + + const stats = viewModel.stats; + + expect(stats.totalDrivers).toBe(0); + expect(stats.cleanRate).toBe(0); + expect(stats.averageIncidents).toBe(0); + }); +}); diff --git a/apps/website/lib/view-models/RaceStatsViewModel.test.ts b/apps/website/lib/view-models/RaceStatsViewModel.test.ts new file mode 100644 index 000000000..e8edeffc5 --- /dev/null +++ b/apps/website/lib/view-models/RaceStatsViewModel.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { RaceStatsViewModel } from './RaceStatsViewModel'; +import type { RaceStatsDTO } from '../types/generated'; + +const createDto = (overrides: Partial = {}): RaceStatsDTO => ({ + totalRaces: 1234, + ...overrides, +}); + +describe('RaceStatsViewModel', () => { + it('maps totalRaces from DTO', () => { + const dto = createDto({ totalRaces: 42 }); + + const viewModel = new RaceStatsViewModel(dto); + + expect(viewModel.totalRaces).toBe(42); + }); + + it('formats totalRaces with locale separators', () => { + const dto = createDto({ totalRaces: 12345 }); + + const viewModel = new RaceStatsViewModel(dto); + + const formatted = viewModel.formattedTotalRaces; + + expect(formatted).toBe((12345).toLocaleString()); + }); +}); diff --git a/apps/website/lib/view-models/RaceStewardingViewModel.test.ts b/apps/website/lib/view-models/RaceStewardingViewModel.test.ts new file mode 100644 index 000000000..5ef73ea2e --- /dev/null +++ b/apps/website/lib/view-models/RaceStewardingViewModel.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { RaceStewardingViewModel } from './RaceStewardingViewModel'; + +const createRaceStewardingDto = () => ({ + raceDetail: { + race: { + id: 'race-1', + track: 'Spa-Francorchamps', + scheduledAt: '2024-01-01T20:00:00Z', + status: 'completed', + }, + league: { + id: 'league-1', + name: 'Test League', + }, + }, + protests: { + protests: [ + { + id: 'p1', + protestingDriverId: 'd1', + accusedDriverId: 'd2', + incident: { lap: 1, description: 'Turn 1 divebomb' }, + filedAt: '2024-01-01T21:00:00Z', + status: 'pending', + }, + { + id: 'p2', + protestingDriverId: 'd3', + accusedDriverId: 'd4', + incident: { lap: 5, description: 'Blocking' }, + filedAt: '2024-01-01T21:05:00Z', + status: 'under_review', + }, + { + id: 'p3', + protestingDriverId: 'd5', + accusedDriverId: 'd6', + incident: { lap: 10, description: 'Contact' }, + filedAt: '2024-01-01T21:10:00Z', + status: 'upheld', + }, + { + id: 'p4', + protestingDriverId: 'd7', + accusedDriverId: 'd8', + incident: { lap: 12, description: 'Off-track overtake' }, + filedAt: '2024-01-01T21:15:00Z', + status: 'dismissed', + }, + { + id: 'p5', + protestingDriverId: 'd9', + accusedDriverId: 'd10', + incident: { lap: 15, description: 'Withdrawn protest' }, + filedAt: '2024-01-01T21:20:00Z', + status: 'withdrawn', + }, + ], + driverMap: { + d1: { id: 'd1', name: 'Driver 1' }, + d2: { id: 'd2', name: 'Driver 2' }, + }, + }, + penalties: { + penalties: [ + { + id: 'pen1', + driverId: 'd2', + type: 'time', + value: 5, + reason: 'Avoidable contact', + }, + { + id: 'pen2', + driverId: 'd3', + type: 'points', + value: 2, + reason: 'Reckless driving', + }, + ], + driverMap: { + d3: { id: 'd3', name: 'Driver 3' }, + d4: { id: 'd4', name: 'Driver 4' }, + }, + }, +}); + +describe('RaceStewardingViewModel', () => { + it('maps core race, league, protests, penalties and driver map', () => { + const dto = createRaceStewardingDto(); + + const viewModel = new RaceStewardingViewModel(dto as any); + + expect(viewModel.race).toEqual(dto.raceDetail.race); + expect(viewModel.league).toEqual(dto.raceDetail.league); + expect(viewModel.protests).toEqual(dto.protests.protests); + expect(viewModel.penalties).toEqual(dto.penalties.penalties); + expect(viewModel.driverMap).toEqual({ + ...dto.protests.driverMap, + ...dto.penalties.driverMap, + }); + }); + + it('derives pending and resolved protest buckets and counts', () => { + const dto = createRaceStewardingDto(); + + const viewModel = new RaceStewardingViewModel(dto as any); + + const pendingIds = viewModel.pendingProtests.map(p => p.id); + const resolvedIds = viewModel.resolvedProtests.map(p => p.id); + + expect(pendingIds.sort()).toEqual(['p1', 'p2']); + expect(resolvedIds.sort()).toEqual(['p3', 'p4', 'p5']); + expect(viewModel.pendingCount).toBe(2); + expect(viewModel.resolvedCount).toBe(3); + }); + + it('derives penalties count from penalties list', () => { + const dto = createRaceStewardingDto(); + + const viewModel = new RaceStewardingViewModel(dto as any); + + expect(viewModel.penaltiesCount).toBe(2); + }); + + it('handles empty protests and penalties gracefully', () => { + const dto = createRaceStewardingDto(); + dto.protests.protests = []; + dto.penalties.penalties = []; + + const viewModel = new RaceStewardingViewModel(dto as any); + + expect(viewModel.pendingProtests).toEqual([]); + expect(viewModel.resolvedProtests).toEqual([]); + expect(viewModel.pendingCount).toBe(0); + expect(viewModel.resolvedCount).toBe(0); + expect(viewModel.penaltiesCount).toBe(0); + }); +}); diff --git a/apps/website/lib/view-models/RaceViewModel.test.ts b/apps/website/lib/view-models/RaceViewModel.test.ts new file mode 100644 index 000000000..de4dd5b59 --- /dev/null +++ b/apps/website/lib/view-models/RaceViewModel.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { RaceViewModel } from './RaceViewModel'; +import type { RaceDTO } from '../types/generated/RaceDTO'; + +const createRaceDto = (overrides: Partial = {}): RaceDTO => ({ + id: 'race-1', + name: 'Season Opener', + date: '2025-01-01T20:00:00Z', + ...overrides, +}); + +describe('RaceViewModel', () => { + it('maps basic DTO fields', () => { + const dto = createRaceDto({ id: 'race-123', name: 'Test Race', date: '2025-02-01T18:30:00Z' }); + + const viewModel = new RaceViewModel(dto); + + expect(viewModel.id).toBe('race-123'); + expect(viewModel.name).toBe('Test Race'); + expect(viewModel.date).toBe('2025-02-01T18:30:00Z'); + }); + + it('exposes optional status, registeredCount and strengthOfField from constructor arguments', () => { + const dto = createRaceDto(); + + const viewModel = new RaceViewModel(dto, 'upcoming', 25, 3000); + + expect(viewModel.status).toBe('upcoming'); + expect(viewModel.registeredCount).toBe(25); + expect(viewModel.strengthOfField).toBe(3000); + }); + + it('formats date to locale string', () => { + const dto = createRaceDto({ date: '2025-01-01T20:00:00Z' }); + + const viewModel = new RaceViewModel(dto); + + const formatted = viewModel.formattedDate; + + expect(formatted).toContain('2025'); + }); +}); diff --git a/apps/website/lib/view-models/RaceWithSOFViewModel.test.ts b/apps/website/lib/view-models/RaceWithSOFViewModel.test.ts new file mode 100644 index 000000000..12699bb6c --- /dev/null +++ b/apps/website/lib/view-models/RaceWithSOFViewModel.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { RaceWithSOFViewModel } from './RaceWithSOFViewModel'; +import type { RaceWithSOFDTO } from '../types/generated/RaceWithSOFDTO'; + +const createDto = (overrides: Partial = {}): RaceWithSOFDTO => ({ + id: 'race-sof-1', + track: 'Spa', + ...overrides, +}); + +describe('RaceWithSOFViewModel', () => { + it('maps DTO fields', () => { + const dto = createDto({ id: 'race-sof-123', track: 'Nürburgring' }); + + const viewModel = new RaceWithSOFViewModel(dto); + + expect(viewModel.id).toBe('race-sof-123'); + expect(viewModel.track).toBe('Nürburgring'); + }); +}); diff --git a/apps/website/lib/view-models/RacesPageViewModel.test.ts b/apps/website/lib/view-models/RacesPageViewModel.test.ts index 523e6d2dc..bb8115dbe 100644 --- a/apps/website/lib/view-models/RacesPageViewModel.test.ts +++ b/apps/website/lib/view-models/RacesPageViewModel.test.ts @@ -1,212 +1,132 @@ import { describe, it, expect } from 'vitest'; -import { RaceCardViewModel, RacesPageViewModel } from './RacesPageViewModel'; +import { RacesPageViewModel } from './RacesPageViewModel'; +import { RaceListItemViewModel } from './RaceListItemViewModel'; -describe('RaceCardViewModel', () => { - it('should create instance with all properties', () => { - const dto = { - id: 'race-123', - title: 'Season Finale', - scheduledTime: '2023-12-31T20:00:00Z', - status: 'upcoming', - }; +describe('RaceListItemViewModel', () => { + const baseDto = { + id: 'race-123', + track: 'Spa-Francorchamps', + car: 'GT3', + scheduledAt: '2025-01-01T20:00:00Z', + status: 'scheduled', + leagueId: 'league-1', + leagueName: 'Test League', + strengthOfField: 2500, + isUpcoming: true, + isLive: false, + isPast: false, + }; - const viewModel = new RaceCardViewModel(dto); + it('maps DTO fields to properties', () => { + const viewModel = new RaceListItemViewModel(baseDto); expect(viewModel.id).toBe('race-123'); - expect(viewModel.title).toBe('Season Finale'); - expect(viewModel.scheduledTime).toBe('2023-12-31T20:00:00Z'); - expect(viewModel.status).toBe('upcoming'); + expect(viewModel.track).toBe('Spa-Francorchamps'); + expect(viewModel.car).toBe('GT3'); + expect(viewModel.scheduledAt).toBe('2025-01-01T20:00:00Z'); + expect(viewModel.status).toBe('scheduled'); + expect(viewModel.leagueId).toBe('league-1'); + expect(viewModel.leagueName).toBe('Test League'); + expect(viewModel.strengthOfField).toBe(2500); + expect(viewModel.isUpcoming).toBe(true); + expect(viewModel.isLive).toBe(false); + expect(viewModel.isPast).toBe(false); }); - it('should format scheduled time as locale string', () => { - const dto = { - id: 'race-123', - title: 'Test Race', - scheduledTime: '2023-12-31T20:00:00Z', - status: 'upcoming', - }; + it('formats scheduled time as locale string', () => { + const viewModel = new RaceListItemViewModel(baseDto); - const viewModel = new RaceCardViewModel(dto); const formatted = viewModel.formattedScheduledTime; - expect(formatted).toContain('2023'); - expect(formatted).toContain('12/31'); + expect(formatted).toContain('2025'); }); - it('should handle different race statuses', () => { - const statuses = ['upcoming', 'live', 'finished', 'cancelled']; + it('computes status badge variants', () => { + const scheduled = new RaceListItemViewModel({ ...baseDto, status: 'scheduled' }); + const running = new RaceListItemViewModel({ ...baseDto, status: 'running' }); + const completed = new RaceListItemViewModel({ ...baseDto, status: 'completed' }); + const cancelled = new RaceListItemViewModel({ ...baseDto, status: 'cancelled' }); + const other = new RaceListItemViewModel({ ...baseDto, status: 'unknown' }); - statuses.forEach(status => { - const dto = { - id: 'race-123', - title: 'Test Race', - scheduledTime: '2023-12-31T20:00:00Z', - status, - }; - - const viewModel = new RaceCardViewModel(dto); - - expect(viewModel.status).toBe(status); - }); + expect(scheduled.statusBadgeVariant).toBe('info'); + expect(running.statusBadgeVariant).toBe('success'); + expect(completed.statusBadgeVariant).toBe('secondary'); + expect(cancelled.statusBadgeVariant).toBe('danger'); + expect(other.statusBadgeVariant).toBe('default'); }); }); describe('RacesPageViewModel', () => { - it('should create instance with upcoming and completed races', () => { - const dto = { - upcomingRaces: [ - { - id: 'race-1', - title: 'Race 1', - scheduledTime: '2023-12-31T20:00:00Z', - status: 'upcoming', - }, - { - id: 'race-2', - title: 'Race 2', - scheduledTime: '2024-01-01T20:00:00Z', - status: 'upcoming', - }, - ], - completedRaces: [ - { - id: 'race-3', - title: 'Race 3', - scheduledTime: '2023-12-20T20:00:00Z', - status: 'finished', - }, - ], - totalCount: 3, - }; + const createDto = () => ({ + races: [ + { + id: 'race-1', + track: 'Spa', + car: 'GT3', + scheduledAt: '2025-01-01T20:00:00Z', + status: 'scheduled', + leagueId: 'league-1', + leagueName: 'League 1', + strengthOfField: 2500, + isUpcoming: true, + isLive: false, + isPast: false, + }, + { + id: 'race-2', + track: 'Nürburgring', + car: 'GT4', + scheduledAt: '2025-01-02T20:00:00Z', + status: 'running', + leagueId: 'league-2', + leagueName: 'League 2', + strengthOfField: null, + isUpcoming: true, + isLive: true, + isPast: false, + }, + { + id: 'race-3', + track: 'Daytona', + car: 'LMP2', + scheduledAt: '2024-12-31T20:00:00Z', + status: 'completed', + leagueId: 'league-3', + leagueName: 'League 3', + strengthOfField: 2000, + isUpcoming: false, + isLive: false, + isPast: true, + }, + ], + }); - const viewModel = new RacesPageViewModel(dto); + it('wraps races DTOs in RaceListItemViewModel instances', () => { + const viewModel = new RacesPageViewModel(createDto()); + + expect(viewModel.races).toHaveLength(3); + expect(viewModel.races[0]).toBeInstanceOf(RaceListItemViewModel); + }); + + it('computes total count from races length', () => { + const viewModel = new RacesPageViewModel(createDto()); + + expect(viewModel.totalCount).toBe(3); + }); + + it('filters upcoming, live and past races', () => { + const viewModel = new RacesPageViewModel(createDto()); expect(viewModel.upcomingRaces).toHaveLength(2); + expect(viewModel.liveRaces).toHaveLength(1); + expect(viewModel.pastRaces).toHaveLength(1); + }); + + it('filters scheduled, running and completed races by status', () => { + const viewModel = new RacesPageViewModel(createDto()); + + expect(viewModel.scheduledRaces).toHaveLength(1); + expect(viewModel.runningRaces).toHaveLength(1); expect(viewModel.completedRaces).toHaveLength(1); - expect(viewModel.totalCount).toBe(3); - }); - - it('should convert DTOs to view models', () => { - const dto = { - upcomingRaces: [ - { - id: 'race-1', - title: 'Race 1', - scheduledTime: '2023-12-31T20:00:00Z', - status: 'upcoming', - }, - ], - completedRaces: [], - totalCount: 1, - }; - - const viewModel = new RacesPageViewModel(dto); - - expect(viewModel.upcomingRaces[0]).toBeInstanceOf(RaceCardViewModel); - expect(viewModel.upcomingRaces[0].id).toBe('race-1'); - }); - - it('should return correct upcoming count', () => { - const dto = { - upcomingRaces: [ - { - id: 'race-1', - title: 'Race 1', - scheduledTime: '2023-12-31T20:00:00Z', - status: 'upcoming', - }, - { - id: 'race-2', - title: 'Race 2', - scheduledTime: '2024-01-01T20:00:00Z', - status: 'upcoming', - }, - { - id: 'race-3', - title: 'Race 3', - scheduledTime: '2024-01-02T20:00:00Z', - status: 'upcoming', - }, - ], - completedRaces: [], - totalCount: 3, - }; - - const viewModel = new RacesPageViewModel(dto); - - expect(viewModel.upcomingCount).toBe(3); - }); - - it('should return correct completed count', () => { - const dto = { - upcomingRaces: [], - completedRaces: [ - { - id: 'race-1', - title: 'Race 1', - scheduledTime: '2023-12-20T20:00:00Z', - status: 'finished', - }, - { - id: 'race-2', - title: 'Race 2', - scheduledTime: '2023-12-21T20:00:00Z', - status: 'finished', - }, - ], - totalCount: 2, - }; - - const viewModel = new RacesPageViewModel(dto); - - expect(viewModel.completedCount).toBe(2); - }); - - it('should handle empty race lists', () => { - const dto = { - upcomingRaces: [], - completedRaces: [], - totalCount: 0, - }; - - const viewModel = new RacesPageViewModel(dto); - - expect(viewModel.upcomingCount).toBe(0); - expect(viewModel.completedCount).toBe(0); - expect(viewModel.totalCount).toBe(0); - }); - - it('should handle mixed race lists', () => { - const dto = { - upcomingRaces: [ - { - id: 'race-1', - title: 'Upcoming', - scheduledTime: '2024-01-01T20:00:00Z', - status: 'upcoming', - }, - ], - completedRaces: [ - { - id: 'race-2', - title: 'Completed 1', - scheduledTime: '2023-12-20T20:00:00Z', - status: 'finished', - }, - { - id: 'race-3', - title: 'Completed 2', - scheduledTime: '2023-12-21T20:00:00Z', - status: 'finished', - }, - ], - totalCount: 3, - }; - - const viewModel = new RacesPageViewModel(dto); - - expect(viewModel.upcomingCount).toBe(1); - expect(viewModel.completedCount).toBe(2); - expect(viewModel.totalCount).toBe(3); }); }); \ No newline at end of file diff --git a/apps/website/lib/view-models/RecordEngagementInputViewModel.test.ts b/apps/website/lib/view-models/RecordEngagementInputViewModel.test.ts new file mode 100644 index 000000000..a1b4cd436 --- /dev/null +++ b/apps/website/lib/view-models/RecordEngagementInputViewModel.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { RecordEngagementInputViewModel } from './RecordEngagementInputViewModel'; + +describe('RecordEngagementInputViewModel', () => { + it('maps basic fields from input data', () => { + const vm = new RecordEngagementInputViewModel({ + eventType: 'button_click', + userId: 'user-1', + metadata: { key: 'value' }, + }); + + expect(vm.eventType).toBe('button_click'); + expect(vm.userId).toBe('user-1'); + expect(vm.metadata).toEqual({ key: 'value' }); + }); + + it('derives displayEventType from snake_case eventType', () => { + const vm = new RecordEngagementInputViewModel({ + eventType: 'page_view', + }); + + expect(vm.displayEventType).toBe('Page View'); + }); + + it('detects presence of metadata and counts keys', () => { + const withMetadata = new RecordEngagementInputViewModel({ + eventType: 'test', + metadata: { a: 1, b: 2 }, + }); + const withoutMetadata = new RecordEngagementInputViewModel({ + eventType: 'test', + }); + const emptyMetadata = new RecordEngagementInputViewModel({ + eventType: 'test', + metadata: {}, + }); + + expect(withMetadata.hasMetadata).toBe(true); + expect(withMetadata.metadataKeysCount).toBe(2); + + expect(withoutMetadata.hasMetadata).toBe(false); + expect(withoutMetadata.metadataKeysCount).toBe(0); + + expect(emptyMetadata.hasMetadata).toBe(false); + expect(emptyMetadata.metadataKeysCount).toBe(0); + }); +}); diff --git a/apps/website/lib/view-models/RecordEngagementOutputViewModel.test.ts b/apps/website/lib/view-models/RecordEngagementOutputViewModel.test.ts new file mode 100644 index 000000000..7e0d8c1e2 --- /dev/null +++ b/apps/website/lib/view-models/RecordEngagementOutputViewModel.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { RecordEngagementOutputViewModel } from './RecordEngagementOutputViewModel'; + +describe('RecordEngagementOutputViewModel', () => { + it('maps DTO fields into view model', () => { + const dto = { eventId: 'evt-123', engagementWeight: 1.2345 } as any; + + const vm = new RecordEngagementOutputViewModel(dto); + + expect(vm.eventId).toBe('evt-123'); + expect(vm.engagementWeight).toBe(1.2345); + }); + + it('formats displayEventId with prefix', () => { + const vm = new RecordEngagementOutputViewModel({ eventId: 'evt-999', engagementWeight: 0.5 } as any); + + expect(vm.displayEventId).toBe('Event: evt-999'); + }); + + it('formats engagement weight to two decimals', () => { + const vm = new RecordEngagementOutputViewModel({ eventId: 'evt', engagementWeight: 1.2 } as any); + + expect(vm.displayEngagementWeight).toBe('1.20'); + }); + + it('flags high engagement above threshold', () => { + const low = new RecordEngagementOutputViewModel({ eventId: 'evt', engagementWeight: 0.5 } as any); + const high = new RecordEngagementOutputViewModel({ eventId: 'evt', engagementWeight: 1.5 } as any); + + expect(low.isHighEngagement).toBe(false); + expect(high.isHighEngagement).toBe(true); + }); +}); diff --git a/apps/website/lib/view-models/RecordPageViewInputViewModel.test.ts b/apps/website/lib/view-models/RecordPageViewInputViewModel.test.ts new file mode 100644 index 000000000..0a94ac750 --- /dev/null +++ b/apps/website/lib/view-models/RecordPageViewInputViewModel.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { RecordPageViewInputViewModel } from './RecordPageViewInputViewModel'; + +describe('RecordPageViewInputViewModel', () => { + it('maps basic fields from input data', () => { + const vm = new RecordPageViewInputViewModel({ path: '/home', userId: 'user-1' }); + + expect(vm.path).toBe('/home'); + expect(vm.userId).toBe('user-1'); + }); + + it('normalizes displayPath to always start with a slash', () => { + const withLeadingSlash = new RecordPageViewInputViewModel({ path: '/dashboard' }); + const withoutLeadingSlash = new RecordPageViewInputViewModel({ path: 'settings' }); + + expect(withLeadingSlash.displayPath).toBe('/dashboard'); + expect(withoutLeadingSlash.displayPath).toBe('/settings'); + }); + + it('detects when user context is present', () => { + const withUser = new RecordPageViewInputViewModel({ path: '/', userId: 'user-1' }); + const withoutUser = new RecordPageViewInputViewModel({ path: '/' }); + const emptyUserId = new RecordPageViewInputViewModel({ path: '/', userId: '' }); + + expect(withUser.hasUserContext).toBe(true); + expect(withoutUser.hasUserContext).toBe(false); + expect(emptyUserId.hasUserContext).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/RecordPageViewOutputViewModel.test.ts b/apps/website/lib/view-models/RecordPageViewOutputViewModel.test.ts new file mode 100644 index 000000000..f8165e46f --- /dev/null +++ b/apps/website/lib/view-models/RecordPageViewOutputViewModel.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { RecordPageViewOutputViewModel } from './RecordPageViewOutputViewModel'; + +describe('RecordPageViewOutputViewModel', () => { + it('maps DTO fields into view model', () => { + const dto = { pageViewId: 'pv-123' } as any; + + const vm = new RecordPageViewOutputViewModel(dto); + + expect(vm.pageViewId).toBe('pv-123'); + }); + + it('formats displayPageViewId with prefix', () => { + const vm = new RecordPageViewOutputViewModel({ pageViewId: 'pv-999' } as any); + + expect(vm.displayPageViewId).toBe('Page View: pv-999'); + }); +}); diff --git a/apps/website/lib/view-models/RemoveMemberViewModel.test.ts b/apps/website/lib/view-models/RemoveMemberViewModel.test.ts new file mode 100644 index 000000000..2c788c7f8 --- /dev/null +++ b/apps/website/lib/view-models/RemoveMemberViewModel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { RemoveMemberViewModel } from './RemoveMemberViewModel'; +import type { RemoveLeagueMemberOutputDTO } from '../types/generated/RemoveLeagueMemberOutputDTO'; + +const createRemoveMemberDto = (overrides: Partial = {}): RemoveLeagueMemberOutputDTO => ({ + success: true, + ...overrides, +}); + +describe('RemoveMemberViewModel', () => { + it('maps success flag from DTO', () => { + const dto = createRemoveMemberDto({ success: true }); + + const vm = new RemoveMemberViewModel(dto); + + expect(vm.success).toBe(true); + }); + + it('provides success message when operation succeeds', () => { + const dto = createRemoveMemberDto({ success: true }); + + const vm = new RemoveMemberViewModel(dto); + + expect(vm.successMessage).toBe('Member removed successfully!'); + }); + + it('provides failure message when operation fails', () => { + const dto = createRemoveMemberDto({ success: false }); + + const vm = new RemoveMemberViewModel(dto); + + expect(vm.successMessage).toBe('Failed to remove member.'); + }); +}); diff --git a/apps/website/lib/view-models/RenewalAlertViewModel.test.ts b/apps/website/lib/view-models/RenewalAlertViewModel.test.ts new file mode 100644 index 000000000..7a9941fc8 --- /dev/null +++ b/apps/website/lib/view-models/RenewalAlertViewModel.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { RenewalAlertViewModel } from './RenewalAlertViewModel'; + +describe('RenewalAlertViewModel', () => { + it('maps core fields and derives formatted price and date', () => { + const vm = new RenewalAlertViewModel({ + id: 'ren-1', + name: 'League Sponsorship', + type: 'league', + renewDate: '2024-01-15', + price: 100, + }); + + expect(vm.id).toBe('ren-1'); + expect(vm.name).toBe('League Sponsorship'); + expect(vm.type).toBe('league'); + expect(vm.formattedPrice).toBe('$100'); + expect(typeof vm.formattedRenewDate).toBe('string'); + }); + + it('maps type to icon name', () => { + const league = new RenewalAlertViewModel({ id: '1', name: 'A', type: 'league', renewDate: '2024-01-01', price: 0 }); + const team = new RenewalAlertViewModel({ id: '2', name: 'B', type: 'team', renewDate: '2024-01-01', price: 0 }); + const driver = new RenewalAlertViewModel({ id: '3', name: 'C', type: 'driver', renewDate: '2024-01-01', price: 0 }); + const race = new RenewalAlertViewModel({ id: '4', name: 'D', type: 'race', renewDate: '2024-01-01', price: 0 }); + const platform = new RenewalAlertViewModel({ id: '5', name: 'E', type: 'platform', renewDate: '2024-01-01', price: 0 }); + + expect(league.typeIcon).toBe('Trophy'); + expect(team.typeIcon).toBe('Users'); + expect(driver.typeIcon).toBe('Car'); + expect(race.typeIcon).toBe('Flag'); + expect(platform.typeIcon).toBe('Megaphone'); + }); + + it('computes daysUntilRenewal and urgency flag based on current date', () => { + const now = new Date(); + const soon = new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000); + const later = new Date(now.getTime() + 40 * 24 * 60 * 60 * 1000); + + const soonAlert = new RenewalAlertViewModel({ + id: 'soon', + name: 'Soon', + type: 'league', + renewDate: soon.toISOString(), + price: 0, + }); + + const laterAlert = new RenewalAlertViewModel({ + id: 'later', + name: 'Later', + type: 'league', + renewDate: later.toISOString(), + price: 0, + }); + + expect(soonAlert.daysUntilRenewal).toBeGreaterThan(0); + expect(soonAlert.isUrgent).toBe(true); + + expect(laterAlert.daysUntilRenewal).toBeGreaterThan(30); + expect(laterAlert.isUrgent).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.test.ts b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.test.ts new file mode 100644 index 000000000..060b69955 --- /dev/null +++ b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { RequestAvatarGenerationViewModel } from './RequestAvatarGenerationViewModel'; + +describe('RequestAvatarGenerationViewModel', () => { + it('creates instance with successful generation and avatarUrl', () => { + const dto = { + success: true, + avatarUrl: 'https://example.com/generated-avatar.jpg', + }; + + const viewModel = new RequestAvatarGenerationViewModel(dto); + + expect(viewModel.success).toBe(true); + expect(viewModel.avatarUrl).toBe('https://example.com/generated-avatar.jpg'); + expect(viewModel.error).toBeUndefined(); + }); + + it('creates instance with failed generation and error', () => { + const dto = { + success: false, + error: 'Generation failed', + }; + + const viewModel = new RequestAvatarGenerationViewModel(dto); + + expect(viewModel.success).toBe(false); + expect(viewModel.avatarUrl).toBeUndefined(); + expect(viewModel.error).toBe('Generation failed'); + }); + + it('isSuccessful reflects success flag', () => { + const successVm = new RequestAvatarGenerationViewModel({ success: true }); + const failureVm = new RequestAvatarGenerationViewModel({ success: false }); + + expect(successVm.isSuccessful).toBe(true); + expect(failureVm.isSuccessful).toBe(false); + }); + + it('hasError reflects presence of error message', () => { + const noErrorVm = new RequestAvatarGenerationViewModel({ success: true }); + const errorVm = new RequestAvatarGenerationViewModel({ success: false, error: 'Something went wrong' }); + + expect(noErrorVm.hasError).toBe(false); + expect(errorVm.hasError).toBe(true); + }); + + it('treats empty error string as no error', () => { + const viewModel = new RequestAvatarGenerationViewModel({ success: false, error: '' }); + + expect(viewModel.hasError).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/SessionViewModel.test.ts b/apps/website/lib/view-models/SessionViewModel.test.ts new file mode 100644 index 000000000..faddbaf5b --- /dev/null +++ b/apps/website/lib/view-models/SessionViewModel.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { SessionViewModel } from './SessionViewModel'; +import type { AuthenticatedUserDTO } from '../types/generated/AuthenticatedUserDTO'; + +describe('SessionViewModel', () => { + const createDto = (overrides?: Partial): AuthenticatedUserDTO => ({ + userId: 'user-1', + email: 'user@example.com', + displayName: 'Test User', + ...overrides, + }); + + it('maps basic user identity fields from the DTO', () => { + const dto = createDto(); + const viewModel = new SessionViewModel(dto); + + expect(viewModel.userId).toBe('user-1'); + expect(viewModel.email).toBe('user@example.com'); + expect(viewModel.displayName).toBe('Test User'); + }); + + it('provides a greeting based on the display name', () => { + const dto = createDto({ displayName: 'Roo Racer' }); + const viewModel = new SessionViewModel(dto); + + expect(viewModel.greeting).toBe('Hello, Roo Racer!'); + }); + + it('derives avatar initials from the display name', () => { + const dto = createDto({ displayName: 'Roo Racer' }); + const viewModel = new SessionViewModel(dto); + + expect(viewModel.avatarInitials).toBe('RR'); + }); + + it('falls back to the email first letter when display name is empty', () => { + const dto = createDto({ displayName: '' }); + const viewModel = new SessionViewModel(dto); + + expect(viewModel.avatarInitials).toBe('U'); + }); + + it('indicates when a driver profile is present via hasDriverProfile', () => { + const dto = createDto(); + const withoutDriver = new SessionViewModel(dto); + + const withDriver = new SessionViewModel(dto); + withDriver.driverId = 'driver-1'; + + expect(withoutDriver.hasDriverProfile).toBe(false); + expect(withDriver.hasDriverProfile).toBe(true); + }); + + it('returns the correct authStatusDisplay based on isAuthenticated flag', () => { + const dto = createDto(); + const authenticated = new SessionViewModel(dto); + + const unauthenticated = new SessionViewModel(dto); + unauthenticated.isAuthenticated = false; + + expect(authenticated.authStatusDisplay).toBe('Logged In'); + expect(unauthenticated.authStatusDisplay).toBe('Logged Out'); + }); +}); diff --git a/apps/website/lib/view-models/SponsorDashboardViewModel.test.ts b/apps/website/lib/view-models/SponsorDashboardViewModel.test.ts new file mode 100644 index 000000000..7650d5d6b --- /dev/null +++ b/apps/website/lib/view-models/SponsorDashboardViewModel.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { SponsorDashboardViewModel } from './SponsorDashboardViewModel'; +import { SponsorshipViewModel } from './SponsorshipViewModel'; +import { ActivityItemViewModel } from './ActivityItemViewModel'; +import { RenewalAlertViewModel } from './RenewalAlertViewModel'; + +function makeSponsorship(overrides: Partial = {}) { + return { + id: 's-1', + type: 'leagues', + entityId: 'league-1', + entityName: 'Pro League', + status: 'active', + startDate: '2025-01-01', + endDate: '2025-12-31', + price: 5_000, + impressions: 50_000, + ...overrides, + }; +} + +describe('SponsorDashboardViewModel', () => { + const baseDto = { + sponsorId: 'sp-1', + sponsorName: 'Acme Corp', + metrics: { totalSpend: 10_000, totalImpressions: 100_000 }, + sponsorships: { + leagues: [makeSponsorship()], + teams: [makeSponsorship({ id: 's-2', type: 'teams', price: 2_000, impressions: 20_000 })], + drivers: [], + races: [], + platform: [], + }, + recentActivity: [ + { id: 'a-1', type: 'impression', timestamp: '2025-01-01T00:00:00Z', metadata: {} }, + ], + upcomingRenewals: [ + { id: 'r-1', sponsorshipId: 's-1', leagueName: 'Pro League', daysUntilRenewal: 10 }, + ], + } as any; + + it('maps nested DTOs into view models', () => { + const vm = new SponsorDashboardViewModel(baseDto); + + expect(vm.sponsorId).toBe(baseDto.sponsorId); + expect(vm.sponsorName).toBe(baseDto.sponsorName); + expect(vm.metrics).toBe(baseDto.metrics); + + expect(vm.sponsorships.leagues[0]).toBeInstanceOf(SponsorshipViewModel); + expect(vm.sponsorships.teams[0]).toBeInstanceOf(SponsorshipViewModel); + expect(vm.recentActivity[0]).toBeInstanceOf(ActivityItemViewModel); + expect(vm.upcomingRenewals[0]).toBeInstanceOf(RenewalAlertViewModel); + }); + + it('computes total, active counts and investment from sponsorship buckets', () => { + const vm = new SponsorDashboardViewModel(baseDto); + + const all = [ + ...baseDto.sponsorships.leagues, + ...baseDto.sponsorships.teams, + ]; + + expect(vm.totalSponsorships).toBe(all.length); + expect(vm.activeSponsorships).toBe(all.filter(s => s.status === 'active').length); + const expectedInvestment = all + .filter(s => s.status === 'active') + .reduce((sum, s) => sum + s.price, 0); + expect(vm.totalInvestment).toBe(expectedInvestment); + }); + + it('aggregates total impressions across all sponsorships', () => { + const vm = new SponsorDashboardViewModel(baseDto); + + const all = [ + ...baseDto.sponsorships.leagues, + ...baseDto.sponsorships.teams, + ]; + + const expectedImpressions = all.reduce((sum, s) => sum + s.impressions, 0); + expect(vm.totalImpressions).toBe(expectedImpressions); + }); + + it('derives formatted investment, active percentage, status text and CPM', () => { + const vm = new SponsorDashboardViewModel(baseDto); + + expect(vm.formattedTotalInvestment).toBe(`$${vm.totalInvestment.toLocaleString()}`); + + const expectedActivePercentage = Math.round((vm.activeSponsorships / vm.totalSponsorships) * 100); + expect(vm.activePercentage).toBe(expectedActivePercentage); + expect(vm.hasSponsorships).toBe(true); + + // statusText variants + const noActive = new SponsorDashboardViewModel({ + ...baseDto, + sponsorships: { + leagues: [makeSponsorship({ status: 'expired' })], + teams: [], + drivers: [], + races: [], + platform: [], + }, + } as any); + expect(noActive.statusText).toBe('No active sponsorships'); + + const allActive = new SponsorDashboardViewModel({ + ...baseDto, + sponsorships: { + leagues: [makeSponsorship()], + teams: [], + drivers: [], + races: [], + platform: [], + }, + } as any); + expect(allActive.statusText).toBe('All sponsorships active'); + + // cost per thousand views + expect(vm.costPerThousandViews).toBe(`$${(vm.totalInvestment / vm.totalImpressions * 1000).toFixed(2)}`); + + const zeroImpressions = new SponsorDashboardViewModel({ + ...baseDto, + sponsorships: { + leagues: [makeSponsorship({ impressions: 0 })], + teams: [], + drivers: [], + races: [], + platform: [], + }, + } as any); + expect(zeroImpressions.costPerThousandViews).toBe('$0.00'); + }); + + it('exposes category data per sponsorship bucket', () => { + const vm = new SponsorDashboardViewModel(baseDto); + + expect(vm.categoryData.leagues.count).toBe(baseDto.sponsorships.leagues.length); + expect(vm.categoryData.leagues.impressions).toBe( + baseDto.sponsorships.leagues.reduce((sum: number, s: any) => sum + s.impressions, 0), + ); + + expect(vm.categoryData.teams.count).toBe(baseDto.sponsorships.teams.length); + expect(vm.categoryData.teams.impressions).toBe( + baseDto.sponsorships.teams.reduce((sum: number, s: any) => sum + s.impressions, 0), + ); + }); + + it('handles missing sponsorship buckets gracefully', () => { + const vm = new SponsorDashboardViewModel({ + ...baseDto, + sponsorships: undefined, + recentActivity: undefined, + upcomingRenewals: undefined, + } as any); + + expect(vm.totalSponsorships).toBe(0); + expect(vm.activeSponsorships).toBe(0); + expect(vm.totalInvestment).toBe(0); + expect(vm.totalImpressions).toBe(0); + expect(vm.activePercentage).toBe(0); + expect(vm.hasSponsorships).toBe(false); + expect(vm.costPerThousandViews).toBe('$0.00'); + expect(vm.recentActivity).toEqual([]); + expect(vm.upcomingRenewals).toEqual([]); + }); +}); diff --git a/apps/website/lib/view-models/SponsorSettingsViewModel.test.ts b/apps/website/lib/view-models/SponsorSettingsViewModel.test.ts new file mode 100644 index 000000000..25fa4321f --- /dev/null +++ b/apps/website/lib/view-models/SponsorSettingsViewModel.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { SponsorSettingsViewModel, SponsorProfileViewModel, NotificationSettingsViewModel, PrivacySettingsViewModel } from './SponsorSettingsViewModel'; + +describe('SponsorSettingsViewModel', () => { + const profile = { + companyName: 'Acme Corp', + contactName: 'John Doe', + contactEmail: 'john@example.com', + contactPhone: '+1234567890', + website: 'https://acme.example', + description: 'We sponsor racing', + logoUrl: '/logo.png', + industry: 'Automotive', + address: { + street: '123 Main St', + city: 'Metropolis', + country: 'US', + postalCode: '12345', + }, + taxId: 'TAX-123', + socialLinks: { + twitter: '@acme', + linkedin: 'https://linkedin.com/acme', + instagram: '@acme_insta', + }, + }; + + const notifications = { + emailNewSponsorships: true, + emailWeeklyReport: false, + emailRaceAlerts: true, + emailPaymentAlerts: false, + emailNewOpportunities: true, + emailContractExpiry: true, + }; + + const privacy = { + publicProfile: true, + showStats: false, + showActiveSponsorships: true, + allowDirectContact: false, + }; + + it('maps raw settings object into nested view models', () => { + const vm = new SponsorSettingsViewModel({ profile, notifications, privacy }); + + expect(vm.profile).toBeInstanceOf(SponsorProfileViewModel); + expect(vm.notifications).toBeInstanceOf(NotificationSettingsViewModel); + expect(vm.privacy).toBeInstanceOf(PrivacySettingsViewModel); + + expect(vm.profile.companyName).toBe(profile.companyName); + expect(vm.notifications.emailNewSponsorships).toBe(true); + expect(vm.privacy.publicProfile).toBe(true); + }); + + it('exposes fullAddress computed from address fields', () => { + const profileVm = new SponsorProfileViewModel(profile); + + expect(profileVm.fullAddress).toBe( + `${profile.address.street}, ${profile.address.city}, ${profile.address.postalCode}, ${profile.address.country}`, + ); + }); + + it('preserves notification and privacy boolean flags', () => { + const notificationsVm = new NotificationSettingsViewModel(notifications); + const privacyVm = new PrivacySettingsViewModel(privacy); + + expect(notificationsVm.emailWeeklyReport).toBe(false); + expect(notificationsVm.emailContractExpiry).toBe(true); + + expect(privacyVm.showStats).toBe(false); + expect(privacyVm.allowDirectContact).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/SponsorSponsorshipsViewModel.test.ts b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.test.ts new file mode 100644 index 000000000..cc86038fb --- /dev/null +++ b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { SponsorSponsorshipsViewModel } from './SponsorSponsorshipsViewModel'; +import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel'; + +describe('SponsorSponsorshipsViewModel', () => { + const dto = { + sponsorId: 'sp-1', + sponsorName: 'Acme Corp', + } as any; + + const makeDetail = (overrides: Partial = {}) => { + const base = new SponsorshipDetailViewModel({ + id: 'detail-1', + leagueId: 'league-1', + leagueName: 'Pro League', + seasonId: 'season-1', + seasonName: 'Season 1', + } as any); + Object.assign(base, overrides); + return base; + }; + + it('maps core sponsor identifiers from DTO', () => { + const vm = new SponsorSponsorshipsViewModel(dto); + + expect(vm.sponsorId).toBe(dto.sponsorId); + expect(vm.sponsorName).toBe(dto.sponsorName); + }); + + it('derives counts and investment summary from attached sponsorships', () => { + const vm = new SponsorSponsorshipsViewModel(dto); + vm.sponsorships = [ + makeDetail({ id: 'd1', amount: 2_000, currency: 'USD', status: 'active' }), + makeDetail({ id: 'd2', amount: 1_000, currency: 'USD', status: 'expired' }), + makeDetail({ id: 'd3', amount: 3_000, currency: 'USD', status: 'active' }), + ]; + + expect(vm.totalCount).toBe(3); + expect(vm.activeSponsorships).toHaveLength(2); + expect(vm.activeCount).toBe(2); + expect(vm.hasSponsorships).toBe(true); + + const expectedInvestment = vm.sponsorships.reduce((sum, s) => sum + s.amount, 0); + expect(vm.totalInvestment).toBe(expectedInvestment); + expect(vm.formattedTotalInvestment).toBe(`USD ${expectedInvestment.toLocaleString()}`); + }); + + it('handles empty sponsorships gracefully', () => { + const vm = new SponsorSponsorshipsViewModel(dto); + + expect(vm.totalCount).toBe(0); + expect(vm.activeSponsorships).toEqual([]); + expect(vm.activeCount).toBe(0); + expect(vm.hasSponsorships).toBe(false); + expect(vm.totalInvestment).toBe(0); + expect(vm.formattedTotalInvestment).toBe('USD 0'); + }); +}); diff --git a/apps/website/lib/view-models/SponsorViewModel.test.ts b/apps/website/lib/view-models/SponsorViewModel.test.ts new file mode 100644 index 000000000..1161f946a --- /dev/null +++ b/apps/website/lib/view-models/SponsorViewModel.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { SponsorViewModel } from './SponsorViewModel'; + +describe('SponsorViewModel', () => { + it('maps basic sponsor fields from DTO', () => { + const dto = { + id: 'sp-1', + name: 'Acme Corp', + logoUrl: '/logo.png', + websiteUrl: 'https://acme.example', + }; + + const vm = new SponsorViewModel(dto as any); + + expect(vm.id).toBe(dto.id); + expect(vm.name).toBe(dto.name); + expect(vm.logoUrl).toBe(dto.logoUrl); + expect(vm.websiteUrl).toBe(dto.websiteUrl); + }); + + it('does not assign optional fields when they are undefined', () => { + const dto = { + id: 'sp-2', + name: 'Minimal Sponsor', + logoUrl: undefined, + websiteUrl: undefined, + }; + + const vm = new SponsorViewModel(dto as any); + + expect(vm.id).toBe(dto.id); + expect(vm.name).toBe(dto.name); + expect('logoUrl' in vm).toBe(false); + expect('websiteUrl' in vm).toBe(false); + }); + + it('exposes simple UI helpers', () => { + const withWebsite = new SponsorViewModel({ id: 'sp-3', name: 'With Site', websiteUrl: 'https://example.com' } as any); + const withoutWebsite = new SponsorViewModel({ id: 'sp-4', name: 'No Site' } as any); + + expect(withWebsite.displayName).toBe(withWebsite.name); + expect(withWebsite.hasWebsite).toBe(true); + expect(withoutWebsite.hasWebsite).toBe(false); + expect(withWebsite.websiteLinkText).toBe('Visit Website'); + }); +}); diff --git a/apps/website/lib/view-models/SponsorshipDetailViewModel.test.ts b/apps/website/lib/view-models/SponsorshipDetailViewModel.test.ts new file mode 100644 index 000000000..4a81ce5b1 --- /dev/null +++ b/apps/website/lib/view-models/SponsorshipDetailViewModel.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel'; + +describe('SponsorshipDetailViewModel', () => { + const dto = { + id: 'detail-1', + leagueId: 'league-1', + leagueName: 'Pro League', + seasonId: 'season-1', + seasonName: 'Season 1', + } as any; + + it('maps core identifiers from generated DTO', () => { + const vm = new SponsorshipDetailViewModel(dto); + + expect(vm.id).toBe(dto.id); + expect(vm.leagueId).toBe(dto.leagueId); + expect(vm.leagueName).toBe(dto.leagueName); + expect(vm.seasonId).toBe(dto.seasonId); + expect(vm.seasonName).toBe(dto.seasonName); + }); + + it('uses default UI-specific sponsorship fields and formatting', () => { + const vm = new SponsorshipDetailViewModel(dto); + + expect(vm.tier).toBe('secondary'); + expect(vm.status).toBe('active'); + expect(vm.amount).toBe(0); + expect(vm.currency).toBe('USD'); + + expect(vm.formattedAmount).toBe('USD 0'); + expect(vm.tierBadgeVariant).toBe('secondary'); + expect(vm.statusColor).toBe('green'); + expect(vm.statusDisplay).toBe('Active'); + }); + + it('derives badge variant and status styling from status and tier', () => { + const main = new SponsorshipDetailViewModel(dto); + main.tier = 'main'; + main.status = 'pending'; + main.amount = 5_000; + + expect(main.tierBadgeVariant).toBe('primary'); + expect(main.statusColor).toBe('yellow'); + expect(main.statusDisplay).toBe('Pending'); + expect(main.formattedAmount).toBe('USD 5,000'); + + const expired = new SponsorshipDetailViewModel(dto); + expired.status = 'expired'; + expect(expired.statusColor).toBe('red'); + + const unknown = new SponsorshipDetailViewModel(dto); + unknown.status = 'unknown' as any; + expect(unknown.statusColor).toBe('gray'); + }); +}); diff --git a/apps/website/lib/view-models/SponsorshipPricingViewModel.test.ts b/apps/website/lib/view-models/SponsorshipPricingViewModel.test.ts new file mode 100644 index 000000000..9afc16b87 --- /dev/null +++ b/apps/website/lib/view-models/SponsorshipPricingViewModel.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { SponsorshipPricingViewModel } from './SponsorshipPricingViewModel'; + +describe('SponsorshipPricingViewModel', () => { + const dto = { + mainSlotPrice: 10_000, + secondarySlotPrice: 6_000, + currency: 'USD', + } as any; + + it('maps basic pricing fields from DTO', () => { + const vm = new SponsorshipPricingViewModel(dto); + + expect(vm.mainSlotPrice).toBe(dto.mainSlotPrice); + expect(vm.secondarySlotPrice).toBe(dto.secondarySlotPrice); + expect(vm.currency).toBe(dto.currency); + }); + + it('exposes formatted prices and price difference', () => { + const vm = new SponsorshipPricingViewModel(dto); + + expect(vm.formattedMainSlotPrice).toBe(`${dto.currency} ${dto.mainSlotPrice.toLocaleString()}`); + expect(vm.formattedSecondarySlotPrice).toBe(`${dto.currency} ${dto.secondarySlotPrice.toLocaleString()}`); + + const expectedDiff = dto.mainSlotPrice - dto.secondarySlotPrice; + expect(vm.priceDifference).toBe(expectedDiff); + expect(vm.formattedPriceDifference).toBe(`${dto.currency} ${expectedDiff.toLocaleString()}`); + }); + + it('computes discount percentage for secondary slots', () => { + const vm = new SponsorshipPricingViewModel(dto); + + const expectedDiscount = Math.round((1 - dto.secondarySlotPrice / dto.mainSlotPrice) * 100); + expect(vm.secondaryDiscountPercentage).toBe(expectedDiscount); + + const zeroMain = new SponsorshipPricingViewModel({ ...dto, mainSlotPrice: 0 }); + expect(zeroMain.secondaryDiscountPercentage).toBe(0); + }); +}); diff --git a/apps/website/lib/view-models/SponsorshipRequestViewModel.test.ts b/apps/website/lib/view-models/SponsorshipRequestViewModel.test.ts new file mode 100644 index 000000000..fa5d438d6 --- /dev/null +++ b/apps/website/lib/view-models/SponsorshipRequestViewModel.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SponsorshipRequestViewModel } from './SponsorshipRequestViewModel'; + +describe('SponsorshipRequestViewModel', () => { + const baseDto = { + id: 'req-1', + sponsorId: 'sp-1', + sponsorName: 'Acme Corp', + sponsorLogo: '/logo.png', + tier: 'main' as const, + offeredAmount: 50_000, + currency: 'USD', + formattedAmount: 'USD 50,000', + message: 'We would like to sponsor your league', + createdAt: new Date('2025-01-10T00:00:00Z'), + platformFee: 5_000, + netAmount: 45_000, + }; + + it('maps DTO fields and keeps date instance', () => { + const vm = new SponsorshipRequestViewModel(baseDto as any); + + expect(vm.id).toBe(baseDto.id); + expect(vm.sponsorId).toBe(baseDto.sponsorId); + expect(vm.sponsorName).toBe(baseDto.sponsorName); + expect(vm.sponsorLogo).toBe(baseDto.sponsorLogo); + expect(vm.tier).toBe(baseDto.tier); + expect(vm.offeredAmount).toBe(baseDto.offeredAmount); + expect(vm.currency).toBe(baseDto.currency); + expect(vm.formattedAmount).toBe(baseDto.formattedAmount); + expect(vm.message).toBe(baseDto.message); + expect(vm.createdAt).toBeInstanceOf(Date); + expect(vm.platformFee).toBe(baseDto.platformFee); + expect(vm.netAmount).toBe(baseDto.netAmount); + }); + + it('formats created date for UI display', () => { + const fixedNow = new Date('2025-01-10T00:00:00Z'); + vi.setSystemTime(fixedNow); + + const vm = new SponsorshipRequestViewModel(baseDto as any); + + const formatted = vm.formattedDate; + expect(typeof formatted).toBe('string'); + expect(formatted.length).toBeGreaterThan(0); + + vi.useRealTimers(); + }); + + it('exposes net amount in dollars and tier helpers', () => { + const vmMain = new SponsorshipRequestViewModel(baseDto as any); + expect(vmMain.netAmountDollars).toBe(`$${(baseDto.netAmount / 100).toFixed(2)}`); + expect(vmMain.tierDisplay).toBe('Main Sponsor'); + expect(vmMain.tierBadgeVariant).toBe('primary'); + + const vmSecondary = new SponsorshipRequestViewModel({ ...baseDto, tier: 'secondary' } as any); + expect(vmSecondary.tierDisplay).toBe('Secondary'); + expect(vmSecondary.tierBadgeVariant).toBe('secondary'); + }); +}); diff --git a/apps/website/lib/view-models/SponsorshipViewModel.test.ts b/apps/website/lib/view-models/SponsorshipViewModel.test.ts new file mode 100644 index 000000000..b6f3e0602 --- /dev/null +++ b/apps/website/lib/view-models/SponsorshipViewModel.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SponsorshipViewModel } from './SponsorshipViewModel'; + +describe('SponsorshipViewModel', () => { + const baseData = { + id: 'sp-1', + type: 'leagues' as const, + entityId: 'league-1', + entityName: 'Pro League', + tier: 'main' as const, + status: 'active' as const, + applicationDate: '2025-01-01T00:00:00Z', + approvalDate: '2025-01-02T00:00:00Z', + startDate: '2025-01-10T00:00:00Z', + endDate: '2025-02-10T00:00:00Z', + price: 5_000, + impressions: 50_000, + impressionsChange: 10, + engagement: 3.5, + details: 'Full season branding', + entityOwner: 'League Owner', + applicationMessage: 'Excited to sponsor', + }; + + it('maps core fields and converts dates', () => { + const vm = new SponsorshipViewModel(baseData); + + expect(vm.id).toBe(baseData.id); + expect(vm.type).toBe(baseData.type); + expect(vm.entityId).toBe(baseData.entityId); + expect(vm.entityName).toBe(baseData.entityName); + expect(vm.tier).toBe(baseData.tier); + expect(vm.status).toBe(baseData.status); + + expect(vm.applicationDate).toBeInstanceOf(Date); + expect(vm.approvalDate).toBeInstanceOf(Date); + expect(vm.startDate).toBeInstanceOf(Date); + expect(vm.endDate).toBeInstanceOf(Date); + + expect(vm.price).toBe(baseData.price); + expect(vm.impressions).toBe(baseData.impressions); + expect(vm.impressionsChange).toBe(baseData.impressionsChange); + expect(vm.engagement).toBe(baseData.engagement); + expect(vm.details).toBe(baseData.details); + expect(vm.entityOwner).toBe(baseData.entityOwner); + expect(vm.applicationMessage).toBe(baseData.applicationMessage); + }); + + it('exposes formatted impressions and price', () => { + const vm = new SponsorshipViewModel(baseData); + + expect(vm.formattedImpressions).toBe(baseData.impressions.toLocaleString()); + expect(vm.formattedPrice).toBe(`$${baseData.price}`); + }); + + it('computes daysRemaining and expiringSoon based on endDate', () => { + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const vm = new SponsorshipViewModel({ + ...baseData, + endDate: '2025-01-15T00:00:00Z', + }); + + expect(vm.daysRemaining).toBeGreaterThan(0); + expect(vm.isExpiringSoon).toBe(true); + + const farFuture = new SponsorshipViewModel({ + ...baseData, + endDate: '2025-12-31T00:00:00Z', + }); + expect(farFuture.isExpiringSoon).toBe(false); + + const past = new SponsorshipViewModel({ + ...baseData, + endDate: '2024-12-31T00:00:00Z', + }); + expect(past.daysRemaining).toBeLessThanOrEqual(0); + expect(past.isExpiringSoon).toBe(false); + + vi.useRealTimers(); + }); + + it('derives human-readable status and type labels', () => { + const active = new SponsorshipViewModel({ ...baseData, status: 'active' }); + const pending = new SponsorshipViewModel({ ...baseData, status: 'pending_approval' }); + const approved = new SponsorshipViewModel({ ...baseData, status: 'approved' }); + const rejected = new SponsorshipViewModel({ ...baseData, status: 'rejected' }); + const expired = new SponsorshipViewModel({ ...baseData, status: 'expired' }); + + expect(active.statusLabel).toBe('Active'); + expect(pending.statusLabel).toBe('Awaiting Approval'); + expect(approved.statusLabel).toBe('Approved'); + expect(rejected.statusLabel).toBe('Declined'); + expect(expired.statusLabel).toBe('Expired'); + + const league = new SponsorshipViewModel({ ...baseData, type: 'leagues' }); + const team = new SponsorshipViewModel({ ...baseData, type: 'teams' }); + const driver = new SponsorshipViewModel({ ...baseData, type: 'drivers' }); + const race = new SponsorshipViewModel({ ...baseData, type: 'races' }); + const platform = new SponsorshipViewModel({ ...baseData, type: 'platform' }); + + expect(league.typeLabel).toBe('League'); + expect(team.typeLabel).toBe('Team'); + expect(driver.typeLabel).toBe('Driver'); + expect(race.typeLabel).toBe('Race'); + expect(platform.typeLabel).toBe('Platform'); + }); + + it('formats sponsorship period as month-year range', () => { + const vm = new SponsorshipViewModel({ + ...baseData, + startDate: '2025-01-10T00:00:00Z', + endDate: '2025-03-10T00:00:00Z', + }); + + const [start, end] = vm.periodDisplay.split(' - '); + expect(typeof start).toBe('string'); + expect(typeof end).toBe('string'); + expect(start.length).toBeGreaterThan(0); + expect(end.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/website/lib/view-models/StandingEntryViewModel.ts b/apps/website/lib/view-models/StandingEntryViewModel.ts index 0db89e7a0..4c513bee7 100644 --- a/apps/website/lib/view-models/StandingEntryViewModel.ts +++ b/apps/website/lib/view-models/StandingEntryViewModel.ts @@ -13,10 +13,13 @@ export class StandingEntryViewModel { private currentUserId: string; private previousPosition?: number; - constructor(dto: LeagueStandingDTO, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) { + constructor(dto: LeagueStandingDTO & { position: number; points: number; wins?: number; podiums?: number; races?: number }, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) { this.driverId = dto.driverId; this.position = dto.position; this.points = dto.points; + this.wins = dto.wins ?? 0; + this.podiums = dto.podiums ?? 0; + this.races = dto.races ?? 0; this.leaderPoints = leaderPoints; this.nextPoints = nextPoints; this.currentUserId = currentUserId; @@ -31,9 +34,6 @@ export class StandingEntryViewModel { // Note: The generated DTO is incomplete // These fields will need to be added when the OpenAPI spec is updated driver?: any; - wins: number = 0; - podiums: number = 0; - races: number = 0; /** UI-specific: Points difference to leader */ get pointsGapToLeader(): number { diff --git a/apps/website/lib/view-models/TeamCardViewModel.test.ts b/apps/website/lib/view-models/TeamCardViewModel.test.ts new file mode 100644 index 000000000..e1d3d55de --- /dev/null +++ b/apps/website/lib/view-models/TeamCardViewModel.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { TeamCardViewModel } from './TeamCardViewModel'; +import type { TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO'; + +const createTeamCardDto = (): { id: string; name: string; tag: string; description: string } => ({ + id: 'team-1', + name: 'Team Alpha', + tag: 'ALPHA', + description: 'Endurance specialists', +}); + +const createTeamListItemDto = (overrides: Partial = {}): TeamListItemDTO => ({ + id: 'team-2', + name: 'Team Beta', + tag: 'BETA', + description: 'A test team', + memberCount: 5, + leagues: ['league-1'], + ...overrides, +}); + +describe('TeamCardViewModel', () => { + it('maps fields from simple TeamCardDTO', () => { + const dto = createTeamCardDto(); + + const vm = new TeamCardViewModel(dto); + + expect(vm.id).toBe('team-1'); + expect(vm.name).toBe('Team Alpha'); + expect(vm.tag).toBe('ALPHA'); + expect(vm.description).toBe('Endurance specialists'); + }); + + it('maps fields from TeamListItemDTO', () => { + const dto = createTeamListItemDto({ id: 'team-123', name: 'Custom Team', tag: 'CT' }); + + const vm = new TeamCardViewModel(dto); + + expect(vm.id).toBe('team-123'); + expect(vm.name).toBe('Custom Team'); + expect(vm.tag).toBe('CT'); + expect(vm.description).toBe('A test team'); + }); +}); diff --git a/apps/website/lib/view-models/TeamDetailsViewModel.test.ts b/apps/website/lib/view-models/TeamDetailsViewModel.test.ts new file mode 100644 index 000000000..0077bc578 --- /dev/null +++ b/apps/website/lib/view-models/TeamDetailsViewModel.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { TeamDetailsViewModel } from './TeamDetailsViewModel'; +import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO'; + +const createTeamDetailsDto = (overrides: Partial = {}): GetTeamDetailsOutputDTO => ({ + team: { + id: 'team-1', + name: 'Test Team', + tag: 'TT', + description: 'A test team', + ownerId: 'owner-1', + leagues: ['league-1'], + createdAt: '2024-01-01T00:00:00Z', + specialization: 'endurance', + region: 'EU', + languages: ['en'], + ...(overrides.team ?? {}), + }, + membership: + 'membership' in overrides + ? (overrides.membership ?? null) + : { + role: 'member', + joinedAt: '2024-01-01T00:00:00Z', + isActive: true, + }, + canManage: overrides.canManage ?? true, +}); + +describe('TeamDetailsViewModel', () => { + it('maps team fields and membership from DTO', () => { + const dto = createTeamDetailsDto(); + const vm = new TeamDetailsViewModel(dto, 'current-user'); + + expect(vm.id).toBe('team-1'); + expect(vm.name).toBe('Test Team'); + expect(vm.tag).toBe('TT'); + expect(vm.description).toBe('A test team'); + expect(vm.ownerId).toBe('owner-1'); + expect(vm.leagues).toEqual(['league-1']); + expect(vm.createdAt).toBe('2024-01-01T00:00:00Z'); + expect(vm.specialization).toBe('endurance'); + expect(vm.region).toBe('EU'); + expect(vm.languages).toEqual(['en']); + expect(vm.membership).toEqual({ + role: 'member', + joinedAt: '2024-01-01T00:00:00Z', + isActive: true, + }); + }); + + it('derives ownership, membership and role flags', () => { + const dto = createTeamDetailsDto({ + membership: { + role: 'owner', + joinedAt: '2024-01-01T00:00:00Z', + isActive: true, + }, + }); + + const vm = new TeamDetailsViewModel(dto, 'owner-user'); + + expect(vm.isOwner).toBe(true); + expect(vm.isMember).toBe(true); + expect(vm.userRole).toBe('owner'); + }); + + it('exposes canManage flag from DTO', () => { + const canManageDto = createTeamDetailsDto({ canManage: true }); + const cannotManageDto = createTeamDetailsDto({ canManage: false }); + + const canManageVm = new TeamDetailsViewModel(canManageDto, 'user-1'); + const cannotManageVm = new TeamDetailsViewModel(cannotManageDto, 'user-1'); + + expect(canManageVm.canManage).toBe(true); + expect(cannotManageVm.canManage).toBe(false); + }); + + it('handles null membership as non-member with default role', () => { + const dto = createTeamDetailsDto({ membership: null }); + + const vm = new TeamDetailsViewModel(dto, 'user-1'); + + expect(vm.isMember).toBe(false); + expect(vm.userRole).toBe('none'); + expect(vm.isOwner).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/TeamDetailsViewModel.ts b/apps/website/lib/view-models/TeamDetailsViewModel.ts index 003d3e2c1..07917374d 100644 --- a/apps/website/lib/view-models/TeamDetailsViewModel.ts +++ b/apps/website/lib/view-models/TeamDetailsViewModel.ts @@ -12,7 +12,7 @@ export class TeamDetailsViewModel { region?: string; languages?: string[]; membership: { role: string; joinedAt: string; isActive: boolean } | null; - canManage: boolean; + private _canManage: boolean; private currentUserId: string; constructor(dto: GetTeamDetailsOutputDTO, currentUserId: string) { @@ -27,7 +27,7 @@ export class TeamDetailsViewModel { this.region = dto.team.region; this.languages = dto.team.languages; this.membership = dto.membership; - this.canManage = dto.canManage; + this._canManage = dto.canManage; this.currentUserId = currentUserId; } @@ -38,7 +38,7 @@ export class TeamDetailsViewModel { /** UI-specific: Whether can manage team */ get canManage(): boolean { - return this.canManage; + return this._canManage; } /** UI-specific: Whether current user is member */ diff --git a/apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts b/apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts new file mode 100644 index 000000000..c3cf8d91a --- /dev/null +++ b/apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from './TeamJoinRequestViewModel'; + +const createTeamJoinRequestDto = (overrides: Partial = {}): TeamJoinRequestDTO => ({ + id: 'request-1', + teamId: 'team-1', + driverId: 'driver-1', + requestedAt: '2024-01-01T12:00:00Z', + message: 'Please let me join', + ...overrides, +}); + +describe('TeamJoinRequestViewModel', () => { + it('maps fields from DTO', () => { + const dto = createTeamJoinRequestDto({ id: 'req-123', driverId: 'driver-123' }); + + const vm = new TeamJoinRequestViewModel(dto, 'current-user', true); + + expect(vm.id).toBe('req-123'); + expect(vm.teamId).toBe('team-1'); + expect(vm.driverId).toBe('driver-123'); + expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z'); + expect(vm.message).toBe('Please let me join'); + }); + + it('allows approval only for owners', () => { + const dto = createTeamJoinRequestDto(); + + const ownerVm = new TeamJoinRequestViewModel(dto, 'owner-user', true); + const nonOwnerVm = new TeamJoinRequestViewModel(dto, 'regular-user', false); + + expect(ownerVm.canApprove).toBe(true); + expect(nonOwnerVm.canApprove).toBe(false); + }); + + it('exposes a pending status with yellow color', () => { + const dto = createTeamJoinRequestDto(); + const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true); + + expect(vm.status).toBe('Pending'); + expect(vm.statusColor).toBe('yellow'); + }); + + it('provides approve and reject button labels', () => { + const dto = createTeamJoinRequestDto(); + const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true); + + expect(vm.approveButtonText).toBe('Approve'); + expect(vm.rejectButtonText).toBe('Reject'); + }); + + it('formats requestedAt as localized date-time', () => { + const dto = createTeamJoinRequestDto({ requestedAt: '2024-01-01T12:00:00Z' }); + const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true); + + const formatted = vm.formattedRequestedAt; + + expect(typeof formatted).toBe('string'); + expect(formatted.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/website/lib/view-models/TeamMemberViewModel.test.ts b/apps/website/lib/view-models/TeamMemberViewModel.test.ts new file mode 100644 index 000000000..130107b69 --- /dev/null +++ b/apps/website/lib/view-models/TeamMemberViewModel.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { TeamMemberViewModel } from './TeamMemberViewModel'; +import type { TeamMemberDTO } from '@/lib/types/generated/GetTeamMembersOutputDTO'; + +const createTeamMemberDto = (overrides: Partial = {}): TeamMemberDTO => ({ + driverId: 'driver-1', + driverName: 'Test Driver', + role: 'member', + joinedAt: '2024-01-01T00:00:00Z', + isActive: true, + avatarUrl: 'https://example.com/avatar.png', + ...overrides, +}); + +describe('TeamMemberViewModel', () => { + it('maps fields from DTO', () => { + const dto = createTeamMemberDto({ driverId: 'driver-123', driverName: 'Driver 123', role: 'owner' }); + + const vm = new TeamMemberViewModel(dto, 'current-user', 'owner-1'); + + expect(vm.driverId).toBe('driver-123'); + expect(vm.driverName).toBe('Driver 123'); + expect(vm.role).toBe('owner'); + expect(vm.joinedAt).toBe('2024-01-01T00:00:00Z'); + expect(vm.isActive).toBe(true); + expect(vm.avatarUrl).toBe('https://example.com/avatar.png'); + }); + + it('derives roleBadgeVariant based on role', () => { + const ownerVm = new TeamMemberViewModel(createTeamMemberDto({ role: 'owner' }), 'current-user', 'owner-1'); + const managerVm = new TeamMemberViewModel(createTeamMemberDto({ role: 'manager' }), 'current-user', 'owner-1'); + const memberVm = new TeamMemberViewModel(createTeamMemberDto({ role: 'member' }), 'current-user', 'owner-1'); + + expect(ownerVm.roleBadgeVariant).toBe('primary'); + expect(managerVm.roleBadgeVariant).toBe('secondary'); + expect(memberVm.roleBadgeVariant).toBe('default'); + }); + + it('identifies owner correctly based on teamOwnerId', () => { + const dto = createTeamMemberDto({ driverId: 'owner-1', role: 'owner' }); + + const ownerVm = new TeamMemberViewModel(dto, 'some-user', 'owner-1'); + const nonOwnerVm = new TeamMemberViewModel(dto, 'some-user', 'another-owner'); + + expect(ownerVm.isOwner).toBe(true); + expect(nonOwnerVm.isOwner).toBe(false); + }); + + it('determines canManage only for team owner and non-self members', () => { + const memberDto = createTeamMemberDto({ driverId: 'member-1' }); + + const ownerManagingMember = new TeamMemberViewModel(memberDto, 'owner-1', 'owner-1'); + const ownerSelf = new TeamMemberViewModel(createTeamMemberDto({ driverId: 'owner-1' }), 'owner-1', 'owner-1'); + const nonOwner = new TeamMemberViewModel(memberDto, 'another-user', 'owner-1'); + + expect(ownerManagingMember.canManage).toBe(true); + expect(ownerSelf.canManage).toBe(false); + expect(nonOwner.canManage).toBe(false); + }); + + it('identifies current user correctly', () => { + const dto = createTeamMemberDto({ driverId: 'current-user' }); + + const vm = new TeamMemberViewModel(dto, 'current-user', 'owner-1'); + + expect(vm.isCurrentUser).toBe(true); + }); + + it('formats joinedAt as a localized date string', () => { + const dto = createTeamMemberDto({ joinedAt: '2024-01-01T00:00:00Z' }); + const vm = new TeamMemberViewModel(dto, 'current-user', 'owner-1'); + + const formatted = vm.formattedJoinedAt; + + expect(typeof formatted).toBe('string'); + expect(formatted.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/website/lib/view-models/TeamSummaryViewModel.test.ts b/apps/website/lib/view-models/TeamSummaryViewModel.test.ts new file mode 100644 index 000000000..dbc4a58c0 --- /dev/null +++ b/apps/website/lib/view-models/TeamSummaryViewModel.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { TeamSummaryViewModel } from './TeamSummaryViewModel'; +import type { TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO'; + +const createTeamListItemDto = (overrides: Partial = {}): TeamListItemDTO => ({ + id: 'team-1', + name: 'Test Team', + tag: 'TT', + memberCount: 5, + description: 'A test team', + specialization: 'endurance', + region: 'EU', + languages: ['en'], + leagues: ['league-1'], + ...overrides, +}); + +describe('TeamSummaryViewModel', () => { + it('maps fields from DTO', () => { + const dto = createTeamListItemDto({ id: 'team-123', name: 'Custom Team', tag: 'CT', memberCount: 3 }); + + const vm = new TeamSummaryViewModel(dto); + + expect(vm.id).toBe('team-123'); + expect(vm.name).toBe('Custom Team'); + expect(vm.tag).toBe('CT'); + expect(vm.memberCount).toBe(3); + expect(vm.description).toBe('A test team'); + expect(vm.specialization).toBe('endurance'); + expect(vm.region).toBe('EU'); + expect(vm.languages).toEqual(['en']); + expect(vm.leagues).toEqual(['league-1']); + }); + + it('derives isFull and related displays when team is full', () => { + const dto = createTeamListItemDto({ memberCount: 10 }); + const vm = new TeamSummaryViewModel(dto); + + expect(vm.isFull).toBe(true); + expect(vm.memberCountDisplay).toBe('10/10'); + expect(vm.statusIndicator).toBe('Full'); + expect(vm.statusColor).toBe('red'); + }); + + it('derives isFull and related displays when team is not full', () => { + const dto = createTeamListItemDto({ memberCount: 3 }); + const vm = new TeamSummaryViewModel(dto); + + expect(vm.isFull).toBe(false); + expect(vm.memberCountDisplay).toBe('3/10'); + expect(vm.statusIndicator).toBe('Open'); + expect(vm.statusColor).toBe('green'); + }); + + it('formats tagDisplay with square brackets', () => { + const dto = createTeamListItemDto({ tag: 'TT' }); + const vm = new TeamSummaryViewModel(dto); + + expect(vm.tagDisplay).toBe('[TT]'); + }); +}); diff --git a/apps/website/lib/view-models/UpcomingRaceCardViewModel.test.ts b/apps/website/lib/view-models/UpcomingRaceCardViewModel.test.ts new file mode 100644 index 000000000..e1c1d6f62 --- /dev/null +++ b/apps/website/lib/view-models/UpcomingRaceCardViewModel.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel'; + +describe('UpcomingRaceCardViewModel', () => { + const baseDto = { + id: 'race-1', + track: 'Spa-Francorchamps', + car: 'GT3', + scheduledAt: '2025-01-01T20:00:00Z', + }; + + it('maps DTO fields', () => { + const viewModel = new UpcomingRaceCardViewModel(baseDto); + + expect(viewModel.id).toBe('race-1'); + expect(viewModel.track).toBe('Spa-Francorchamps'); + expect(viewModel.car).toBe('GT3'); + expect(viewModel.scheduledAt).toBe('2025-01-01T20:00:00Z'); + }); + + it('formats date label with month and day', () => { + const viewModel = new UpcomingRaceCardViewModel(baseDto); + + const formatted = viewModel.formattedDate; + + expect(formatted).toMatch(/\d{1,2}/); + }); +}); diff --git a/apps/website/lib/view-models/UpdateAvatarViewModel.test.ts b/apps/website/lib/view-models/UpdateAvatarViewModel.test.ts new file mode 100644 index 000000000..0d3b47f04 --- /dev/null +++ b/apps/website/lib/view-models/UpdateAvatarViewModel.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { UpdateAvatarViewModel } from './UpdateAvatarViewModel'; + +describe('UpdateAvatarViewModel', () => { + it('creates instance with success true and no error', () => { + const dto = { success: true }; + + const viewModel = new UpdateAvatarViewModel(dto); + + expect(viewModel.success).toBe(true); + expect(viewModel.error).toBeUndefined(); + }); + + it('creates instance with success false and error', () => { + const dto = { success: false, error: 'Update failed' }; + + const viewModel = new UpdateAvatarViewModel(dto); + + expect(viewModel.success).toBe(false); + expect(viewModel.error).toBe('Update failed'); + }); + + it('isSuccessful reflects success flag', () => { + const successVm = new UpdateAvatarViewModel({ success: true }); + const failureVm = new UpdateAvatarViewModel({ success: false }); + + expect(successVm.isSuccessful).toBe(true); + expect(failureVm.isSuccessful).toBe(false); + }); + + it('hasError reflects presence of error message', () => { + const noErrorVm = new UpdateAvatarViewModel({ success: true }); + const errorVm = new UpdateAvatarViewModel({ success: false, error: 'Something went wrong' }); + + expect(noErrorVm.hasError).toBe(false); + expect(errorVm.hasError).toBe(true); + }); + + it('treats empty error string as no error', () => { + const viewModel = new UpdateAvatarViewModel({ success: false, error: '' }); + + expect(viewModel.hasError).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/UpdateTeamViewModel.test.ts b/apps/website/lib/view-models/UpdateTeamViewModel.test.ts new file mode 100644 index 000000000..35dc34884 --- /dev/null +++ b/apps/website/lib/view-models/UpdateTeamViewModel.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { UpdateTeamViewModel } from './UpdateTeamViewModel'; + +describe('UpdateTeamViewModel', () => { + it('maps success from DTO', () => { + const dto = { success: true }; + + const vm = new UpdateTeamViewModel(dto); + + expect(vm.success).toBe(true); + }); + + it('returns success successMessage when update succeeded', () => { + const dto = { success: true }; + + const vm = new UpdateTeamViewModel(dto); + + expect(vm.successMessage).toBe('Team updated successfully!'); + }); + + it('returns failure successMessage when update failed', () => { + const dto = { success: false }; + + const vm = new UpdateTeamViewModel(dto); + + expect(vm.successMessage).toBe('Failed to update team.'); + }); +}); diff --git a/apps/website/lib/view-models/UploadMediaViewModel.test.ts b/apps/website/lib/view-models/UploadMediaViewModel.test.ts new file mode 100644 index 000000000..b92e05a02 --- /dev/null +++ b/apps/website/lib/view-models/UploadMediaViewModel.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { UploadMediaViewModel } from './UploadMediaViewModel'; + +describe('UploadMediaViewModel', () => { + it('creates instance with successful upload and media data', () => { + const dto = { + success: true, + mediaId: 'media-123', + url: 'https://example.com/uploaded.jpg', + }; + + const viewModel = new UploadMediaViewModel(dto); + + expect(viewModel.success).toBe(true); + expect(viewModel.mediaId).toBe('media-123'); + expect(viewModel.url).toBe('https://example.com/uploaded.jpg'); + expect(viewModel.error).toBeUndefined(); + }); + + it('creates instance with failed upload and error', () => { + const dto = { + success: false, + error: 'Upload failed', + }; + + const viewModel = new UploadMediaViewModel(dto); + + expect(viewModel.success).toBe(false); + expect(viewModel.mediaId).toBeUndefined(); + expect(viewModel.url).toBeUndefined(); + expect(viewModel.error).toBe('Upload failed'); + }); + + it('isSuccessful reflects success flag', () => { + const successVm = new UploadMediaViewModel({ success: true }); + const failureVm = new UploadMediaViewModel({ success: false }); + + expect(successVm.isSuccessful).toBe(true); + expect(failureVm.isSuccessful).toBe(false); + }); + + it('hasError reflects presence of error message', () => { + const noErrorVm = new UploadMediaViewModel({ success: true }); + const errorVm = new UploadMediaViewModel({ success: false, error: 'Something went wrong' }); + + expect(noErrorVm.hasError).toBe(false); + expect(errorVm.hasError).toBe(true); + }); + + it('treats empty error string as no error', () => { + const viewModel = new UploadMediaViewModel({ success: false, error: '' }); + + expect(viewModel.hasError).toBe(false); + }); +}); diff --git a/apps/website/lib/view-models/UserProfileViewModel.test.ts b/apps/website/lib/view-models/UserProfileViewModel.test.ts new file mode 100644 index 000000000..96e5f675c --- /dev/null +++ b/apps/website/lib/view-models/UserProfileViewModel.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { UserProfileViewModel } from './UserProfileViewModel'; + +describe('UserProfileViewModel', () => { + it('maps required properties from the DTO', () => { + const dto = { + id: 'user-1', + name: 'Roo Racer', + }; + + const viewModel = new UserProfileViewModel(dto); + + expect(viewModel.id).toBe('user-1'); + expect(viewModel.name).toBe('Roo Racer'); + expect(viewModel.avatarUrl).toBeUndefined(); + expect(viewModel.iracingId).toBeUndefined(); + expect(viewModel.rating).toBeUndefined(); + }); + + it('maps optional properties only when provided', () => { + const dto = { + id: 'user-2', + name: 'Rated Driver', + avatarUrl: 'https://example.com/avatar.jpg', + iracingId: '12345', + rating: 2734, + }; + + const viewModel = new UserProfileViewModel(dto); + + expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg'); + expect(viewModel.iracingId).toBe('12345'); + expect(viewModel.rating).toBe(2734); + }); + + it('formats rating as whole-number string when present, or Unrated otherwise', () => { + const unrated = new UserProfileViewModel({ id: 'user-1', name: 'Unrated Driver' }); + const rated = new UserProfileViewModel({ id: 'user-2', name: 'Rated Driver', rating: 2734.56 }); + + expect(unrated.formattedRating).toBe('Unrated'); + expect(rated.formattedRating).toBe('2735'); + }); + + it('indicates whether an iRacing ID is present', () => { + const withoutId = new UserProfileViewModel({ id: 'user-1', name: 'No ID' }); + const withId = new UserProfileViewModel({ id: 'user-2', name: 'Has ID', iracingId: '67890' }); + + expect(withoutId.hasIracingId).toBe(false); + expect(withId.hasIracingId).toBe(true); + }); + + it('computes profile completeness based on available optional fields', () => { + const base = new UserProfileViewModel({ id: 'user-1', name: 'Base User' }); + const withAvatar = new UserProfileViewModel({ id: 'user-2', name: 'Avatar User', avatarUrl: 'url' }); + const withAvatarAndId = new UserProfileViewModel({ + id: 'user-3', + name: 'Avatar + ID', + avatarUrl: 'url', + iracingId: '123', + }); + const withAll = new UserProfileViewModel({ + id: 'user-4', + name: 'Full Profile', + avatarUrl: 'url', + iracingId: '123', + rating: 2000, + }); + + expect(base.profileCompleteness).toBe(25); + expect(withAvatar.profileCompleteness).toBe(50); + expect(withAvatarAndId.profileCompleteness).toBe(75); + expect(withAll.profileCompleteness).toBe(100); + }); + + it('derives avatar initials from the name', () => { + const viewModel = new UserProfileViewModel({ id: 'user-1', name: 'Roo Racer' }); + + expect(viewModel.avatarInitials).toBe('RR'); + }); +}); diff --git a/apps/website/lib/view-models/WalletTransactionViewModel.test.ts b/apps/website/lib/view-models/WalletTransactionViewModel.test.ts new file mode 100644 index 000000000..ae25965d4 --- /dev/null +++ b/apps/website/lib/view-models/WalletTransactionViewModel.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { WalletTransactionViewModel } from './WalletTransactionViewModel'; + +const createTx = (overrides: Partial = {}): any => ({ + id: 'tx-1', + type: 'sponsorship', + description: 'Test', + amount: 100, + fee: 10, + netAmount: 90, + date: new Date('2024-01-01T00:00:00Z'), + status: 'completed', + reference: 'ref-1', + ...overrides, +}); + +describe('WalletTransactionViewModel', () => { + it('maps DTO fields into the view model', () => { + const vm = new WalletTransactionViewModel(createTx()); + + expect(vm.id).toBe('tx-1'); + expect(vm.type).toBe('sponsorship'); + expect(vm.amount).toBe(100); + expect(vm.netAmount).toBe(90); + expect(vm.status).toBe('completed'); + }); + + it('formats amount with sign and currency symbol', () => { + const incoming = new WalletTransactionViewModel(createTx({ amount: 50 })); + const outgoing = new WalletTransactionViewModel(createTx({ amount: -20 })); + + expect(incoming.formattedAmount).toBe('+$50.00'); + expect(outgoing.formattedAmount).toBe('$20.00'); + }); + + it('derives amount color and isIncoming from amount sign', () => { + const incoming = new WalletTransactionViewModel(createTx({ amount: 10 })); + const outgoing = new WalletTransactionViewModel(createTx({ amount: -5 })); + + expect(incoming.amountColor).toBe('green'); + expect(incoming.isIncoming).toBe(true); + expect(outgoing.amountColor).toBe('red'); + expect(outgoing.isIncoming).toBe(false); + }); + + it('derives typeDisplay and formattedDate', () => { + const vm = new WalletTransactionViewModel(createTx({ type: 'membership' as any })); + + expect(vm.typeDisplay).toBe('Membership'); + expect(typeof vm.formattedDate).toBe('string'); + }); +}); diff --git a/apps/website/lib/view-models/WalletViewModel.test.ts b/apps/website/lib/view-models/WalletViewModel.test.ts new file mode 100644 index 000000000..915e0bbe6 --- /dev/null +++ b/apps/website/lib/view-models/WalletViewModel.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { WalletViewModel } from './WalletViewModel'; +import { WalletTransactionViewModel } from './WalletTransactionViewModel'; + +const createWalletDto = (overrides: Partial = {}): any => ({ + id: 'wallet-1', + leagueId: 'league-1', + balance: 100.5, + totalRevenue: 1000, + totalPlatformFees: 50, + totalWithdrawn: 200, + createdAt: '2024-01-01T00:00:00Z', + currency: 'EUR', + transactions: [], + ...overrides, +}); + +const createTransactionDto = (overrides: Partial = {}): any => ({ + id: 'tx-1', + type: 'sponsorship', + description: 'Test', + amount: 10, + fee: 1, + netAmount: 9, + date: new Date('2024-01-01T00:00:00Z'), + status: 'completed', + reference: 'ref', + ...overrides, +}); + +describe('WalletViewModel', () => { + it('maps wallet DTO fields and wraps transactions into view models', () => { + const dto = createWalletDto({ + transactions: [createTransactionDto({ id: 'tx-1' }), createTransactionDto({ id: 'tx-2', amount: -5 })], + }); + + const vm = new WalletViewModel(dto); + + expect(vm.id).toBe('wallet-1'); + expect(vm.leagueId).toBe('league-1'); + expect(vm.balance).toBe(100.5); + expect(vm.transactions).toHaveLength(2); + expect(vm.transactions[0]).toBeInstanceOf(WalletTransactionViewModel); + }); + + it('formats balance with currency and 2 decimals', () => { + const vm = new WalletViewModel(createWalletDto({ balance: 250, currency: 'USD' })); + + expect(vm.formattedBalance).toBe('USD 250.00'); + }); + + it('derives balanceColor based on sign of balance', () => { + const positive = new WalletViewModel(createWalletDto({ balance: 10 })); + const zero = new WalletViewModel(createWalletDto({ balance: 0 })); + const negative = new WalletViewModel(createWalletDto({ balance: -5 })); + + expect(positive.balanceColor).toBe('green'); + expect(zero.balanceColor).toBe('green'); + expect(negative.balanceColor).toBe('red'); + }); + + it('exposes recentTransactions and totalTransactions helpers', () => { + const transactions = [ + createTransactionDto({ id: 'tx-1' }), + createTransactionDto({ id: 'tx-2' }), + createTransactionDto({ id: 'tx-3' }), + createTransactionDto({ id: 'tx-4' }), + createTransactionDto({ id: 'tx-5' }), + createTransactionDto({ id: 'tx-6' }), + ]; + + const vm = new WalletViewModel(createWalletDto({ transactions })); + + expect(vm.totalTransactions).toBe(6); + expect(vm.recentTransactions).toHaveLength(5); + expect(vm.recentTransactions[0]).toBeInstanceOf(WalletTransactionViewModel); + }); +});