view models
This commit is contained in:
80
apps/website/lib/view-models/ActivityItemViewModel.test.ts
Normal file
80
apps/website/lib/view-models/ActivityItemViewModel.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
186
apps/website/lib/view-models/BillingViewModel.test.ts
Normal file
186
apps/website/lib/view-models/BillingViewModel.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
36
apps/website/lib/view-models/CreateLeagueViewModel.test.ts
Normal file
36
apps/website/lib/view-models/CreateLeagueViewModel.test.ts
Normal file
@@ -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> = {}): 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.');
|
||||
});
|
||||
});
|
||||
29
apps/website/lib/view-models/CreateTeamViewModel.test.ts
Normal file
29
apps/website/lib/view-models/CreateTeamViewModel.test.ts
Normal file
@@ -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.');
|
||||
});
|
||||
});
|
||||
146
apps/website/lib/view-models/DashboardOverviewViewModel.test.ts
Normal file
146
apps/website/lib/view-models/DashboardOverviewViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
127
apps/website/lib/view-models/DriverProfileViewModel.test.ts
Normal file
127
apps/website/lib/view-models/DriverProfileViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
45
apps/website/lib/view-models/DriverSummaryViewModel.test.ts
Normal file
45
apps/website/lib/view-models/DriverSummaryViewModel.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
68
apps/website/lib/view-models/DriverTeamViewModel.test.ts
Normal file
68
apps/website/lib/view-models/DriverTeamViewModel.test.ts
Normal file
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
48
apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts
Normal file
48
apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
50
apps/website/lib/view-models/LeagueCardViewModel.test.ts
Normal file
50
apps/website/lib/view-models/LeagueCardViewModel.test.ts
Normal file
@@ -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 }> = {}): 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');
|
||||
});
|
||||
});
|
||||
234
apps/website/lib/view-models/LeagueDetailPageViewModel.test.ts
Normal file
234
apps/website/lib/view-models/LeagueDetailPageViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
97
apps/website/lib/view-models/LeagueDetailViewModel.test.ts
Normal file
97
apps/website/lib/view-models/LeagueDetailViewModel.test.ts
Normal file
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
65
apps/website/lib/view-models/LeagueMemberViewModel.test.ts
Normal file
65
apps/website/lib/view-models/LeagueMemberViewModel.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
29
apps/website/lib/view-models/LeagueScheduleViewModel.test.ts
Normal file
29
apps/website/lib/view-models/LeagueScheduleViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
63
apps/website/lib/view-models/LeagueSettingsViewModel.test.ts
Normal file
63
apps/website/lib/view-models/LeagueSettingsViewModel.test.ts
Normal file
@@ -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> = {}): LeagueConfigFormModel => ({
|
||||
name: 'Pro League',
|
||||
description: 'Top tier competition',
|
||||
maxDrivers: 40,
|
||||
maxTeams: 10,
|
||||
...overrides,
|
||||
} as LeagueConfigFormModel);
|
||||
|
||||
const createPreset = (overrides: Partial<LeagueScoringPresetDTO> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
|
||||
34
apps/website/lib/view-models/LeagueStatsViewModel.test.ts
Normal file
34
apps/website/lib/view-models/LeagueStatsViewModel.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
59
apps/website/lib/view-models/LeagueSummaryViewModel.test.ts
Normal file
59
apps/website/lib/view-models/LeagueSummaryViewModel.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { LeagueSummaryViewModel } from './LeagueSummaryViewModel';
|
||||
|
||||
describe('LeagueSummaryViewModel shape', () => {
|
||||
const createSummary = (overrides: Partial<LeagueSummaryViewModel> = {}): 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');
|
||||
});
|
||||
});
|
||||
87
apps/website/lib/view-models/LeagueWalletViewModel.test.ts
Normal file
87
apps/website/lib/view-models/LeagueWalletViewModel.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueWalletViewModel } from './LeagueWalletViewModel';
|
||||
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
|
||||
|
||||
const createTransaction = (overrides: Partial<WalletTransactionViewModel> = {}): 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]);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
74
apps/website/lib/view-models/MembershipFeeViewModel.test.ts
Normal file
74
apps/website/lib/view-models/MembershipFeeViewModel.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
74
apps/website/lib/view-models/PaymentViewModel.test.ts
Normal file
74
apps/website/lib/view-models/PaymentViewModel.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PaymentViewModel } from './PaymentViewModel';
|
||||
|
||||
const createPaymentDto = (overrides: Partial<any> = {}): 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');
|
||||
});
|
||||
});
|
||||
88
apps/website/lib/view-models/PrizeViewModel.test.ts
Normal file
88
apps/website/lib/view-models/PrizeViewModel.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PrizeViewModel } from './PrizeViewModel';
|
||||
|
||||
const createPrizeDto = (overrides: Partial<any> = {}): 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');
|
||||
});
|
||||
});
|
||||
32
apps/website/lib/view-models/ProtestDriverViewModel.test.ts
Normal file
32
apps/website/lib/view-models/ProtestDriverViewModel.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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('');
|
||||
});
|
||||
});
|
||||
130
apps/website/lib/view-models/RaceResultsDetailViewModel.test.ts
Normal file
130
apps/website/lib/view-models/RaceResultsDetailViewModel.test.ts
Normal file
@@ -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> = {}): 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[] }> = {}): 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);
|
||||
});
|
||||
});
|
||||
28
apps/website/lib/view-models/RaceStatsViewModel.test.ts
Normal file
28
apps/website/lib/view-models/RaceStatsViewModel.test.ts
Normal file
@@ -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> = {}): 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());
|
||||
});
|
||||
});
|
||||
140
apps/website/lib/view-models/RaceStewardingViewModel.test.ts
Normal file
140
apps/website/lib/view-models/RaceStewardingViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
42
apps/website/lib/view-models/RaceViewModel.test.ts
Normal file
42
apps/website/lib/view-models/RaceViewModel.test.ts
Normal file
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
20
apps/website/lib/view-models/RaceWithSOFViewModel.test.ts
Normal file
20
apps/website/lib/view-models/RaceWithSOFViewModel.test.ts
Normal file
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
34
apps/website/lib/view-models/RemoveMemberViewModel.test.ts
Normal file
34
apps/website/lib/view-models/RemoveMemberViewModel.test.ts
Normal file
@@ -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> = {}): 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.');
|
||||
});
|
||||
});
|
||||
62
apps/website/lib/view-models/RenewalAlertViewModel.test.ts
Normal file
62
apps/website/lib/view-models/RenewalAlertViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
64
apps/website/lib/view-models/SessionViewModel.test.ts
Normal file
64
apps/website/lib/view-models/SessionViewModel.test.ts
Normal file
@@ -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>): 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');
|
||||
});
|
||||
});
|
||||
165
apps/website/lib/view-models/SponsorDashboardViewModel.test.ts
Normal file
165
apps/website/lib/view-models/SponsorDashboardViewModel.test.ts
Normal file
@@ -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<any> = {}) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<SponsorshipDetailViewModel> = {}) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
46
apps/website/lib/view-models/SponsorViewModel.test.ts
Normal file
46
apps/website/lib/view-models/SponsorViewModel.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
123
apps/website/lib/view-models/SponsorshipViewModel.test.ts
Normal file
123
apps/website/lib/view-models/SponsorshipViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
44
apps/website/lib/view-models/TeamCardViewModel.test.ts
Normal file
44
apps/website/lib/view-models/TeamCardViewModel.test.ts
Normal file
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
88
apps/website/lib/view-models/TeamDetailsViewModel.test.ts
Normal file
88
apps/website/lib/view-models/TeamDetailsViewModel.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from './TeamJoinRequestViewModel';
|
||||
|
||||
const createTeamJoinRequestDto = (overrides: Partial<TeamJoinRequestDTO> = {}): 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);
|
||||
});
|
||||
});
|
||||
78
apps/website/lib/view-models/TeamMemberViewModel.test.ts
Normal file
78
apps/website/lib/view-models/TeamMemberViewModel.test.ts
Normal file
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
61
apps/website/lib/view-models/TeamSummaryViewModel.test.ts
Normal file
61
apps/website/lib/view-models/TeamSummaryViewModel.test.ts
Normal file
@@ -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> = {}): 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]');
|
||||
});
|
||||
});
|
||||
@@ -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}/);
|
||||
});
|
||||
});
|
||||
44
apps/website/lib/view-models/UpdateAvatarViewModel.test.ts
Normal file
44
apps/website/lib/view-models/UpdateAvatarViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
28
apps/website/lib/view-models/UpdateTeamViewModel.test.ts
Normal file
28
apps/website/lib/view-models/UpdateTeamViewModel.test.ts
Normal file
@@ -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.');
|
||||
});
|
||||
});
|
||||
55
apps/website/lib/view-models/UploadMediaViewModel.test.ts
Normal file
55
apps/website/lib/view-models/UploadMediaViewModel.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
80
apps/website/lib/view-models/UserProfileViewModel.test.ts
Normal file
80
apps/website/lib/view-models/UserProfileViewModel.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
|
||||
|
||||
const createTx = (overrides: Partial<any> = {}): 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');
|
||||
});
|
||||
});
|
||||
78
apps/website/lib/view-models/WalletViewModel.test.ts
Normal file
78
apps/website/lib/view-models/WalletViewModel.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WalletViewModel } from './WalletViewModel';
|
||||
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
|
||||
|
||||
const createWalletDto = (overrides: Partial<any> = {}): 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user