view models

This commit is contained in:
2025-12-18 13:48:35 +01:00
parent cc2553876a
commit 91adbb9c83
71 changed files with 3119 additions and 359 deletions

View File

@@ -8,6 +8,9 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noEmit": false,
"noEmitOnError": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"declaration": true,
"declarationMap": true,
"removeComments": true,

View File

@@ -1,6 +1,6 @@
{
"extends": ["next/core-web-vitals", "plugin:import/recommended", "plugin:import/typescript"],
"plugins": ["boundaries", "import"],
"plugins": ["boundaries", "import", "@typescript-eslint", "unused-imports", "no-unused-vars"],
"settings": {
"import/resolver": {
"typescript": {}
@@ -11,6 +11,17 @@
"@next/next/no-img-element": "warn",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"args": "all",
"argsIgnorePattern": "^$",
"vars": "all",
"varsIgnorePattern": "^$",
"caughtErrors": "all",
"ignoreTypeImports": false
}
],
"boundaries/element-types": [
2,
{
@@ -22,6 +33,16 @@
}
]
}
],
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"error",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}

View File

@@ -1,9 +1,9 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type {
LoginParamsDto,
SignupParamsDto,
SessionDataDto,
} from '../../dtos';
import { AuthSessionDTO } from '../../types/generated/AuthSessionDTO';
// TODO: Create DTOs for login/signup params in apps/website/lib/types/generated
type LoginParamsDto = { email: string; password: string };
type SignupParamsDto = { email: string; password: string; displayName: string };
/**
* Auth API Client
@@ -12,18 +12,18 @@ import type {
*/
export class AuthApiClient extends BaseApiClient {
/** Sign up with email */
signup(params: SignupParamsDto): Promise<SessionDataDto> {
return this.post<SessionDataDto>('/auth/signup', params);
signup(params: SignupParamsDto): Promise<AuthSessionDTO> {
return this.post<AuthSessionDTO>('/auth/signup', params);
}
/** Login with email */
login(params: LoginParamsDto): Promise<SessionDataDto> {
return this.post<SessionDataDto>('/auth/login', params);
login(params: LoginParamsDto): Promise<AuthSessionDTO> {
return this.post<AuthSessionDTO>('/auth/login', params);
}
/** Get current session */
getSession(): Promise<SessionDataDto | null> {
return this.get<SessionDataDto | null>('/auth/session');
getSession(): Promise<AuthSessionDTO | null> {
return this.get<AuthSessionDTO | null>('/auth/session');
}
/** Logout */

View File

@@ -1,10 +1,19 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type {
DriversLeaderboardDto,
DriverRegistrationStatusDto,
} from '../../dtos';
// Import generated types
import type { DriverDTO, CompleteOnboardingInputDTO, CompleteOnboardingOutputDTO } from '../../types/api-helpers';
import type { CompleteOnboardingInputDTO, CompleteOnboardingOutputDTO, DriverRegistrationStatusDTO, DriverLeaderboardItemDTO } from '../../types/generated';
// TODO: Create proper DriverDTO in generated types
type DriverDTO = {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
};
type DriversLeaderboardDto = {
drivers: DriverLeaderboardItemDTO[];
};
/**
* Drivers API Client
@@ -19,16 +28,16 @@ export class DriversApiClient extends BaseApiClient {
/** Complete driver onboarding */
completeOnboarding(input: CompleteOnboardingInputDTO): Promise<CompleteOnboardingOutputDTO> {
return this.post<CompleteOnboardingOutputDto>('/drivers/complete-onboarding', input);
return this.post<CompleteOnboardingOutputDTO>('/drivers/complete-onboarding', input);
}
/** Get current driver (based on session) */
getCurrent(): Promise<DriverDTO | null> {
return this.get<DriverDto | null>('/drivers/current');
return this.get<DriverDTO | null>('/drivers/current');
}
/** Get driver registration status for a specific race */
getRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusDto> {
return this.get<DriverRegistrationStatusDto>(`/drivers/${driverId}/races/${raceId}/registration-status`);
getRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusDTO> {
return this.get<DriverRegistrationStatusDTO>(`/drivers/${driverId}/races/${raceId}/registration-status`);
}
}

View File

@@ -1,17 +1,45 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type {
GetPaymentsOutputDto,
CreatePaymentInputDto,
CreatePaymentOutputDto,
GetMembershipFeesOutputDto,
GetPrizesOutputDto,
GetWalletOutputDto,
ProcessWalletTransactionInputDto,
ProcessWalletTransactionOutputDto,
UpdateMemberPaymentInputDto,
UpdateMemberPaymentOutputDto,
GetWalletTransactionsOutputDto,
} from '../../dtos';
// TODO: Import these types from apps/website/lib/types/generated when available
type GetPaymentsOutputDto = { payments: import('../types/generated').PaymentDto[] };
type CreatePaymentInputDto = {
type: 'sponsorship' | 'membership_fee';
amount: number;
payerId: string;
payerType: 'sponsor' | 'driver';
leagueId: string;
seasonId?: string;
};
type CreatePaymentOutputDto = { payment: import('../types/generated').PaymentDto };
type GetMembershipFeesOutputDto = {
fee: import('../types/generated').MembershipFeeDto | null;
payments: import('../types/generated').MemberPaymentDto[]
};
type GetPrizesOutputDto = { prizes: import('../types/generated').PrizeDto[] };
type GetWalletOutputDto = {
wallet: import('../types/generated').WalletDto;
transactions: import('../types/generated').TransactionDto[]
};
type ProcessWalletTransactionInputDto = {
leagueId: string;
type: 'deposit' | 'withdrawal' | 'platform_fee';
amount: number;
description: string;
referenceId?: string;
referenceType?: 'sponsorship' | 'membership_fee' | 'prize';
};
type ProcessWalletTransactionOutputDto = {
wallet: import('../types/generated').WalletDto;
transaction: import('../types/generated').TransactionDto
};
type UpdateMemberPaymentInputDto = {
feeId: string;
driverId: string;
status?: 'pending' | 'paid' | 'overdue';
paidAt?: Date | string;
};
type UpdateMemberPaymentOutputDto = { payment: import('../types/generated').MemberPaymentDto };
type GetWalletTransactionsOutputDto = { transactions: import('../types/generated').TransactionDto[] };
/**
* Payments API Client

View File

@@ -1,12 +1,13 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type {
GetEntitySponsorshipPricingResultDto,
GetSponsorsOutputDto,
CreateSponsorInputDto,
CreateSponsorOutputDto,
SponsorDashboardDto,
SponsorSponsorshipsDto,
} from '../../dtos';
import type { CreateSponsorInputDTO } from '../../types/generated/CreateSponsorInputDTO';
import type { SponsorDashboardDTO } from '../../types/generated/SponsorDashboardDTO';
import type { SponsorSponsorshipsDTO } from '../../types/generated/SponsorSponsorshipsDTO';
// TODO: Move these types to apps/website/lib/types/generated when available
export type CreateSponsorOutputDto = { id: string; name: string };
export type GetEntitySponsorshipPricingResultDto = { pricing: Array<{ entityType: string; price: number }> };
export type SponsorDTO = { id: string; name: string; logoUrl?: string; websiteUrl?: string };
export type GetSponsorsOutputDto = { sponsors: SponsorDTO[] };
/**
* Sponsors API Client
@@ -25,17 +26,17 @@ export class SponsorsApiClient extends BaseApiClient {
}
/** Create a new sponsor */
create(input: CreateSponsorInputDto): Promise<CreateSponsorOutputDto> {
create(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDto> {
return this.post<CreateSponsorOutputDto>('/sponsors', input);
}
/** Get sponsor dashboard */
getDashboard(sponsorId: string): Promise<SponsorDashboardDto | null> {
return this.get<SponsorDashboardDto | null>(`/sponsors/dashboard/${sponsorId}`);
getDashboard(sponsorId: string): Promise<SponsorDashboardDTO | null> {
return this.get<SponsorDashboardDTO | null>(`/sponsors/dashboard/${sponsorId}`);
}
/** Get sponsor sponsorships */
getSponsorships(sponsorId: string): Promise<SponsorSponsorshipsDto | null> {
return this.get<SponsorSponsorshipsDto | null>(`/sponsors/${sponsorId}/sponsorships`);
getSponsorships(sponsorId: string): Promise<SponsorSponsorshipsDTO | null> {
return this.get<SponsorSponsorshipsDTO | null>(`/sponsors/${sponsorId}/sponsorships`);
}
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AnalyticsService } from './AnalyticsService';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { RecordPageViewInputViewModel } from '../../view-models/RecordPageViewInputViewModel';
import { RecordEngagementInputViewModel } from '../../view-models/RecordEngagementInputViewModel';
import { RecordPageViewOutputViewModel } from '../../view-models/RecordPageViewOutputViewModel';
import { RecordEngagementOutputViewModel } from '../../view-models/RecordEngagementOutputViewModel';
describe('AnalyticsService', () => {
let mockApiClient: Mocked<AnalyticsApiClient>;
@@ -19,10 +19,10 @@ describe('AnalyticsService', () => {
describe('recordPageView', () => {
it('should call apiClient.recordPageView with correct input', async () => {
const input = new RecordPageViewInputViewModel({
const input = {
path: '/dashboard',
userId: 'user-123',
});
};
const expectedOutput = { pageViewId: 'pv-123' };
mockApiClient.recordPageView.mockResolvedValue(expectedOutput);
@@ -33,13 +33,14 @@ describe('AnalyticsService', () => {
path: '/dashboard',
userId: 'user-123',
});
expect(result).toEqual(expectedOutput);
expect(result).toBeInstanceOf(RecordPageViewOutputViewModel);
expect(result.pageViewId).toEqual('pv-123');
});
it('should call apiClient.recordPageView without userId when not provided', async () => {
const input = new RecordPageViewInputViewModel({
const input = {
path: '/home',
});
};
const expectedOutput = { pageViewId: 'pv-456' };
mockApiClient.recordPageView.mockResolvedValue(expectedOutput);
@@ -49,17 +50,18 @@ describe('AnalyticsService', () => {
expect(mockApiClient.recordPageView).toHaveBeenCalledWith({
path: '/home',
});
expect(result).toEqual(expectedOutput);
expect(result).toBeInstanceOf(RecordPageViewOutputViewModel);
expect(result.pageViewId).toEqual('pv-456');
});
});
describe('recordEngagement', () => {
it('should call apiClient.recordEngagement with correct input', async () => {
const input = new RecordEngagementInputViewModel({
const input = {
eventType: 'button_click',
userId: 'user-123',
metadata: { buttonId: 'submit', page: '/form' },
});
};
const expectedOutput = { eventId: 'event-123', engagementWeight: 1.5 };
mockApiClient.recordEngagement.mockResolvedValue(expectedOutput);
@@ -71,13 +73,15 @@ describe('AnalyticsService', () => {
userId: 'user-123',
metadata: { buttonId: 'submit', page: '/form' },
});
expect(result).toEqual(expectedOutput);
expect(result).toBeInstanceOf(RecordEngagementOutputViewModel);
expect(result.eventId).toEqual('event-123');
expect(result.engagementWeight).toEqual(1.5);
});
it('should call apiClient.recordEngagement without optional fields', async () => {
const input = new RecordEngagementInputViewModel({
const input = {
eventType: 'page_load',
});
};
const expectedOutput = { eventId: 'event-456', engagementWeight: 0.5 };
mockApiClient.recordEngagement.mockResolvedValue(expectedOutput);
@@ -87,7 +91,9 @@ describe('AnalyticsService', () => {
expect(mockApiClient.recordEngagement).toHaveBeenCalledWith({
eventType: 'page_load',
});
expect(result).toEqual(expectedOutput);
expect(result).toBeInstanceOf(RecordEngagementOutputViewModel);
expect(result.eventId).toEqual('event-456');
expect(result.engagementWeight).toEqual(0.5);
});
});
});

View File

@@ -1,7 +1,18 @@
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { RecordPageViewOutputDTO, RecordEngagementOutputDTO } from '../../types/generated';
import { RecordPageViewInputViewModel } from '../../view-models/RecordPageViewInputViewModel';
import { RecordEngagementInputViewModel } from '../../view-models/RecordEngagementInputViewModel';
import { RecordPageViewOutputViewModel } from '../../view-models/RecordPageViewOutputViewModel';
import { RecordEngagementOutputViewModel } from '../../view-models/RecordEngagementOutputViewModel';
// TODO: Create proper DTOs in generated types
interface RecordPageViewInputDTO {
path: string;
userId?: string;
}
interface RecordEngagementInputDTO {
eventType: string;
userId?: string;
metadata?: Record<string, unknown>;
}
/**
* Analytics Service
@@ -15,31 +26,18 @@ export class AnalyticsService {
) {}
/**
* Record a page view
*/
async recordPageView(input: RecordPageViewInputViewModel): Promise<RecordPageViewOutputDTO> {
const apiInput: { path: string; userId?: string } = {
path: input.path,
};
if (input.userId) {
apiInput.userId = input.userId;
}
return await this.apiClient.recordPageView(apiInput);
}
* Record a page view
*/
async recordPageView(input: RecordPageViewInputDTO): Promise<RecordPageViewOutputViewModel> {
const result = await this.apiClient.recordPageView(input);
return new RecordPageViewOutputViewModel(result);
}
/**
* Record an engagement event
*/
async recordEngagement(input: RecordEngagementInputViewModel): Promise<RecordEngagementOutputDTO> {
const apiInput: { eventType: string; userId?: string; metadata?: Record<string, unknown> } = {
eventType: input.eventType,
};
if (input.userId) {
apiInput.userId = input.userId;
}
if (input.metadata) {
apiInput.metadata = input.metadata;
}
return await this.apiClient.recordEngagement(apiInput);
}
* Record an engagement event
*/
async recordEngagement(input: RecordEngagementInputDTO): Promise<RecordEngagementOutputViewModel> {
const result = await this.apiClient.recordEngagement(input);
return new RecordEngagementOutputViewModel(result);
}
}

View File

@@ -60,21 +60,4 @@ describe('DashboardService', () => {
});
});
describe('getDashboardOverview', () => {
it('should call getDashboardData and return the result', async () => {
const dto = {
totalUsers: 200,
activeUsers: 100,
totalRaces: 40,
totalLeagues: 10,
};
mockApiClient.getDashboardData.mockResolvedValue(dto);
const result = await service.getDashboardOverview();
expect(mockApiClient.getDashboardData).toHaveBeenCalled();
expect(result).toBeInstanceOf(AnalyticsDashboardViewModel);
expect(result.totalUsers).toBe(200);
});
});
});

View File

@@ -1,5 +1,6 @@
import { AnalyticsDashboardViewModel } from '@/lib/view-models/AnalyticsDashboardViewModel';
import { AnalyticsMetricsViewModel } from '@/lib/view-models/AnalyticsMetricsViewModel';
import { AnalyticsApiClient } from '../../api/analytics/AnalyticsApiClient';
import { AnalyticsDashboardViewModel, AnalyticsMetricsViewModel } from '../../view-models';
/**
* Dashboard Service

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AuthService } from './AuthService';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
describe('AuthService', () => {
let mockApiClient: Mocked<AuthApiClient>;
let service: AuthService;
beforeEach(() => {
mockApiClient = {
signup: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
getIracingAuthUrl: vi.fn(),
} as Mocked<AuthApiClient>;
service = new AuthService(mockApiClient);
});
describe('signup', () => {
it('should call apiClient.signup and return SessionViewModel', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.signup.mockResolvedValue(mockResponse);
const result = await service.signup(params);
expect(mockApiClient.signup).toHaveBeenCalledWith(params);
expect(result).toBeInstanceOf(SessionViewModel);
expect(result.userId).toBe('user-123');
expect(result.email).toBe('test@example.com');
expect(result.displayName).toBe('Test User');
expect(result.isAuthenticated).toBe(true);
});
it('should throw error when apiClient.signup fails', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
displayName: 'Test User',
};
const error = new Error('Signup failed');
mockApiClient.signup.mockRejectedValue(error);
await expect(service.signup(params)).rejects.toThrow('Signup failed');
});
});
describe('login', () => {
it('should call apiClient.login and return SessionViewModel', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
};
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.login.mockResolvedValue(mockResponse);
const result = await service.login(params);
expect(mockApiClient.login).toHaveBeenCalledWith(params);
expect(result).toBeInstanceOf(SessionViewModel);
expect(result.userId).toBe('user-123');
expect(result.email).toBe('test@example.com');
expect(result.displayName).toBe('Test User');
expect(result.isAuthenticated).toBe(true);
});
it('should throw error when apiClient.login fails', async () => {
const params = {
email: 'test@example.com',
password: 'password123',
};
const error = new Error('Login failed');
mockApiClient.login.mockRejectedValue(error);
await expect(service.login(params)).rejects.toThrow('Login failed');
});
});
describe('logout', () => {
it('should call apiClient.logout', async () => {
mockApiClient.logout.mockResolvedValue(undefined);
await service.logout();
expect(mockApiClient.logout).toHaveBeenCalled();
});
it('should throw error when apiClient.logout fails', async () => {
const error = new Error('Logout failed');
mockApiClient.logout.mockRejectedValue(error);
await expect(service.logout()).rejects.toThrow('Logout failed');
});
});
describe('getIracingAuthUrl', () => {
it('should call apiClient.getIracingAuthUrl with returnTo', () => {
const returnTo = '/dashboard';
const expectedUrl = 'https://api.example.com/auth/iracing/start?returnTo=%2Fdashboard';
mockApiClient.getIracingAuthUrl.mockReturnValue(expectedUrl);
const result = service.getIracingAuthUrl(returnTo);
expect(mockApiClient.getIracingAuthUrl).toHaveBeenCalledWith(returnTo);
expect(result).toBe(expectedUrl);
});
it('should call apiClient.getIracingAuthUrl without returnTo', () => {
const expectedUrl = 'https://api.example.com/auth/iracing/start';
mockApiClient.getIracingAuthUrl.mockReturnValue(expectedUrl);
const result = service.getIracingAuthUrl();
expect(mockApiClient.getIracingAuthUrl).toHaveBeenCalledWith(undefined);
expect(result).toBe(expectedUrl);
});
});
});

View File

@@ -1,9 +1,9 @@
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
// TODO: Move these types to apps/website/lib/types/generated when available
// TODO: Create DTOs for login/signup params in apps/website/lib/types/generated
type LoginParamsDto = { email: string; password: string };
type SignupParamsDto = { email: string; password: string; displayName: string };
type SessionDataDto = { userId: string; email: string; displayName: string; token: string };
/**
* Auth Service
@@ -19,9 +19,10 @@ export class AuthService {
/**
* Sign up a new user
*/
async signup(params: SignupParamsDto): Promise<SessionDataDto> {
async signup(params: SignupParamsDto): Promise<SessionViewModel> {
try {
return await this.apiClient.signup(params);
const dto = await this.apiClient.signup(params);
return new SessionViewModel(dto.user);
} catch (error) {
throw error;
}
@@ -30,9 +31,10 @@ export class AuthService {
/**
* Log in an existing user
*/
async login(params: LoginParamsDto): Promise<SessionDataDto> {
async login(params: LoginParamsDto): Promise<SessionViewModel> {
try {
return await this.apiClient.login(params);
const dto = await this.apiClient.login(params);
return new SessionViewModel(dto.user);
} catch (error) {
throw error;
}

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { SessionService } from './SessionService';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
describe('SessionService', () => {
let mockApiClient: Mocked<AuthApiClient>;
let service: SessionService;
beforeEach(() => {
mockApiClient = {
getSession: vi.fn(),
} as Mocked<AuthApiClient>;
service = new SessionService(mockApiClient);
});
describe('getSession', () => {
it('should call apiClient.getSession and return SessionViewModel when session exists', async () => {
const mockResponse = {
token: 'jwt-token',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
},
};
mockApiClient.getSession.mockResolvedValue(mockResponse);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result).toBeInstanceOf(SessionViewModel);
expect(result?.userId).toBe('user-123');
expect(result?.email).toBe('test@example.com');
expect(result?.displayName).toBe('Test User');
expect(result?.isAuthenticated).toBe(true);
});
it('should return null when apiClient.getSession returns null', async () => {
mockApiClient.getSession.mockResolvedValue(null);
const result = await service.getSession();
expect(mockApiClient.getSession).toHaveBeenCalled();
expect(result).toBeNull();
});
it('should throw error when apiClient.getSession fails', async () => {
const error = new Error('Get session failed');
mockApiClient.getSession.mockRejectedValue(error);
await expect(service.getSession()).rejects.toThrow('Get session failed');
});
});
});

View File

@@ -1,5 +1,5 @@
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models';
/**
* Session Service
@@ -17,6 +17,6 @@ export class SessionService {
*/
async getSession(): Promise<SessionViewModel | null> {
const dto = await this.apiClient.getSession();
return dto ? new SessionViewModel(dto) : null;
return dto ? new SessionViewModel(dto.user) : null;
}
}

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { DriverRegistrationService } from './DriverRegistrationService';
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
import { DriverRegistrationStatusViewModel } from '../../view-models/DriverRegistrationStatusViewModel';
describe('DriverRegistrationService', () => {
let mockApiClient: Mocked<DriversApiClient>;
let service: DriverRegistrationService;
beforeEach(() => {
mockApiClient = {
getRegistrationStatus: vi.fn(),
} as Mocked<DriversApiClient>;
service = new DriverRegistrationService(mockApiClient);
});
describe('getDriverRegistrationStatus', () => {
it('should call apiClient.getRegistrationStatus and return DriverRegistrationStatusViewModel', async () => {
const driverId = 'driver-123';
const raceId = 'race-456';
const mockDto = {
isRegistered: true,
raceId: 'race-456',
driverId: 'driver-123',
};
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
const result = await service.getDriverRegistrationStatus(driverId, raceId);
expect(mockApiClient.getRegistrationStatus).toHaveBeenCalledWith(driverId, raceId);
expect(result).toBeInstanceOf(DriverRegistrationStatusViewModel);
expect(result.isRegistered).toBe(true);
expect(result.raceId).toBe('race-456');
expect(result.driverId).toBe('driver-123');
expect(result.statusMessage).toBe('Registered for this race');
expect(result.statusColor).toBe('green');
expect(result.statusBadgeVariant).toBe('success');
expect(result.registrationButtonText).toBe('Withdraw');
expect(result.canRegister).toBe(false);
});
it('should handle unregistered driver', async () => {
const driverId = 'driver-123';
const raceId = 'race-456';
const mockDto = {
isRegistered: false,
raceId: 'race-456',
driverId: 'driver-123',
};
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
const result = await service.getDriverRegistrationStatus(driverId, raceId);
expect(result.isRegistered).toBe(false);
expect(result.statusMessage).toBe('Not registered');
expect(result.statusColor).toBe('red');
expect(result.statusBadgeVariant).toBe('warning');
expect(result.registrationButtonText).toBe('Register');
expect(result.canRegister).toBe(true);
});
it('should throw error when apiClient.getRegistrationStatus fails', async () => {
const driverId = 'driver-123';
const raceId = 'race-456';
const error = new Error('API call failed');
mockApiClient.getRegistrationStatus.mockRejectedValue(error);
await expect(service.getDriverRegistrationStatus(driverId, raceId)).rejects.toThrow('API call failed');
});
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { DriverService } from './DriverService';
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
import { DriverLeaderboardViewModel } from '../../view-models/DriverLeaderboardViewModel';
import { DriverViewModel } from '../../view-models/DriverViewModel';
import { CompleteOnboardingViewModel } from '../../view-models/CompleteOnboardingViewModel';
describe('DriverService', () => {
let mockApiClient: Mocked<DriversApiClient>;
let service: DriverService;
beforeEach(() => {
mockApiClient = {
getLeaderboard: vi.fn(),
completeOnboarding: vi.fn(),
getCurrent: vi.fn(),
} as Mocked<DriversApiClient>;
service = new DriverService(mockApiClient);
});
describe('getDriverLeaderboard', () => {
it('should call apiClient.getLeaderboard and return DriverLeaderboardViewModel', async () => {
const mockDto = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 2500,
wins: 10,
podiums: 15,
totalRaces: 50,
country: 'US',
avatarUrl: 'https://example.com/avatar.jpg',
},
],
};
mockApiClient.getLeaderboard.mockResolvedValue(mockDto);
const result = await service.getDriverLeaderboard();
expect(mockApiClient.getLeaderboard).toHaveBeenCalled();
expect(result).toBeInstanceOf(DriverLeaderboardViewModel);
expect(result.drivers).toHaveLength(1);
expect(result.drivers[0].name).toBe('John Doe');
});
it('should throw error when apiClient.getLeaderboard fails', async () => {
const error = new Error('API call failed');
mockApiClient.getLeaderboard.mockRejectedValue(error);
await expect(service.getDriverLeaderboard()).rejects.toThrow('API call failed');
});
});
describe('completeDriverOnboarding', () => {
it('should call apiClient.completeOnboarding and return CompleteOnboardingViewModel', async () => {
const input = {
iracingId: '123456',
country: 'US',
};
const mockDto = {
success: true,
driverId: 'driver-123',
};
mockApiClient.completeOnboarding.mockResolvedValue(mockDto);
const result = await service.completeDriverOnboarding(input);
expect(mockApiClient.completeOnboarding).toHaveBeenCalledWith(input);
expect(result).toBeInstanceOf(CompleteOnboardingViewModel);
expect(result.success).toBe(true);
expect(result.isSuccessful).toBe(true);
});
it('should throw error when apiClient.completeOnboarding fails', async () => {
const input = {
iracingId: '123456',
country: 'US',
};
const error = new Error('API call failed');
mockApiClient.completeOnboarding.mockRejectedValue(error);
await expect(service.completeDriverOnboarding(input)).rejects.toThrow('API call failed');
});
});
describe('getCurrentDriver', () => {
it('should call apiClient.getCurrent and return DriverViewModel when driver exists', async () => {
const mockDto = {
id: 'driver-123',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: '123456',
rating: 2500,
};
mockApiClient.getCurrent.mockResolvedValue(mockDto);
const result = await service.getCurrentDriver();
expect(mockApiClient.getCurrent).toHaveBeenCalled();
expect(result).toBeInstanceOf(DriverViewModel);
expect(result?.id).toBe('driver-123');
expect(result?.name).toBe('John Doe');
expect(result?.hasIracingId).toBe(true);
expect(result?.formattedRating).toBe('2500');
});
it('should return null when apiClient.getCurrent returns null', async () => {
mockApiClient.getCurrent.mockResolvedValue(null);
const result = await service.getCurrentDriver();
expect(mockApiClient.getCurrent).toHaveBeenCalled();
expect(result).toBeNull();
});
it('should throw error when apiClient.getCurrent fails', async () => {
const error = new Error('API call failed');
mockApiClient.getCurrent.mockRejectedValue(error);
await expect(service.getCurrentDriver()).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,8 +1,17 @@
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
import { DriverLeaderboardViewModel } from '../../view-models';
import { DriverViewModel } from '../../view-models/DriverViewModel';
import { CompleteOnboardingViewModel } from '../../view-models/CompleteOnboardingViewModel';
import type { CompleteOnboardingInputDTO } from '../../types/generated';
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { CompleteOnboardingInputDTO } from "@/lib/types/generated/CompleteOnboardingInputDTO";
import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel";
import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
// TODO: Create proper DriverDTO in generated types
type DriverDTO = {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
};
/**
* Driver Service

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { LeagueMembershipService } from './LeagueMembershipService';
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import { LeagueMemberViewModel } from '../../view-models';
import type { LeagueMemberDTO } from '../../types/generated';
describe('LeagueMembershipService', () => {
let mockApiClient: Mocked<LeaguesApiClient>;
let service: LeagueMembershipService;
beforeEach(() => {
mockApiClient = {
getMemberships: vi.fn(),
removeMember: vi.fn(),
} as Mocked<LeaguesApiClient>;
service = new LeagueMembershipService(mockApiClient);
});
describe('getLeagueMemberships', () => {
it('should call apiClient.getMemberships and return array of LeagueMemberViewModel', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto = {
members: [
{ driverId: 'driver-1' },
{ driverId: 'driver-2' },
] as LeagueMemberDTO[],
};
mockApiClient.getMemberships.mockResolvedValue(mockDto);
const result = await service.getLeagueMemberships(leagueId, currentUserId);
expect(mockApiClient.getMemberships).toHaveBeenCalledWith(leagueId);
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(LeagueMemberViewModel);
expect(result[0].driverId).toBe('driver-1');
expect(result[0].currentUserId).toBe(currentUserId);
expect(result[1]).toBeInstanceOf(LeagueMemberViewModel);
expect(result[1].driverId).toBe('driver-2');
expect(result[1].currentUserId).toBe(currentUserId);
});
it('should handle empty members array', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto = {
members: [] as LeagueMemberDTO[],
};
mockApiClient.getMemberships.mockResolvedValue(mockDto);
const result = await service.getLeagueMemberships(leagueId, currentUserId);
expect(result).toHaveLength(0);
});
it('should throw error when apiClient.getMemberships fails', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const error = new Error('API call failed');
mockApiClient.getMemberships.mockRejectedValue(error);
await expect(service.getLeagueMemberships(leagueId, currentUserId)).rejects.toThrow('API call failed');
});
});
describe('removeMember', () => {
it('should call apiClient.removeMember and return the result', async () => {
const leagueId = 'league-123';
const performerDriverId = 'performer-456';
const targetDriverId = 'target-789';
const mockResult = { success: true };
mockApiClient.removeMember.mockResolvedValue(mockResult);
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
expect(result).toEqual(mockResult);
});
it('should handle unsuccessful removal', async () => {
const leagueId = 'league-123';
const performerDriverId = 'performer-456';
const targetDriverId = 'target-789';
const mockResult = { success: false };
mockApiClient.removeMember.mockResolvedValue(mockResult);
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
expect(result.success).toBe(false);
});
it('should throw error when apiClient.removeMember fails', async () => {
const leagueId = 'league-123';
const performerDriverId = 'performer-456';
const targetDriverId = 'target-789';
const error = new Error('API call failed');
mockApiClient.removeMember.mockRejectedValue(error);
await expect(service.removeMember(leagueId, performerDriverId, targetDriverId)).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,6 +1,11 @@
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import { LeagueMemberViewModel } from '../../view-models';
import type { LeagueMemberDTO } from '../../types/generated';
// TODO: Move to generated types when available
type LeagueMembershipsDTO = {
members: LeagueMemberDTO[];
};
/**
* League Membership Service
@@ -17,7 +22,7 @@ export class LeagueMembershipService {
* Get league memberships with view model transformation
*/
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
const dto = await this.apiClient.getMemberships(leagueId);
const dto: LeagueMembershipsDTO = await this.apiClient.getMemberships(leagueId);
return dto.members.map((member: LeagueMemberDTO) => new LeagueMemberViewModel(member, currentUserId));
}

View File

@@ -0,0 +1,299 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { LeagueService } from './LeagueService';
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import { LeagueSummaryViewModel, LeagueStandingsViewModel, LeagueStatsViewModel, LeagueScheduleViewModel, LeagueMembershipsViewModel, CreateLeagueViewModel, RemoveMemberViewModel, LeagueMemberViewModel } from '../../view-models';
import type { LeagueWithCapacityDTO, CreateLeagueInputDTO, CreateLeagueOutputDTO, RemoveLeagueMemberOutputDTO, LeagueMemberDTO } from '../../types/generated';
describe('LeagueService', () => {
let mockApiClient: Mocked<LeaguesApiClient>;
let service: LeagueService;
beforeEach(() => {
mockApiClient = {
getAllWithCapacity: vi.fn(),
getStandings: vi.fn(),
getTotal: vi.fn(),
getSchedule: vi.fn(),
getMemberships: vi.fn(),
create: vi.fn(),
removeMember: vi.fn(),
} as Mocked<LeaguesApiClient>;
service = new LeagueService(mockApiClient);
});
describe('getAllLeagues', () => {
it('should call apiClient.getAllWithCapacity and return array of LeagueSummaryViewModel', async () => {
const mockDto = {
leagues: [
{ id: 'league-1', name: 'League One' },
{ id: 'league-2', name: 'League Two' },
] as LeagueWithCapacityDTO[],
};
mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto);
const result = await service.getAllLeagues();
expect(mockApiClient.getAllWithCapacity).toHaveBeenCalled();
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(LeagueSummaryViewModel);
expect(result[0].id).toBe('league-1');
expect(result[1]).toBeInstanceOf(LeagueSummaryViewModel);
expect(result[1].id).toBe('league-2');
});
it('should handle empty leagues array', async () => {
const mockDto = {
leagues: [] as LeagueWithCapacityDTO[],
};
mockApiClient.getAllWithCapacity.mockResolvedValue(mockDto);
const result = await service.getAllLeagues();
expect(result).toHaveLength(0);
});
it('should throw error when apiClient.getAllWithCapacity fails', async () => {
const error = new Error('API call failed');
mockApiClient.getAllWithCapacity.mockRejectedValue(error);
await expect(service.getAllLeagues()).rejects.toThrow('API call failed');
});
});
describe('getLeagueStandings', () => {
it('should call apiClient.getStandings and return LeagueStandingsViewModel', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto = {
id: leagueId,
name: 'Test League',
};
mockApiClient.getStandings.mockResolvedValue(mockDto);
const result = await service.getLeagueStandings(leagueId, currentUserId);
expect(mockApiClient.getStandings).toHaveBeenCalledWith(leagueId);
expect(result).toBeInstanceOf(LeagueStandingsViewModel);
});
it('should throw error when apiClient.getStandings fails', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const error = new Error('API call failed');
mockApiClient.getStandings.mockRejectedValue(error);
await expect(service.getLeagueStandings(leagueId, currentUserId)).rejects.toThrow('API call failed');
});
});
describe('getLeagueStats', () => {
it('should call apiClient.getTotal and return LeagueStatsViewModel', async () => {
const mockDto = { totalLeagues: 42 };
mockApiClient.getTotal.mockResolvedValue(mockDto);
const result = await service.getLeagueStats();
expect(mockApiClient.getTotal).toHaveBeenCalled();
expect(result).toBeInstanceOf(LeagueStatsViewModel);
expect(result.totalLeagues).toBe(42);
});
it('should throw error when apiClient.getTotal fails', async () => {
const error = new Error('API call failed');
mockApiClient.getTotal.mockRejectedValue(error);
await expect(service.getLeagueStats()).rejects.toThrow('API call failed');
});
});
describe('getLeagueSchedule', () => {
it('should call apiClient.getSchedule and return LeagueScheduleViewModel', async () => {
const leagueId = 'league-123';
const mockDto = { races: [{ id: 'race-1' }, { id: 'race-2' }] };
mockApiClient.getSchedule.mockResolvedValue(mockDto);
const result = await service.getLeagueSchedule(leagueId);
expect(mockApiClient.getSchedule).toHaveBeenCalledWith(leagueId);
expect(result).toBeInstanceOf(LeagueScheduleViewModel);
expect(result.races).toEqual(mockDto.races);
});
it('should handle empty races array', async () => {
const leagueId = 'league-123';
const mockDto = { races: [] };
mockApiClient.getSchedule.mockResolvedValue(mockDto);
const result = await service.getLeagueSchedule(leagueId);
expect(result.races).toEqual([]);
expect(result.hasRaces).toBe(false);
});
it('should throw error when apiClient.getSchedule fails', async () => {
const leagueId = 'league-123';
const error = new Error('API call failed');
mockApiClient.getSchedule.mockRejectedValue(error);
await expect(service.getLeagueSchedule(leagueId)).rejects.toThrow('API call failed');
});
});
describe('getLeagueMemberships', () => {
it('should call apiClient.getMemberships and return LeagueMembershipsViewModel', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto = {
memberships: [
{ driverId: 'driver-1' },
{ driverId: 'driver-2' },
] as LeagueMemberDTO[],
};
mockApiClient.getMemberships.mockResolvedValue(mockDto);
const result = await service.getLeagueMemberships(leagueId, currentUserId);
expect(mockApiClient.getMemberships).toHaveBeenCalledWith(leagueId);
expect(result).toBeInstanceOf(LeagueMembershipsViewModel);
expect(result.memberships).toHaveLength(2);
expect(result.memberships[0]).toBeInstanceOf(LeagueMemberViewModel);
expect(result.memberships[0].driverId).toBe('driver-1');
});
it('should handle empty memberships array', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const mockDto = {
memberships: [] as LeagueMemberDTO[],
};
mockApiClient.getMemberships.mockResolvedValue(mockDto);
const result = await service.getLeagueMemberships(leagueId, currentUserId);
expect(result.memberships).toHaveLength(0);
expect(result.hasMembers).toBe(false);
});
it('should throw error when apiClient.getMemberships fails', async () => {
const leagueId = 'league-123';
const currentUserId = 'user-456';
const error = new Error('API call failed');
mockApiClient.getMemberships.mockRejectedValue(error);
await expect(service.getLeagueMemberships(leagueId, currentUserId)).rejects.toThrow('API call failed');
});
});
describe('createLeague', () => {
it('should call apiClient.create and return CreateLeagueViewModel', async () => {
const input: CreateLeagueInputDTO = {
name: 'New League',
description: 'A new league',
};
const mockDto: CreateLeagueOutputDTO = {
leagueId: 'new-league-id',
success: true,
};
mockApiClient.create.mockResolvedValue(mockDto);
const result = await service.createLeague(input);
expect(mockApiClient.create).toHaveBeenCalledWith(input);
expect(result).toBeInstanceOf(CreateLeagueViewModel);
expect(result.leagueId).toBe('new-league-id');
expect(result.success).toBe(true);
});
it('should handle unsuccessful creation', async () => {
const input: CreateLeagueInputDTO = {
name: 'New League',
description: 'A new league',
};
const mockDto: CreateLeagueOutputDTO = {
leagueId: '',
success: false,
};
mockApiClient.create.mockResolvedValue(mockDto);
const result = await service.createLeague(input);
expect(result.success).toBe(false);
expect(result.successMessage).toBe('Failed to create league.');
});
it('should throw error when apiClient.create fails', async () => {
const input: CreateLeagueInputDTO = {
name: 'New League',
description: 'A new league',
};
const error = new Error('API call failed');
mockApiClient.create.mockRejectedValue(error);
await expect(service.createLeague(input)).rejects.toThrow('API call failed');
});
});
describe('removeMember', () => {
it('should call apiClient.removeMember and return RemoveMemberViewModel', async () => {
const leagueId = 'league-123';
const performerDriverId = 'performer-456';
const targetDriverId = 'target-789';
const mockDto: RemoveLeagueMemberOutputDTO = { success: true };
mockApiClient.removeMember.mockResolvedValue(mockDto);
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
expect(mockApiClient.removeMember).toHaveBeenCalledWith(leagueId, performerDriverId, targetDriverId);
expect(result).toBeInstanceOf(RemoveMemberViewModel);
expect(result.success).toBe(true);
});
it('should handle unsuccessful removal', async () => {
const leagueId = 'league-123';
const performerDriverId = 'performer-456';
const targetDriverId = 'target-789';
const mockDto: RemoveLeagueMemberOutputDTO = { success: false };
mockApiClient.removeMember.mockResolvedValue(mockDto);
const result = await service.removeMember(leagueId, performerDriverId, targetDriverId);
expect(result.success).toBe(false);
expect(result.successMessage).toBe('Failed to remove member.');
});
it('should throw error when apiClient.removeMember fails', async () => {
const leagueId = 'league-123';
const performerDriverId = 'performer-456';
const targetDriverId = 'target-789';
const error = new Error('API call failed');
mockApiClient.removeMember.mockRejectedValue(error);
await expect(service.removeMember(leagueId, performerDriverId, targetDriverId)).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,11 +1,14 @@
import type { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import { LeagueSummaryViewModel, LeagueStandingsViewModel } from '../../view-models';
import type { CreateLeagueInputDTO, CreateLeagueOutputDTO, LeagueWithCapacityDTO } from '../../types/generated';
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO";
import { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO";
import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel";
import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel";
import { LeagueScheduleViewModel } from "@/lib/view-models/LeagueScheduleViewModel";
import { LeagueStandingsViewModel } from "@/lib/view-models/LeagueStandingsViewModel";
import { LeagueStatsViewModel } from "@/lib/view-models/LeagueStatsViewModel";
import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel";
import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel";
// TODO: Move these types to apps/website/lib/types/generated when available
type LeagueStatsDto = { totalLeagues: number };
type LeagueScheduleDto = { races: Array<unknown> };
type LeagueMembershipsDto = { memberships: Array<unknown> };
/**
* League Service
@@ -43,35 +46,40 @@ export class LeagueService {
/**
* Get league statistics
*/
async getLeagueStats(): Promise<LeagueStatsDto> {
return await this.apiClient.getTotal();
async getLeagueStats(): Promise<LeagueStatsViewModel> {
const dto = await this.apiClient.getTotal();
return new LeagueStatsViewModel(dto);
}
/**
* Get league schedule
*/
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleDto> {
return await this.apiClient.getSchedule(leagueId);
async getLeagueSchedule(leagueId: string): Promise<LeagueScheduleViewModel> {
const dto = await this.apiClient.getSchedule(leagueId);
return new LeagueScheduleViewModel(dto);
}
/**
* Get league memberships
*/
async getLeagueMemberships(leagueId: string): Promise<LeagueMembershipsDto> {
return await this.apiClient.getMemberships(leagueId);
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMembershipsViewModel> {
const dto = await this.apiClient.getMemberships(leagueId);
return new LeagueMembershipsViewModel(dto, currentUserId);
}
/**
* Create a new league
*/
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueOutputDTO> {
return await this.apiClient.create(input);
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueViewModel> {
const dto = await this.apiClient.create(input);
return new CreateLeagueViewModel(dto);
}
/**
* Remove a member from league
*/
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
return await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<RemoveMemberViewModel> {
const dto = await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
return new RemoveMemberViewModel(dto);
}
}

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { AvatarService } from './AvatarService';
import { MediaApiClient } from '../../api/media/MediaApiClient';
import { RequestAvatarGenerationViewModel } from '../../view-models/RequestAvatarGenerationViewModel';
import { AvatarViewModel } from '../../view-models/AvatarViewModel';
import { UpdateAvatarViewModel } from '../../view-models/UpdateAvatarViewModel';
describe('AvatarService', () => {
let mockApiClient: Mocked<MediaApiClient>;
let service: AvatarService;
beforeEach(() => {
mockApiClient = {
requestAvatarGeneration: vi.fn(),
getAvatar: vi.fn(),
updateAvatar: vi.fn(),
} as Mocked<MediaApiClient>;
service = new AvatarService(mockApiClient);
});
describe('requestAvatarGeneration', () => {
it('should call apiClient.requestAvatarGeneration with correct input and return view model', async () => {
const input = {
userId: 'user-123',
facePhotoData: 'base64data',
suitColor: 'red' as const,
};
const expectedOutput = { success: true, avatarUrl: 'https://example.com/avatar.jpg' };
mockApiClient.requestAvatarGeneration.mockResolvedValue(expectedOutput);
const result = await service.requestAvatarGeneration(input);
expect(mockApiClient.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(result).toBeInstanceOf(RequestAvatarGenerationViewModel);
expect(result.success).toBe(true);
expect(result.avatarUrl).toBe('https://example.com/avatar.jpg');
});
it('should handle error response', async () => {
const input = {
userId: 'user-123',
facePhotoData: 'base64data',
suitColor: 'blue' as const,
};
const expectedOutput = { success: false, error: 'Generation failed' };
mockApiClient.requestAvatarGeneration.mockResolvedValue(expectedOutput);
const result = await service.requestAvatarGeneration(input);
expect(result).toBeInstanceOf(RequestAvatarGenerationViewModel);
expect(result.success).toBe(false);
expect(result.error).toBe('Generation failed');
});
});
describe('getAvatar', () => {
it('should call apiClient.getAvatar with driverId and return view model', async () => {
const driverId = 'driver-123';
const expectedOutput = { driverId: 'driver-123', avatarUrl: 'https://example.com/avatar.jpg' };
mockApiClient.getAvatar.mockResolvedValue(expectedOutput);
const result = await service.getAvatar(driverId);
expect(mockApiClient.getAvatar).toHaveBeenCalledWith(driverId);
expect(result).toBeInstanceOf(AvatarViewModel);
expect(result.driverId).toBe('driver-123');
expect(result.avatarUrl).toBe('https://example.com/avatar.jpg');
expect(result.hasAvatar).toBe(true);
});
it('should handle driver without avatar', async () => {
const driverId = 'driver-456';
const expectedOutput = { driverId: 'driver-456' };
mockApiClient.getAvatar.mockResolvedValue(expectedOutput);
const result = await service.getAvatar(driverId);
expect(result).toBeInstanceOf(AvatarViewModel);
expect(result.driverId).toBe('driver-456');
expect(result.avatarUrl).toBeUndefined();
expect(result.hasAvatar).toBe(false);
});
});
describe('updateAvatar', () => {
it('should call apiClient.updateAvatar with correct input and return view model', async () => {
const input = { driverId: 'driver-123', avatarUrl: 'https://example.com/new-avatar.jpg' };
const expectedOutput = { success: true };
mockApiClient.updateAvatar.mockResolvedValue(expectedOutput);
const result = await service.updateAvatar(input);
expect(mockApiClient.updateAvatar).toHaveBeenCalledWith(input);
expect(result).toBeInstanceOf(UpdateAvatarViewModel);
expect(result.success).toBe(true);
expect(result.isSuccessful).toBe(true);
expect(result.hasError).toBe(false);
});
it('should handle error response', async () => {
const input = { driverId: 'driver-123', avatarUrl: 'https://example.com/new-avatar.jpg' };
const expectedOutput = { success: false, error: 'Update failed' };
mockApiClient.updateAvatar.mockResolvedValue(expectedOutput);
const result = await service.updateAvatar(input);
expect(result).toBeInstanceOf(UpdateAvatarViewModel);
expect(result.success).toBe(false);
expect(result.error).toBe('Update failed');
expect(result.isSuccessful).toBe(false);
expect(result.hasError).toBe(true);
});
});
});

View File

@@ -1,13 +1,11 @@
import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel';
import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel';
import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { RequestAvatarGenerationInputDTO } from '../../types/generated';
// TODO: Move these types to apps/website/lib/types/generated when available
type UpdateAvatarInputDto = { driverId: string; avatarUrl: string };
import {
RequestAvatarGenerationViewModel,
AvatarViewModel,
UpdateAvatarViewModel
} from '../../view-models';
/**
* Avatar Service

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { MediaService } from './MediaService';
import { MediaApiClient } from '../../api/media/MediaApiClient';
import { MediaViewModel } from '../../view-models/MediaViewModel';
import { UploadMediaViewModel } from '../../view-models/UploadMediaViewModel';
import { DeleteMediaViewModel } from '../../view-models/DeleteMediaViewModel';
describe('MediaService', () => {
let mockApiClient: Mocked<MediaApiClient>;
let service: MediaService;
beforeEach(() => {
mockApiClient = {
uploadMedia: vi.fn(),
getMedia: vi.fn(),
deleteMedia: vi.fn(),
} as Mocked<MediaApiClient>;
service = new MediaService(mockApiClient);
});
describe('uploadMedia', () => {
it('should call apiClient.uploadMedia with correct input and return view model', async () => {
const input = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
category: 'avatar' as const,
};
const expectedOutput = { success: true, mediaId: 'media-123', url: 'https://example.com/media.jpg' };
mockApiClient.uploadMedia.mockResolvedValue(expectedOutput);
const result = await service.uploadMedia(input);
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(result).toBeInstanceOf(UploadMediaViewModel);
expect(result.success).toBe(true);
expect(result.mediaId).toBe('media-123');
expect(result.url).toBe('https://example.com/media.jpg');
expect(result.isSuccessful).toBe(true);
expect(result.hasError).toBe(false);
});
it('should handle error response', async () => {
const input = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
};
const expectedOutput = { success: false, error: 'Upload failed' };
mockApiClient.uploadMedia.mockResolvedValue(expectedOutput);
const result = await service.uploadMedia(input);
expect(result).toBeInstanceOf(UploadMediaViewModel);
expect(result.success).toBe(false);
expect(result.error).toBe('Upload failed');
expect(result.isSuccessful).toBe(false);
expect(result.hasError).toBe(true);
});
it('should handle upload without category', async () => {
const input = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
};
const expectedOutput = { success: true, mediaId: 'media-456', url: 'https://example.com/media2.jpg' };
mockApiClient.uploadMedia.mockResolvedValue(expectedOutput);
const result = await service.uploadMedia(input);
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(result).toBeInstanceOf(UploadMediaViewModel);
expect(result.success).toBe(true);
});
});
describe('getMedia', () => {
it('should call apiClient.getMedia with mediaId and return view model', async () => {
const mediaId = 'media-123';
const expectedOutput = {
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image' as const,
category: 'avatar' as const,
uploadedAt: new Date('2023-01-15'),
size: 2048000,
};
mockApiClient.getMedia.mockResolvedValue(expectedOutput);
const result = await service.getMedia(mediaId);
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
expect(result).toBeInstanceOf(MediaViewModel);
expect(result.id).toBe('media-123');
expect(result.url).toBe('https://example.com/image.jpg');
expect(result.type).toBe('image');
expect(result.category).toBe('avatar');
expect(result.uploadedAt).toEqual(new Date('2023-01-15'));
expect(result.size).toBe(2048000);
expect(result.formattedSize).toBe('2000.00 KB');
});
it('should handle media without category and size', async () => {
const mediaId = 'media-456';
const expectedOutput = {
id: 'media-456',
url: 'https://example.com/video.mp4',
type: 'video' as const,
uploadedAt: new Date('2023-02-20'),
};
mockApiClient.getMedia.mockResolvedValue(expectedOutput);
const result = await service.getMedia(mediaId);
expect(result).toBeInstanceOf(MediaViewModel);
expect(result.id).toBe('media-456');
expect(result.type).toBe('video');
expect(result.category).toBeUndefined();
expect(result.size).toBeUndefined();
expect(result.formattedSize).toBe('Unknown');
});
});
describe('deleteMedia', () => {
it('should call apiClient.deleteMedia with mediaId and return view model', async () => {
const mediaId = 'media-123';
const expectedOutput = { success: true };
mockApiClient.deleteMedia.mockResolvedValue(expectedOutput);
const result = await service.deleteMedia(mediaId);
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(result).toBeInstanceOf(DeleteMediaViewModel);
expect(result.success).toBe(true);
expect(result.isSuccessful).toBe(true);
expect(result.hasError).toBe(false);
});
it('should handle error response', async () => {
const mediaId = 'media-456';
const expectedOutput = { success: false, error: 'Deletion failed' };
mockApiClient.deleteMedia.mockResolvedValue(expectedOutput);
const result = await service.deleteMedia(mediaId);
expect(result).toBeInstanceOf(DeleteMediaViewModel);
expect(result.success).toBe(false);
expect(result.error).toBe('Deletion failed');
expect(result.isSuccessful).toBe(false);
expect(result.hasError).toBe(true);
});
});
});

View File

@@ -1,8 +1,10 @@
import { DeleteMediaViewModel } from '@/lib/view-models/DeleteMediaViewModel';
import { MediaViewModel } from '@/lib/view-models/MediaViewModel';
import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import { MediaViewModel, UploadMediaViewModel, DeleteMediaViewModel } from '../../view-models';
// TODO: Move these types to apps/website/lib/types/generated when available
type UploadMediaInputDto = { url: string; mediaType: string; entityType: string; entityId: string };
type UploadMediaInputDto = { file: File; type: string; category?: string };
/**
* Media Service

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { MembershipFeeService, GetMembershipFeesOutputDto } from './MembershipFeeService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { MembershipFeeViewModel } from '../../view-models';
import type { MembershipFeeDto } from '../../types/generated';
describe('MembershipFeeService', () => {
let mockApiClient: Mocked<PaymentsApiClient>;
let service: MembershipFeeService;
beforeEach(() => {
mockApiClient = {
getMembershipFees: vi.fn(),
} as Mocked<PaymentsApiClient>;
service = new MembershipFeeService(mockApiClient);
});
describe('getMembershipFees', () => {
it('should call apiClient.getMembershipFees with correct leagueId and return mapped view models', async () => {
const leagueId = 'league-123';
const mockFees: MembershipFeeDto[] = [
{ id: 'fee-1', leagueId: 'league-123' },
{ id: 'fee-2', leagueId: 'league-123' },
];
const mockOutput: GetMembershipFeesOutputDto = { fees: mockFees };
mockApiClient.getMembershipFees.mockResolvedValue(mockOutput);
const result = await service.getMembershipFees(leagueId);
expect(mockApiClient.getMembershipFees).toHaveBeenCalledWith(leagueId);
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(MembershipFeeViewModel);
expect(result[0].id).toEqual('fee-1');
expect(result[0].leagueId).toEqual('league-123');
expect(result[1]).toBeInstanceOf(MembershipFeeViewModel);
expect(result[1].id).toEqual('fee-2');
expect(result[1].leagueId).toEqual('league-123');
});
it('should return empty array when no fees are returned', async () => {
const leagueId = 'league-456';
const mockOutput: GetMembershipFeesOutputDto = { fees: [] };
mockApiClient.getMembershipFees.mockResolvedValue(mockOutput);
const result = await service.getMembershipFees(leagueId);
expect(mockApiClient.getMembershipFees).toHaveBeenCalledWith(leagueId);
expect(result).toEqual([]);
});
});
});

View File

@@ -1,6 +1,11 @@
import { MembershipFeeDto } from '@/lib/types/generated/MembershipFeeDto';
import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { MembershipFeeViewModel } from '../../view-models';
import type { MembershipFeeDto } from '../../types/generated';
// TODO: This DTO should be generated from OpenAPI spec when the endpoint is added
export interface GetMembershipFeesOutputDto {
fees: MembershipFeeDto[];
}
/**
* Membership Fee Service

View File

@@ -0,0 +1,244 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { PaymentService } from './PaymentService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { PaymentViewModel, MembershipFeeViewModel, PrizeViewModel, WalletViewModel } from '../../view-models';
describe('PaymentService', () => {
let mockApiClient: Mocked<PaymentsApiClient>;
let service: PaymentService;
beforeEach(() => {
mockApiClient = {
getPayments: vi.fn(),
createPayment: vi.fn(),
getMembershipFees: vi.fn(),
getPrizes: vi.fn(),
getWallet: vi.fn(),
} as Mocked<PaymentsApiClient>;
service = new PaymentService(mockApiClient);
});
describe('getPayments', () => {
it('should call apiClient.getPayments and return PaymentViewModel array', async () => {
const mockResponse = {
payments: [
{
id: 'payment-1',
type: 'sponsorship' as const,
amount: 100,
platformFee: 10,
netAmount: 90,
payerId: 'user-1',
payerType: 'sponsor' as const,
leagueId: 'league-1',
status: 'completed' as const,
createdAt: new Date(),
},
],
};
mockApiClient.getPayments.mockResolvedValue(mockResponse);
const result = await service.getPayments();
expect(mockApiClient.getPayments).toHaveBeenCalledWith(undefined, undefined);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(PaymentViewModel);
expect(result[0].id).toBe('payment-1');
});
it('should call apiClient.getPayments with filters', async () => {
const mockResponse = { payments: [] };
mockApiClient.getPayments.mockResolvedValue(mockResponse);
await service.getPayments('league-1', 'user-1');
expect(mockApiClient.getPayments).toHaveBeenCalledWith('league-1', 'user-1');
});
});
describe('getPayment', () => {
it('should return PaymentViewModel when payment exists', async () => {
const mockResponse = {
payments: [
{
id: 'payment-1',
type: 'sponsorship' as const,
amount: 100,
platformFee: 10,
netAmount: 90,
payerId: 'user-1',
payerType: 'sponsor' as const,
leagueId: 'league-1',
status: 'completed' as const,
createdAt: new Date(),
},
],
};
mockApiClient.getPayments.mockResolvedValue(mockResponse);
const result = await service.getPayment('payment-1');
expect(result).toBeInstanceOf(PaymentViewModel);
expect(result.id).toBe('payment-1');
});
it('should throw error when payment does not exist', async () => {
const mockResponse = { payments: [] };
mockApiClient.getPayments.mockResolvedValue(mockResponse);
await expect(service.getPayment('non-existent')).rejects.toThrow(
'Payment with ID non-existent not found'
);
});
});
describe('createPayment', () => {
it('should call apiClient.createPayment and return PaymentViewModel', async () => {
const input = {
type: 'sponsorship' as const,
amount: 100,
payerId: 'user-1',
payerType: 'sponsor' as const,
leagueId: 'league-1',
};
const mockResponse = {
payment: {
id: 'payment-1',
type: 'sponsorship' as const,
amount: 100,
platformFee: 10,
netAmount: 90,
payerId: 'user-1',
payerType: 'sponsor' as const,
leagueId: 'league-1',
status: 'pending' as const,
createdAt: new Date(),
},
};
mockApiClient.createPayment.mockResolvedValue(mockResponse);
const result = await service.createPayment(input);
expect(mockApiClient.createPayment).toHaveBeenCalledWith(input);
expect(result).toBeInstanceOf(PaymentViewModel);
expect(result.id).toBe('payment-1');
});
});
describe('getMembershipFees', () => {
it('should return MembershipFeeViewModel when fee exists', async () => {
const mockResponse = {
fee: {
id: 'fee-1',
leagueId: 'league-1',
type: 'season' as const,
amount: 50,
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
payments: [],
};
mockApiClient.getMembershipFees.mockResolvedValue(mockResponse);
const result = await service.getMembershipFees('league-1');
expect(result).toBeInstanceOf(MembershipFeeViewModel);
expect(result!.id).toBe('fee-1');
});
it('should return null when fee does not exist', async () => {
const mockResponse = {
fee: null,
payments: [],
};
mockApiClient.getMembershipFees.mockResolvedValue(mockResponse);
const result = await service.getMembershipFees('league-1');
expect(result).toBeNull();
});
});
describe('getPrizes', () => {
it('should call apiClient.getPrizes and return PrizeViewModel array', async () => {
const mockResponse = {
prizes: [
{
id: 'prize-1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'First Place',
amount: 100,
type: 'cash' as const,
awarded: false,
createdAt: new Date(),
},
],
};
mockApiClient.getPrizes.mockResolvedValue(mockResponse);
const result = await service.getPrizes();
expect(mockApiClient.getPrizes).toHaveBeenCalledWith(undefined, undefined);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(PrizeViewModel);
expect(result[0].id).toBe('prize-1');
});
it('should call apiClient.getPrizes with filters', async () => {
const mockResponse = { prizes: [] };
mockApiClient.getPrizes.mockResolvedValue(mockResponse);
await service.getPrizes('league-1', 'season-1');
expect(mockApiClient.getPrizes).toHaveBeenCalledWith('league-1', 'season-1');
});
});
describe('getWallet', () => {
it('should call apiClient.getWallet and return WalletViewModel', async () => {
const mockResponse = {
wallet: {
id: 'wallet-1',
leagueId: 'league-1',
balance: 1000,
totalRevenue: 1500,
totalPlatformFees: 100,
totalWithdrawn: 400,
createdAt: new Date(),
currency: 'EUR',
},
transactions: [
{
id: 'tx-1',
walletId: 'wallet-1',
type: 'deposit' as const,
amount: 500,
description: 'Deposit',
createdAt: new Date(),
},
],
};
mockApiClient.getWallet.mockResolvedValue(mockResponse);
const result = await service.getWallet('user-1');
expect(mockApiClient.getWallet).toHaveBeenCalledWith('user-1');
expect(result).toBeInstanceOf(WalletViewModel);
expect(result.id).toBe('wallet-1');
expect(result.balance).toBe(1000);
expect(result.transactions).toHaveLength(1);
});
});
});

View File

@@ -1,15 +1,21 @@
import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel';
import { PaymentViewModel } from '@/lib/view-models/PaymentViewModel';
import { PrizeViewModel } from '@/lib/view-models/PrizeViewModel';
import { WalletViewModel } from '@/lib/view-models/WalletViewModel';
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentDto, MembershipFeeDto, PrizeDto } from '../../types/generated';
import type { PaymentDTO } from '../../types/generated/PaymentDto';
import type { PrizeDto } from '../../types/generated/PrizeDto';
// TODO: Move these types to apps/website/lib/types/generated when available
type CreatePaymentInputDto = { amount: number; leagueId: string; driverId: string; description: string };
type CreatePaymentOutputDto = { id: string; success: boolean };
import {
PaymentViewModel,
MembershipFeeViewModel,
PrizeViewModel,
WalletViewModel,
} from '../../view-models';
type CreatePaymentInputDto = {
type: 'sponsorship' | 'membership_fee';
amount: number;
payerId: string;
payerType: 'sponsor' | 'driver';
leagueId: string;
seasonId?: string;
};
/**
* Payment Service
@@ -27,7 +33,7 @@ export class PaymentService {
*/
async getPayments(leagueId?: string, driverId?: string): Promise<PaymentViewModel[]> {
const dto = await this.apiClient.getPayments(leagueId, driverId);
return dto.payments.map((payment: PaymentDto) => new PaymentViewModel(payment));
return dto.payments.map((payment: PaymentDTO) => new PaymentViewModel(payment));
}
/**
@@ -36,7 +42,7 @@ export class PaymentService {
async getPayment(paymentId: string): Promise<PaymentViewModel> {
// Note: Assuming the API returns a single payment from the list
const dto = await this.apiClient.getPayments();
const payment = dto.payments.find((p: PaymentDto) => p.id === paymentId);
const payment = dto.payments.find((p: PaymentDTO) => p.id === paymentId);
if (!payment) {
throw new Error(`Payment with ID ${paymentId} not found`);
}
@@ -46,16 +52,17 @@ export class PaymentService {
/**
* Create a new payment
*/
async createPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
return await this.apiClient.createPayment(input);
async createPayment(input: CreatePaymentInputDto): Promise<PaymentViewModel> {
const dto = await this.apiClient.createPayment(input);
return new PaymentViewModel(dto.payment);
}
/**
* Get membership fees for a league
*/
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel[]> {
async getMembershipFees(leagueId: string): Promise<MembershipFeeViewModel | null> {
const dto = await this.apiClient.getMembershipFees(leagueId);
return dto.fees.map((fee: MembershipFeeDto) => new MembershipFeeViewModel(fee));
return dto.fee ? new MembershipFeeViewModel(dto.fee) : null;
}
/**
@@ -71,14 +78,7 @@ export class PaymentService {
*/
async getWallet(driverId: string): Promise<WalletViewModel> {
const dto = await this.apiClient.getWallet(driverId);
return new WalletViewModel(dto);
}
/**
* Process a payment (alias for createPayment)
*/
async processPayment(input: CreatePaymentInputDto): Promise<CreatePaymentOutputDto> {
return await this.createPayment(input);
return new WalletViewModel({ ...dto.wallet, transactions: dto.transactions });
}
/**

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { WalletService } from './WalletService';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { WalletViewModel } from '../../view-models';
describe('WalletService', () => {
let mockApiClient: Mocked<PaymentsApiClient>;
let service: WalletService;
beforeEach(() => {
mockApiClient = {
getWallet: vi.fn(),
} as Mocked<PaymentsApiClient>;
service = new WalletService(mockApiClient);
});
describe('getWallet', () => {
it('should call apiClient.getWallet and return WalletViewModel', async () => {
const mockResponse = {
wallet: {
id: 'wallet-1',
leagueId: 'league-1',
balance: 1000,
totalRevenue: 1500,
totalPlatformFees: 100,
totalWithdrawn: 400,
createdAt: '2023-01-01T00:00:00Z',
currency: 'EUR',
},
transactions: [
{
id: 'tx-1',
walletId: 'wallet-1',
amount: 500,
description: 'Deposit',
createdAt: '2023-01-01T00:00:00Z',
type: 'deposit' as const,
},
],
};
mockApiClient.getWallet.mockResolvedValue(mockResponse);
const result = await service.getWallet('user-1');
expect(mockApiClient.getWallet).toHaveBeenCalledWith('user-1');
expect(result).toBeInstanceOf(WalletViewModel);
expect(result.id).toBe('wallet-1');
expect(result.balance).toBe(1000);
expect(result.transactions).toHaveLength(1);
expect(result.transactions[0].id).toBe('tx-1');
});
it('should handle wallet without transactions', async () => {
const mockResponse = {
wallet: {
id: 'wallet-1',
leagueId: 'league-1',
balance: 1000,
totalRevenue: 1500,
totalPlatformFees: 100,
totalWithdrawn: 400,
createdAt: '2023-01-01T00:00:00Z',
currency: 'EUR',
},
transactions: [],
};
mockApiClient.getWallet.mockResolvedValue(mockResponse);
const result = await service.getWallet('user-1');
expect(result.transactions).toHaveLength(0);
});
});
});

View File

@@ -1,5 +1,6 @@
import { WalletViewModel } from '@/lib/view-models/WalletViewModel';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import { WalletViewModel } from '../../view-models';
import { FullTransactionDto } from '../../view-models/WalletTransactionViewModel';
/**
* Wallet Service
@@ -16,7 +17,7 @@ export class WalletService {
* Get wallet by driver ID with view model transformation
*/
async getWallet(driverId: string): Promise<WalletViewModel> {
const dto = await this.apiClient.getWallet(driverId);
return new WalletViewModel(dto);
const { wallet, transactions } = await this.apiClient.getWallet(driverId);
return new WalletViewModel({ ...wallet, transactions: transactions as FullTransactionDto[] });
}
}

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { RaceResultsService } from './RaceResultsService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '../../types/generated';
describe('RaceResultsService', () => {
let mockApiClient: Mocked<RacesApiClient>;
let service: RaceResultsService;
beforeEach(() => {
mockApiClient = {
getResultsDetail: vi.fn(),
getWithSOF: vi.fn(),
importResults: vi.fn(),
} as Mocked<RacesApiClient>;
service = new RaceResultsService(mockApiClient);
});
describe('getResultsDetail', () => {
it('should call apiClient.getResultsDetail and return RaceResultsDetailViewModel', async () => {
const raceId = 'race-123';
const currentUserId = 'user-456';
const mockDto: RaceResultsDetailDTO = {
raceId,
track: 'Test Track',
};
mockApiClient.getResultsDetail.mockResolvedValue(mockDto);
const result = await service.getResultsDetail(raceId, currentUserId);
expect(mockApiClient.getResultsDetail).toHaveBeenCalledWith(raceId);
expect(result).toBeInstanceOf(RaceResultsDetailViewModel);
expect(result.raceId).toBe(raceId);
expect(result.track).toBe('Test Track');
});
it('should handle undefined currentUserId', async () => {
const raceId = 'race-123';
const mockDto: RaceResultsDetailDTO = {
raceId,
track: 'Test Track',
};
mockApiClient.getResultsDetail.mockResolvedValue(mockDto);
const result = await service.getResultsDetail(raceId);
expect(result).toBeInstanceOf(RaceResultsDetailViewModel);
expect(result.currentUserId).toBe('');
});
it('should throw error when apiClient.getResultsDetail fails', async () => {
const raceId = 'race-123';
const currentUserId = 'user-456';
const error = new Error('API call failed');
mockApiClient.getResultsDetail.mockRejectedValue(error);
await expect(service.getResultsDetail(raceId, currentUserId)).rejects.toThrow('API call failed');
});
});
describe('getWithSOF', () => {
it('should call apiClient.getWithSOF and return RaceWithSOFViewModel', async () => {
const raceId = 'race-123';
const mockDto: RaceWithSOFDTO = {
id: raceId,
track: 'Test Track',
};
mockApiClient.getWithSOF.mockResolvedValue(mockDto);
const result = await service.getWithSOF(raceId);
expect(mockApiClient.getWithSOF).toHaveBeenCalledWith(raceId);
expect(result).toBeInstanceOf(RaceWithSOFViewModel);
expect(result.id).toBe(raceId);
expect(result.track).toBe('Test Track');
});
it('should throw error when apiClient.getWithSOF fails', async () => {
const raceId = 'race-123';
const error = new Error('API call failed');
mockApiClient.getWithSOF.mockRejectedValue(error);
await expect(service.getWithSOF(raceId)).rejects.toThrow('API call failed');
});
});
describe('importResults', () => {
it('should call apiClient.importResults and return ImportRaceResultsSummaryViewModel', async () => {
const raceId = 'race-123';
const input = { raceId, results: [{ position: 1 }] };
const mockDto = {
raceId,
importedCount: 10,
errors: ['Error 1'],
};
mockApiClient.importResults.mockResolvedValue(mockDto as any);
const result = await service.importResults(raceId, input);
expect(mockApiClient.importResults).toHaveBeenCalledWith(raceId, input);
expect(result).toBeInstanceOf(ImportRaceResultsSummaryViewModel);
expect(result.raceId).toBe(raceId);
expect(result.importedCount).toBe(10);
expect(result.errors).toEqual(['Error 1']);
});
it('should handle successful import with no errors', async () => {
const raceId = 'race-123';
const input = { raceId, results: [] };
const mockDto = {
raceId,
importedCount: 5,
errors: [],
};
mockApiClient.importResults.mockResolvedValue(mockDto as any);
const result = await service.importResults(raceId, input);
expect(result.importedCount).toBe(5);
expect(result.errors).toEqual([]);
});
it('should throw error when apiClient.importResults fails', async () => {
const raceId = 'race-123';
const input = { raceId, results: [] };
const error = new Error('API call failed');
mockApiClient.importResults.mockRejectedValue(error);
await expect(service.importResults(raceId, input)).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,13 +1,17 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
// TODO: Move these types to apps/website/lib/types/generated when available
type ImportRaceResultsInputDto = { raceId: string; results: Array<any> };
// TODO: Move this type to apps/website/lib/types/generated when available
type ImportRaceResultsInputDto = { raceId: string; results: Array<unknown> };
// Note: RaceWithSOFViewModel and ImportRaceResultsSummaryViewModel are defined in presenters
// These will need to be converted to proper view models
type RaceWithSOFViewModel = any; // TODO: Create proper view model
type ImportRaceResultsSummaryViewModel = any; // TODO: Create proper view model
// TODO: Move this type to apps/website/lib/types/generated when available
type ImportRaceResultsSummaryDto = {
raceId: string;
importedCount: number;
errors: string[];
};
/**
* Race Results Service
@@ -29,20 +33,18 @@ export class RaceResultsService {
}
/**
* Get race with strength of field calculation
* TODO: Create RaceWithSOFViewModel and use it here
*/
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
const dto = await this.apiClient.getWithSOF(raceId);
return dto; // TODO: return new RaceWithSOFViewModel(dto);
}
* Get race with strength of field calculation
*/
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
const dto = await this.apiClient.getWithSOF(raceId);
return new RaceWithSOFViewModel(dto);
}
/**
* Import race results and get summary
* TODO: Create ImportRaceResultsSummaryViewModel and use it here
*/
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
const dto = await this.apiClient.importResults(raceId, input);
return dto; // TODO: return new ImportRaceResultsSummaryViewModel(dto);
}
* Import race results and get summary
*/
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
const dto = await this.apiClient.importResults(raceId, input) as ImportRaceResultsSummaryDto;
return new ImportRaceResultsSummaryViewModel(dto);
}
}

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { RaceService } from './RaceService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
describe('RaceService', () => {
let mockApiClient: Mocked<RacesApiClient>;
let service: RaceService;
beforeEach(() => {
mockApiClient = {
getDetail: vi.fn(),
getPageData: vi.fn(),
getTotal: vi.fn(),
} as Mocked<RacesApiClient>;
service = new RaceService(mockApiClient);
});
describe('getRaceDetail', () => {
it('should call apiClient.getDetail and return RaceDetailViewModel', async () => {
const raceId = 'race-123';
const driverId = 'driver-456';
const mockDto = {
race: { id: raceId, track: 'Test Track' },
league: { id: 'league-1', name: 'Test League' },
entryList: [],
registration: { isRegistered: true, canRegister: false },
userResult: null,
};
mockApiClient.getDetail.mockResolvedValue(mockDto as any);
const result = await service.getRaceDetail(raceId, driverId);
expect(mockApiClient.getDetail).toHaveBeenCalledWith(raceId, driverId);
expect(result).toBeInstanceOf(RaceDetailViewModel);
expect(result.race?.id).toBe(raceId);
});
it('should throw error when apiClient.getDetail fails', async () => {
const raceId = 'race-123';
const driverId = 'driver-456';
const error = new Error('API call failed');
mockApiClient.getDetail.mockRejectedValue(error);
await expect(service.getRaceDetail(raceId, driverId)).rejects.toThrow('API call failed');
});
});
describe('getRacesPageData', () => {
it('should call apiClient.getPageData and return RacesPageViewModel with transformed data', async () => {
const mockDto = {
races: [
{
id: 'race-1',
track: 'Monza',
car: 'Ferrari',
scheduledAt: '2023-10-01T10:00:00Z',
status: 'upcoming',
leagueId: 'league-1',
leagueName: 'Test League',
},
{
id: 'race-2',
track: 'Silverstone',
car: 'Mercedes',
scheduledAt: '2023-09-15T10:00:00Z',
status: 'completed',
leagueId: 'league-1',
leagueName: 'Test League',
},
],
};
mockApiClient.getPageData.mockResolvedValue(mockDto as any);
const result = await service.getRacesPageData();
expect(mockApiClient.getPageData).toHaveBeenCalled();
expect(result).toBeInstanceOf(RacesPageViewModel);
expect(result.upcomingRaces).toHaveLength(1);
expect(result.completedRaces).toHaveLength(1);
expect(result.totalCount).toBe(2);
expect(result.upcomingRaces[0].title).toBe('Monza - Ferrari');
expect(result.completedRaces[0].title).toBe('Silverstone - Mercedes');
});
it('should handle empty races array', async () => {
const mockDto = { races: [] };
mockApiClient.getPageData.mockResolvedValue(mockDto as any);
const result = await service.getRacesPageData();
expect(result.upcomingRaces).toHaveLength(0);
expect(result.completedRaces).toHaveLength(0);
expect(result.totalCount).toBe(0);
});
it('should throw error when apiClient.getPageData fails', async () => {
const error = new Error('API call failed');
mockApiClient.getPageData.mockRejectedValue(error);
await expect(service.getRacesPageData()).rejects.toThrow('API call failed');
});
});
describe('getRacesTotal', () => {
it('should call apiClient.getTotal and return RaceStatsViewModel', async () => {
const mockDto = { totalRaces: 42 };
mockApiClient.getTotal.mockResolvedValue(mockDto as any);
const result = await service.getRacesTotal();
expect(mockApiClient.getTotal).toHaveBeenCalled();
expect(result).toBeInstanceOf(RaceStatsViewModel);
expect(result.totalRaces).toBe(42);
expect(result.formattedTotalRaces).toBe('42');
});
it('should throw error when apiClient.getTotal fails', async () => {
const error = new Error('API call failed');
mockApiClient.getTotal.mockRejectedValue(error);
await expect(service.getRacesTotal()).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,9 +1,20 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
// TODO: Move these types to apps/website/lib/types/generated when available
type RacesPageDataDto = { races: Array<any> };
type RaceStatsDto = { totalRaces: number };
type RacesPageDataRaceDTO = {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
};
type RacesPageDataDto = { races: RacesPageDataRaceDTO[] };
type RaceStatsDTO = { totalRaces: number };
/**
* Race Service
@@ -28,18 +39,51 @@ export class RaceService {
}
/**
* Get races page data
* TODO: Add view model transformation when view model is available
* Get races page data with view model transformation
*/
async getRacesPageData(): Promise<RacesPageDataDto> {
return this.apiClient.getPageData();
async getRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return new RacesPageViewModel(this.transformRacesPageData(dto));
}
/**
* Get total races statistics
* TODO: Add view model transformation when view model is available
* Get total races statistics with view model transformation
*/
async getRacesTotal(): Promise<RaceStatsDto> {
return this.apiClient.getTotal();
async getRacesTotal(): Promise<RaceStatsViewModel> {
const dto: RaceStatsDTO = await this.apiClient.getTotal();
return new RaceStatsViewModel(dto);
}
/**
* Transform API races page data to view model format
*/
private transformRacesPageData(dto: RacesPageDataDto): {
upcomingRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>;
completedRaces: Array<{ id: string; title: string; scheduledTime: string; status: string }>;
totalCount: number;
} {
const upcomingRaces = dto.races
.filter(race => race.status !== 'completed')
.map(race => ({
id: race.id,
title: `${race.track} - ${race.car}`,
scheduledTime: race.scheduledAt,
status: race.status,
}));
const completedRaces = dto.races
.filter(race => race.status === 'completed')
.map(race => ({
id: race.id,
title: `${race.track} - ${race.car}`,
scheduledTime: race.scheduledAt,
status: race.status,
}));
return {
upcomingRaces,
completedRaces,
totalCount: dto.races.length,
};
}
}

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { SponsorService } from './SponsorService';
import { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import { SponsorViewModel } from '../../view-models/SponsorViewModel';
import { SponsorDashboardViewModel } from '../../view-models/SponsorDashboardViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
describe('SponsorService', () => {
let mockApiClient: Mocked<SponsorsApiClient>;
let service: SponsorService;
beforeEach(() => {
mockApiClient = {
getAll: vi.fn(),
getDashboard: vi.fn(),
getSponsorships: vi.fn(),
create: vi.fn(),
getPricing: vi.fn(),
} as Mocked<SponsorsApiClient>;
service = new SponsorService(mockApiClient);
});
describe('getAllSponsors', () => {
it('should call apiClient.getAll and return array of SponsorViewModel', async () => {
const mockDto = {
sponsors: [
{
id: 'sponsor-1',
name: 'Test Sponsor',
logoUrl: 'https://example.com/logo.png',
websiteUrl: 'https://example.com',
},
],
};
mockApiClient.getAll.mockResolvedValue(mockDto);
const result = await service.getAllSponsors();
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(SponsorViewModel);
expect(result[0].id).toBe('sponsor-1');
expect(result[0].name).toBe('Test Sponsor');
expect(result[0].hasWebsite).toBe(true);
});
it('should throw error when apiClient.getAll fails', async () => {
const error = new Error('API call failed');
mockApiClient.getAll.mockRejectedValue(error);
await expect(service.getAllSponsors()).rejects.toThrow('API call failed');
});
});
describe('getSponsorDashboard', () => {
it('should call apiClient.getDashboard and return SponsorDashboardViewModel when data exists', async () => {
const mockDto = {
sponsorId: 'sponsor-1',
sponsorName: 'Test Sponsor',
};
mockApiClient.getDashboard.mockResolvedValue(mockDto);
const result = await service.getSponsorDashboard('sponsor-1');
expect(mockApiClient.getDashboard).toHaveBeenCalledWith('sponsor-1');
expect(result).toBeInstanceOf(SponsorDashboardViewModel);
expect(result?.sponsorId).toBe('sponsor-1');
expect(result?.sponsorName).toBe('Test Sponsor');
});
it('should return null when apiClient.getDashboard returns null', async () => {
mockApiClient.getDashboard.mockResolvedValue(null);
const result = await service.getSponsorDashboard('sponsor-1');
expect(mockApiClient.getDashboard).toHaveBeenCalledWith('sponsor-1');
expect(result).toBeNull();
});
it('should throw error when apiClient.getDashboard fails', async () => {
const error = new Error('API call failed');
mockApiClient.getDashboard.mockRejectedValue(error);
await expect(service.getSponsorDashboard('sponsor-1')).rejects.toThrow('API call failed');
});
});
describe('getSponsorSponsorships', () => {
it('should call apiClient.getSponsorships and return SponsorSponsorshipsViewModel when data exists', async () => {
const mockDto = {
sponsorId: 'sponsor-1',
sponsorName: 'Test Sponsor',
};
mockApiClient.getSponsorships.mockResolvedValue(mockDto);
const result = await service.getSponsorSponsorships('sponsor-1');
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith('sponsor-1');
expect(result).toBeInstanceOf(SponsorSponsorshipsViewModel);
expect(result?.sponsorId).toBe('sponsor-1');
expect(result?.sponsorName).toBe('Test Sponsor');
});
it('should return null when apiClient.getSponsorships returns null', async () => {
mockApiClient.getSponsorships.mockResolvedValue(null);
const result = await service.getSponsorSponsorships('sponsor-1');
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith('sponsor-1');
expect(result).toBeNull();
});
it('should throw error when apiClient.getSponsorships fails', async () => {
const error = new Error('API call failed');
mockApiClient.getSponsorships.mockRejectedValue(error);
await expect(service.getSponsorSponsorships('sponsor-1')).rejects.toThrow('API call failed');
});
});
describe('createSponsor', () => {
it('should call apiClient.create and return the result', async () => {
const input = {
name: 'New Sponsor',
};
const mockOutput = {
id: 'sponsor-123',
name: 'New Sponsor',
};
mockApiClient.create.mockResolvedValue(mockOutput);
const result = await service.createSponsor(input);
expect(mockApiClient.create).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should throw error when apiClient.create fails', async () => {
const input = {
name: 'New Sponsor',
};
const error = new Error('API call failed');
mockApiClient.create.mockRejectedValue(error);
await expect(service.createSponsor(input)).rejects.toThrow('API call failed');
});
});
describe('getSponsorshipPricing', () => {
it('should call apiClient.getPricing and return the result', async () => {
const mockPricing = {
pricing: [
{ entityType: 'league', price: 100 },
{ entityType: 'driver', price: 50 },
],
};
mockApiClient.getPricing.mockResolvedValue(mockPricing);
const result = await service.getSponsorshipPricing();
expect(mockApiClient.getPricing).toHaveBeenCalled();
expect(result).toEqual(mockPricing);
});
it('should throw error when apiClient.getPricing fails', async () => {
const error = new Error('API call failed');
mockApiClient.getPricing.mockRejectedValue(error);
await expect(service.getSponsorshipPricing()).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,11 +1,8 @@
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import { SponsorViewModel, SponsorDashboardViewModel, SponsorSponsorshipsViewModel } from '../../view-models';
import type { CreateSponsorInputDTO } from '../../types/generated';
// TODO: Move these types to apps/website/lib/types/generated when available
type CreateSponsorOutputDto = { id: string; name: string };
type GetEntitySponsorshipPricingResultDto = { pricing: Array<{ entityType: string; price: number }> };
type SponsorDTO = { id: string; name: string; logoUrl?: string; websiteUrl?: string };
import type { SponsorsApiClient, CreateSponsorOutputDto, GetEntitySponsorshipPricingResultDto, SponsorDTO } from '../../api/sponsors/SponsorsApiClient';
import { SponsorViewModel } from '../../view-models/SponsorViewModel';
import { SponsorDashboardViewModel } from '../../view-models/SponsorDashboardViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
import type { CreateSponsorInputDTO } from '../../types/generated/CreateSponsorInputDTO';
/**
* Sponsor Service

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { SponsorshipService } from './SponsorshipService';
import { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
describe('SponsorshipService', () => {
let mockApiClient: Mocked<SponsorsApiClient>;
let service: SponsorshipService;
beforeEach(() => {
mockApiClient = {
getPricing: vi.fn(),
getSponsorships: vi.fn(),
} as Mocked<SponsorsApiClient>;
service = new SponsorshipService(mockApiClient);
});
describe('getSponsorshipPricing', () => {
it('should call apiClient.getPricing and return SponsorshipPricingViewModel', async () => {
const mockDto = {
pricing: [
{ entityType: 'league', price: 100 },
{ entityType: 'driver', price: 50 },
],
};
mockApiClient.getPricing.mockResolvedValue(mockDto);
const result = await service.getSponsorshipPricing();
expect(mockApiClient.getPricing).toHaveBeenCalled();
expect(result).toBeInstanceOf(SponsorshipPricingViewModel);
expect(result.mainSlotPrice).toBe(100);
expect(result.secondarySlotPrice).toBe(50);
expect(result.currency).toBe('USD');
});
it('should handle missing entity types with default prices', async () => {
const mockDto = {
pricing: [],
};
mockApiClient.getPricing.mockResolvedValue(mockDto);
const result = await service.getSponsorshipPricing();
expect(result.mainSlotPrice).toBe(0);
expect(result.secondarySlotPrice).toBe(0);
expect(result.currency).toBe('USD');
});
it('should throw error when apiClient.getPricing fails', async () => {
const error = new Error('API call failed');
mockApiClient.getPricing.mockRejectedValue(error);
await expect(service.getSponsorshipPricing()).rejects.toThrow('API call failed');
});
});
describe('getSponsorSponsorships', () => {
it('should call apiClient.getSponsorships and return SponsorSponsorshipsViewModel when data exists', async () => {
const mockDto = {
sponsorId: 'sponsor-1',
sponsorName: 'Test Sponsor',
};
mockApiClient.getSponsorships.mockResolvedValue(mockDto);
const result = await service.getSponsorSponsorships('sponsor-1');
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith('sponsor-1');
expect(result).toBeInstanceOf(SponsorSponsorshipsViewModel);
expect(result?.sponsorId).toBe('sponsor-1');
expect(result?.sponsorName).toBe('Test Sponsor');
});
it('should return null when apiClient.getSponsorships returns null', async () => {
mockApiClient.getSponsorships.mockResolvedValue(null);
const result = await service.getSponsorSponsorships('sponsor-1');
expect(mockApiClient.getSponsorships).toHaveBeenCalledWith('sponsor-1');
expect(result).toBeNull();
});
it('should throw error when apiClient.getSponsorships fails', async () => {
const error = new Error('API call failed');
mockApiClient.getSponsorships.mockRejectedValue(error);
await expect(service.getSponsorSponsorships('sponsor-1')).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,13 +1,11 @@
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import type { GetEntitySponsorshipPricingResultDto } from '../../api/sponsors/SponsorsApiClient';
import {
SponsorshipPricingViewModel,
SponsorSponsorshipsViewModel
} from '../../view-models';
import type { SponsorSponsorshipsDTO } from '../../types/generated';
// TODO: Move these types to apps/website/lib/types/generated when available
type GetEntitySponsorshipPricingResultDto = { pricing: Array<{ entityType: string; price: number }> };
/**
* Sponsorship Service
*

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from 'vitest';
import { TeamJoinService } from './TeamJoinService';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
describe('TeamJoinService', () => {
let service: TeamJoinService;
let mockApiClient: TeamsApiClient;
beforeEach(() => {
mockApiClient = {
getJoinRequests: vi.fn(),
} as any;
service = new TeamJoinService(mockApiClient);
});
describe('getJoinRequests', () => {
it('should return view models for join requests', async () => {
const mockDto = {
requests: [
{
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2023-01-01T00:00:00Z',
message: 'Please accept me',
},
{
id: 'request-2',
teamId: 'team-1',
driverId: 'driver-2',
requestedAt: '2023-01-02T00:00:00Z',
},
],
};
mockApiClient.getJoinRequests = vi.fn().mockResolvedValue(mockDto);
const result = await service.getJoinRequests('team-1', 'user-1', true);
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
expect(result).toHaveLength(2);
expect(result[0].id).toBe('request-1');
expect(result[0].canApprove).toBe(true);
expect(result[1].id).toBe('request-2');
expect(result[1].canApprove).toBe(true);
});
it('should pass correct parameters to view model constructor', async () => {
const mockDto = {
requests: [
{
id: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
requestedAt: '2023-01-01T00:00:00Z',
},
],
};
mockApiClient.getJoinRequests = vi.fn().mockResolvedValue(mockDto);
const result = await service.getJoinRequests('team-1', 'user-1', false);
expect(result[0].canApprove).toBe(false);
});
});
describe('approveJoinRequest', () => {
it('should throw not implemented error', async () => {
await expect(service.approveJoinRequest()).rejects.toThrow('Not implemented: API endpoint for approving join requests');
});
});
describe('rejectJoinRequest', () => {
it('should throw not implemented error', async () => {
await expect(service.rejectJoinRequest()).rejects.toThrow('Not implemented: API endpoint for rejecting join requests');
});
});
});

View File

@@ -1,12 +1,9 @@
import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from '@/lib/view-models/TeamJoinRequestViewModel';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import { TeamJoinRequestViewModel } from '../../view-models';
type TeamJoinRequestDTO = {
id: string;
teamId: string;
driverId: string;
requestedAt: string;
message?: string;
// TODO: Create generated DTO when API spec is available
type TeamJoinRequestsDto = {
requests: TeamJoinRequestDTO[];
};
/**
@@ -24,14 +21,14 @@ export class TeamJoinService {
* Get team join requests with view model transformation
*/
async getJoinRequests(teamId: string, currentUserId: string, isOwner: boolean): Promise<TeamJoinRequestViewModel[]> {
const dto = await this.apiClient.getJoinRequests(teamId);
const dto = await this.apiClient.getJoinRequests(teamId) as TeamJoinRequestsDto;
return dto.requests.map((r: TeamJoinRequestDTO) => new TeamJoinRequestViewModel(r, currentUserId, isOwner));
}
/**
* Approve a team join request
*/
async approveJoinRequest(teamId: string, requestId: string): Promise<void> {
async approveJoinRequest(): Promise<void> {
// TODO: implement API call when endpoint is available
throw new Error('Not implemented: API endpoint for approving join requests');
}
@@ -39,7 +36,7 @@ export class TeamJoinService {
/**
* Reject a team join request
*/
async rejectJoinRequest(teamId: string, requestId: string): Promise<void> {
async rejectJoinRequest(): Promise<void> {
// TODO: implement API call when endpoint is available
throw new Error('Not implemented: API endpoint for rejecting join requests');
}

View File

@@ -0,0 +1,205 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { TeamService } from './TeamService';
import { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import { TeamSummaryViewModel } from '../../view-models/TeamSummaryViewModel';
import { TeamDetailsViewModel } from '../../view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '../../view-models/TeamMemberViewModel';
describe('TeamService', () => {
let mockApiClient: Mocked<TeamsApiClient>;
let service: TeamService;
beforeEach(() => {
mockApiClient = {
getAll: vi.fn(),
getDetails: vi.fn(),
getMembers: vi.fn(),
create: vi.fn(),
update: vi.fn(),
getDriverTeam: vi.fn(),
} as Mocked<TeamsApiClient>;
service = new TeamService(mockApiClient);
});
describe('getAllTeams', () => {
it('should call apiClient.getAll and return array of TeamSummaryViewModel', async () => {
const mockDto = {
teams: [
{
id: 'team-1',
name: 'Test Team',
logoUrl: 'https://example.com/logo.png',
memberCount: 5,
rating: 1500,
},
],
};
mockApiClient.getAll.mockResolvedValue(mockDto);
const result = await service.getAllTeams();
expect(mockApiClient.getAll).toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(TeamSummaryViewModel);
expect(result[0].id).toBe('team-1');
expect(result[0].name).toBe('Test Team');
});
it('should throw error when apiClient.getAll fails', async () => {
const error = new Error('API call failed');
mockApiClient.getAll.mockRejectedValue(error);
await expect(service.getAllTeams()).rejects.toThrow('API call failed');
});
});
describe('getTeamDetails', () => {
it('should call apiClient.getDetails and return TeamDetailsViewModel when data exists', async () => {
const mockDto = {
id: 'team-1',
name: 'Test Team',
description: 'A test team',
logoUrl: 'https://example.com/logo.png',
memberCount: 5,
ownerId: 'owner-1',
members: [],
};
mockApiClient.getDetails.mockResolvedValue(mockDto);
const result = await service.getTeamDetails('team-1', 'user-1');
expect(mockApiClient.getDetails).toHaveBeenCalledWith('team-1');
expect(result).toBeInstanceOf(TeamDetailsViewModel);
expect(result?.id).toBe('team-1');
expect(result?.name).toBe('Test Team');
});
it('should return null when apiClient.getDetails returns null', async () => {
mockApiClient.getDetails.mockResolvedValue(null);
const result = await service.getTeamDetails('team-1', 'user-1');
expect(mockApiClient.getDetails).toHaveBeenCalledWith('team-1');
expect(result).toBeNull();
});
it('should throw error when apiClient.getDetails fails', async () => {
const error = new Error('API call failed');
mockApiClient.getDetails.mockRejectedValue(error);
await expect(service.getTeamDetails('team-1', 'user-1')).rejects.toThrow('API call failed');
});
});
describe('getTeamMembers', () => {
it('should call apiClient.getMembers and return array of TeamMemberViewModel', async () => {
const mockDto = {
members: [
{
driverId: 'driver-1',
driver: { id: 'driver-1', name: 'Driver One', avatarUrl: 'avatar.png', iracingId: '123', rating: 1400 },
role: 'member',
joinedAt: '2023-01-01T00:00:00Z',
},
],
};
mockApiClient.getMembers.mockResolvedValue(mockDto);
const result = await service.getTeamMembers('team-1', 'user-1', 'owner-1');
expect(mockApiClient.getMembers).toHaveBeenCalledWith('team-1');
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(TeamMemberViewModel);
expect(result[0].driverId).toBe('driver-1');
expect(result[0].role).toBe('member');
});
it('should throw error when apiClient.getMembers fails', async () => {
const error = new Error('API call failed');
mockApiClient.getMembers.mockRejectedValue(error);
await expect(service.getTeamMembers('team-1', 'user-1', 'owner-1')).rejects.toThrow('API call failed');
});
});
describe('createTeam', () => {
it('should call apiClient.create and return the result', async () => {
const input = { name: 'New Team', tag: 'NT', description: 'A new team' };
const mockOutput = { id: 'team-123', success: true };
mockApiClient.create.mockResolvedValue(mockOutput);
const result = await service.createTeam(input);
expect(mockApiClient.create).toHaveBeenCalledWith(input);
expect(result).toEqual(mockOutput);
});
it('should throw error when apiClient.create fails', async () => {
const input = { name: 'New Team', tag: 'NT' };
const error = new Error('API call failed');
mockApiClient.create.mockRejectedValue(error);
await expect(service.createTeam(input)).rejects.toThrow('API call failed');
});
});
describe('updateTeam', () => {
it('should call apiClient.update and return the result', async () => {
const input = { name: 'Updated Team', description: 'Updated description' };
const mockOutput = { success: true };
mockApiClient.update.mockResolvedValue(mockOutput);
const result = await service.updateTeam('team-1', input);
expect(mockApiClient.update).toHaveBeenCalledWith('team-1', input);
expect(result).toEqual(mockOutput);
});
it('should throw error when apiClient.update fails', async () => {
const input = { name: 'Updated Team' };
const error = new Error('API call failed');
mockApiClient.update.mockRejectedValue(error);
await expect(service.updateTeam('team-1', input)).rejects.toThrow('API call failed');
});
});
describe('getDriverTeam', () => {
it('should call apiClient.getDriverTeam and return the result', async () => {
const mockOutput = { teamId: 'team-1', teamName: 'Test Team', role: 'member' };
mockApiClient.getDriverTeam.mockResolvedValue(mockOutput);
const result = await service.getDriverTeam('driver-1');
expect(mockApiClient.getDriverTeam).toHaveBeenCalledWith('driver-1');
expect(result).toEqual(mockOutput);
});
it('should return null when apiClient.getDriverTeam returns null', async () => {
mockApiClient.getDriverTeam.mockResolvedValue(null);
const result = await service.getDriverTeam('driver-1');
expect(mockApiClient.getDriverTeam).toHaveBeenCalledWith('driver-1');
expect(result).toBeNull();
});
it('should throw error when apiClient.getDriverTeam fails', async () => {
const error = new Error('API call failed');
mockApiClient.getDriverTeam.mockRejectedValue(error);
await expect(service.getDriverTeam('driver-1')).rejects.toThrow('API call failed');
});
});
});

View File

@@ -1,14 +1,20 @@
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { CreateTeamViewModel } from '@/lib/view-models/CreateTeamViewModel';
import { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
import { DriverTeamViewModel } from '@/lib/view-models/DriverTeamViewModel';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
import { TeamSummaryViewModel, TeamDetailsViewModel, TeamMemberViewModel } from '../../view-models';
// TODO: Move these types to apps/website/lib/types/generated when available
type DriverDTO = { id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number };
type CreateTeamInputDto = { name: string; tag: string; description?: string };
type CreateTeamOutputDto = { id: string; success: boolean };
type UpdateTeamInputDto = { name?: string; tag?: string; description?: string };
type UpdateTeamOutputDto = { success: boolean };
type DriverTeamDto = { teamId: string; teamName: string; role: string };
type TeamSummaryDTO = { id: string; name: string; logoUrl?: string; memberCount: number; rating: number };
type TeamMemberDTO = { driverId: string; driver?: any; role: string; joinedAt: string };
type TeamMemberDTO = { driverId: string; driver?: DriverDTO; role: string; joinedAt: string };
/**
* Team Service
@@ -49,23 +55,26 @@ export class TeamService {
}
/**
* Create a new team
* Create a new team with view model transformation
*/
async createTeam(input: CreateTeamInputDto): Promise<CreateTeamOutputDto> {
return await this.apiClient.create(input);
async createTeam(input: CreateTeamInputDto): Promise<CreateTeamViewModel> {
const dto = await this.apiClient.create(input);
return new CreateTeamViewModel(dto);
}
/**
* Update team
* Update team with view model transformation
*/
async updateTeam(teamId: string, input: UpdateTeamInputDto): Promise<UpdateTeamOutputDto> {
return await this.apiClient.update(teamId, input);
async updateTeam(teamId: string, input: UpdateTeamInputDto): Promise<UpdateTeamViewModel> {
const dto = await this.apiClient.update(teamId, input);
return new UpdateTeamViewModel(dto);
}
/**
* Get driver's team
* Get driver's team with view model transformation
*/
async getDriverTeam(driverId: string): Promise<DriverTeamDto | null> {
return await this.apiClient.getDriverTeam(driverId);
async getDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
const dto = await this.apiClient.getDriverTeam(driverId);
return dto ? new DriverTeamViewModel(dto) : null;
}
}

View File

@@ -0,0 +1,21 @@
import { CreateLeagueOutputDTO } from '../types/generated/CreateLeagueOutputDTO';
/**
* View Model for Create League Result
*
* Represents the result of creating a league in a UI-ready format.
*/
export class CreateLeagueViewModel implements CreateLeagueOutputDTO {
leagueId: string;
success: boolean;
constructor(dto: CreateLeagueOutputDTO) {
this.leagueId = dto.leagueId;
this.success = dto.success;
}
/** UI-specific: Success message */
get successMessage(): string {
return this.success ? 'League created successfully!' : 'Failed to create league.';
}
}

View File

@@ -0,0 +1,19 @@
/**
* View Model for Create Team Result
*
* Represents the result of creating a team in a UI-ready format.
*/
export class CreateTeamViewModel {
id: string;
success: boolean;
constructor(dto: { id: string; success: boolean }) {
this.id = dto.id;
this.success = dto.success;
}
/** UI-specific: Success message */
get successMessage(): string {
return this.success ? 'Team created successfully!' : 'Failed to create team.';
}
}

View File

@@ -0,0 +1,26 @@
/**
* View Model for Driver's Team
*
* Represents a driver's team membership in a UI-ready format.
*/
export class DriverTeamViewModel {
teamId: string;
teamName: string;
role: string;
constructor(dto: { teamId: string; teamName: string; role: string }) {
this.teamId = dto.teamId;
this.teamName = dto.teamName;
this.role = dto.role;
}
/** UI-specific: Display role */
get displayRole(): string {
return this.role.charAt(0).toUpperCase() + this.role.slice(1);
}
/** UI-specific: Is owner */
get isOwner(): boolean {
return this.role === 'owner';
}
}

View File

@@ -0,0 +1,20 @@
// TODO: Create ImportRaceResultsSummaryDTO in apps/website/lib/types/generated when available
interface ImportRaceResultsSummaryDTO {
raceId: string;
importedCount: number;
errors: string[];
}
export class ImportRaceResultsSummaryViewModel implements ImportRaceResultsSummaryDTO {
raceId: string;
importedCount: number;
errors: string[];
constructor(dto: ImportRaceResultsSummaryDTO) {
this.raceId = dto.raceId;
this.importedCount = dto.importedCount;
this.errors = dto.errors;
}
// TODO: Add additional UI-specific fields when DTO is available
}

View File

@@ -1,4 +1,5 @@
import { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO';
import { DriverViewModel } from './DriverViewModel';
export class LeagueMemberViewModel implements LeagueMemberDTO {
driverId: string;
@@ -12,7 +13,7 @@ export class LeagueMemberViewModel implements LeagueMemberDTO {
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
driver?: any;
driver?: DriverViewModel;
role: string = 'member';
joinedAt: string = new Date().toISOString();

View File

@@ -0,0 +1,25 @@
import { LeagueMemberViewModel } from './LeagueMemberViewModel';
import type { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO';
/**
* View Model for League Memberships
*
* Represents the league's memberships in a UI-ready format.
*/
export class LeagueMembershipsViewModel {
memberships: LeagueMemberViewModel[];
constructor(dto: { memberships: LeagueMemberDTO[] }, currentUserId: string) {
this.memberships = dto.memberships.map(membership => new LeagueMemberViewModel(membership, currentUserId));
}
/** UI-specific: Number of members */
get memberCount(): number {
return this.memberships.length;
}
/** UI-specific: Whether the league has members */
get hasMembers(): boolean {
return this.memberCount > 0;
}
}

View File

@@ -0,0 +1,22 @@
/**
* View Model for League Schedule
*
* Represents the league's race schedule in a UI-ready format.
*/
export class LeagueScheduleViewModel {
races: Array<unknown>;
constructor(dto: { races: Array<unknown> }) {
this.races = dto.races;
}
/** UI-specific: Number of races in the schedule */
get raceCount(): number {
return this.races.length;
}
/** UI-specific: Whether the schedule has races */
get hasRaces(): boolean {
return this.raceCount > 0;
}
}

View File

@@ -0,0 +1,17 @@
/**
* View Model for League Statistics
*
* Represents the total number of leagues in a UI-ready format.
*/
export class LeagueStatsViewModel {
totalLeagues: number;
constructor(dto: { totalLeagues: number }) {
this.totalLeagues = dto.totalLeagues;
}
/** UI-specific: Formatted total leagues display */
get formattedTotalLeagues(): string {
return this.totalLeagues.toLocaleString();
}
}

View File

@@ -1,37 +1,51 @@
import { MembershipFeeDto } from '../types/generated/MembershipFeeDto';
import type { MembershipFeeDto } from '../types/generated';
export class MembershipFeeViewModel implements MembershipFeeDto {
export class MembershipFeeViewModel {
id: string;
leagueId: string;
seasonId?: string;
type: string;
amount: number;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
constructor(dto: MembershipFeeDto) {
this.id = dto.id;
this.leagueId = dto.leagueId;
Object.assign(this, dto);
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
amount: number = 0;
currency: string = 'USD';
period: string = 'monthly';
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.currency} ${this.amount.toFixed(2)}`;
return `${this.amount.toFixed(2)}`; // Assuming EUR
}
/** UI-specific: Period display */
get periodDisplay(): string {
switch (this.period) {
case 'monthly': return 'Monthly';
case 'yearly': return 'Yearly';
/** UI-specific: Type display */
get typeDisplay(): string {
switch (this.type) {
case 'season': return 'Per Season';
default: return this.period;
case 'monthly': return 'Monthly';
case 'per_race': return 'Per Race';
default: return this.type;
}
}
/** UI-specific: Amount per period */
get amountPerPeriod(): string {
return `${this.formattedAmount} ${this.periodDisplay.toLowerCase()}`;
/** UI-specific: Status display */
get statusDisplay(): string {
return this.enabled ? 'Enabled' : 'Disabled';
}
/** UI-specific: Status color */
get statusColor(): string {
return this.enabled ? 'green' : 'red';
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return this.createdAt.toLocaleString();
}
/** UI-specific: Formatted updated date */
get formattedUpdatedAt(): string {
return this.updatedAt.toLocaleString();
}
}

View File

@@ -1,19 +1,31 @@
import { PaymentDTO } from '../types/generated/PaymentDto';
import type { PaymentDto } from '../types/generated';
export class PaymentViewModel implements PaymentDTO {
export class PaymentViewModel {
id: string;
type: string;
amount: number;
currency: string;
platformFee: number;
netAmount: number;
payerId: string;
payerType: string;
leagueId: string;
seasonId?: string;
status: string;
createdAt: string;
createdAt: Date;
completedAt?: Date;
constructor(dto: PaymentDTO) {
constructor(dto: PaymentDto) {
Object.assign(this, dto);
}
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.currency} ${this.amount.toFixed(2)}`;
return `${this.amount.toFixed(2)}`; // Assuming EUR currency
}
/** UI-specific: Formatted net amount */
get formattedNetAmount(): string {
return `${this.netAmount.toFixed(2)}`;
}
/** UI-specific: Status color */
@@ -22,17 +34,33 @@ export class PaymentViewModel implements PaymentDTO {
case 'completed': return 'green';
case 'pending': return 'yellow';
case 'failed': return 'red';
case 'refunded': return 'orange';
default: return 'gray';
}
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return new Date(this.createdAt).toLocaleString();
return this.createdAt.toLocaleString();
}
/** UI-specific: Formatted completed date */
get formattedCompletedAt(): string {
return this.completedAt ? this.completedAt.toLocaleString() : 'Not completed';
}
/** UI-specific: Status display */
get statusDisplay(): string {
return this.status.charAt(0).toUpperCase() + this.status.slice(1);
}
/** UI-specific: Type display */
get typeDisplay(): string {
return this.type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
}
/** UI-specific: Payer type display */
get payerTypeDisplay(): string {
return this.payerType.charAt(0).toUpperCase() + this.payerType.slice(1);
}
}

View File

@@ -1,29 +1,26 @@
import { PrizeDto } from '../types/generated/PrizeDto';
import type { PrizeDto } from '../types/generated';
export class PrizeViewModel implements PrizeDto {
export class PrizeViewModel {
id: string;
leagueId: string;
seasonId: string;
position: number;
name: string;
amount: number;
type: string;
description?: string;
awarded: boolean;
awardedTo?: string;
awardedAt?: Date;
createdAt: Date;
constructor(dto: PrizeDto) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.seasonId = dto.seasonId;
this.position = dto.position;
this.name = dto.name;
this.amount = dto.amount;
Object.assign(this, dto);
}
// Note: The generated DTO doesn't have currency
// This will need to be added when the OpenAPI spec is updated
currency: string = 'USD';
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.currency} ${this.amount.toFixed(2)}`;
return `${this.amount.toFixed(2)}`; // Assuming EUR
}
/** UI-specific: Position display */
@@ -36,8 +33,38 @@ export class PrizeViewModel implements PrizeDto {
}
}
/** UI-specific: Type display */
get typeDisplay(): string {
switch (this.type) {
case 'cash': return 'Cash Prize';
case 'merchandise': return 'Merchandise';
case 'other': return 'Other';
default: return this.type;
}
}
/** UI-specific: Status display */
get statusDisplay(): string {
return this.awarded ? 'Awarded' : 'Available';
}
/** UI-specific: Status color */
get statusColor(): string {
return this.awarded ? 'green' : 'blue';
}
/** UI-specific: Prize description */
get prizeDescription(): string {
return `${this.name} - ${this.formattedAmount}`;
}
/** UI-specific: Formatted awarded date */
get formattedAwardedAt(): string {
return this.awardedAt ? this.awardedAt.toLocaleString() : 'Not awarded';
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return this.createdAt.toLocaleString();
}
}

View File

@@ -0,0 +1,18 @@
import { RaceStatsDTO } from '../types/generated';
/**
* Race stats view model
* Represents race statistics for display
*/
export class RaceStatsViewModel {
totalRaces: number;
constructor(dto: RaceStatsDTO) {
this.totalRaces = dto.totalRaces;
}
/** UI-specific: Formatted total races */
get formattedTotalRaces(): string {
return this.totalRaces.toLocaleString();
}
}

View File

@@ -0,0 +1,15 @@
import { RaceWithSOFDTO } from '../types/generated/RaceWithSOFDTO';
export class RaceWithSOFViewModel implements RaceWithSOFDTO {
id: string;
track: string;
constructor(dto: RaceWithSOFDTO) {
this.id = dto.id;
this.track = dto.track;
}
// TODO: Add additional fields when RaceWithSOFDTO is updated in OpenAPI spec
// sof?: number;
// results?: RaceResultViewModel[];
}

View File

@@ -0,0 +1,30 @@
import { RecordEngagementOutputDTO } from '../types/generated';
/**
* Record engagement output view model
* Represents the result of recording an engagement event for UI consumption
*/
export class RecordEngagementOutputViewModel {
eventId: string;
engagementWeight: number;
constructor(dto: RecordEngagementOutputDTO) {
this.eventId = dto.eventId;
this.engagementWeight = dto.engagementWeight;
}
/** UI-specific: Formatted event ID for display */
get displayEventId(): string {
return `Event: ${this.eventId}`;
}
/** UI-specific: Formatted engagement weight */
get displayEngagementWeight(): string {
return `${this.engagementWeight.toFixed(2)}`;
}
/** UI-specific: Is high engagement */
get isHighEngagement(): boolean {
return this.engagementWeight > 1.0;
}
}

View File

@@ -0,0 +1,18 @@
import { RecordPageViewOutputDTO } from '../types/generated';
/**
* Record page view output view model
* Represents the result of recording a page view for UI consumption
*/
export class RecordPageViewOutputViewModel {
pageViewId: string;
constructor(dto: RecordPageViewOutputDTO) {
this.pageViewId = dto.pageViewId;
}
/** UI-specific: Formatted page view ID for display */
get displayPageViewId(): string {
return `Page View: ${this.pageViewId}`;
}
}

View File

@@ -0,0 +1,19 @@
import { RemoveLeagueMemberOutputDTO } from '../types/generated/RemoveLeagueMemberOutputDTO';
/**
* View Model for Remove Member Result
*
* Represents the result of removing a member from a league in a UI-ready format.
*/
export class RemoveMemberViewModel implements RemoveLeagueMemberOutputDTO {
success: boolean;
constructor(dto: RemoveLeagueMemberOutputDTO) {
this.success = dto.success;
}
/** UI-specific: Success message */
get successMessage(): string {
return this.success ? 'Member removed successfully!' : 'Failed to remove member.';
}
}

View File

@@ -1,6 +1,19 @@
import { TeamMemberViewModel } from './TeamMemberViewModel';
// Note: No generated DTO available for TeamDetails yet
interface DriverDTO {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
}
interface TeamMemberDTO {
driverId: string;
driver?: DriverDTO;
role: string;
joinedAt: string;
}
interface TeamDetailsDTO {
id: string;
name: string;
@@ -8,7 +21,7 @@ interface TeamDetailsDTO {
logoUrl?: string;
memberCount: number;
ownerId: string;
members: any[];
members: TeamMemberDTO[];
}
export class TeamDetailsViewModel {

View File

@@ -1,5 +1,5 @@
// Note: No generated DTO available for TeamJoinRequest yet
interface TeamJoinRequestDTO {
export interface TeamJoinRequestDTO {
id: string;
teamId: string;
driverId: string;

View File

@@ -1,7 +1,15 @@
// Note: No generated DTO available for TeamMember yet
interface DriverDTO {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
}
interface TeamMemberDTO {
driverId: string;
driver?: any;
driver?: DriverDTO;
role: string;
joinedAt: string;
}

View File

@@ -0,0 +1,17 @@
/**
* View Model for Update Team Result
*
* Represents the result of updating a team in a UI-ready format.
*/
export class UpdateTeamViewModel {
success: boolean;
constructor(dto: { success: boolean }) {
this.success = dto.success;
}
/** UI-specific: Success message */
get successMessage(): string {
return this.success ? 'Team updated successfully!' : 'Failed to update team.';
}
}

View File

@@ -1,24 +1,30 @@
import { TransactionDto } from '../types/generated/TransactionDto';
export class WalletTransactionViewModel implements TransactionDto {
// TODO: Use generated TransactionDto when it includes all required fields
export type FullTransactionDto = TransactionDto & {
amount: number;
description: string;
createdAt: string;
type: 'deposit' | 'withdrawal';
};
export class WalletTransactionViewModel implements FullTransactionDto {
id: string;
walletId: string;
amount: number;
description: string;
createdAt: string;
type: 'deposit' | 'withdrawal';
constructor(dto: TransactionDto) {
constructor(dto: FullTransactionDto) {
this.id = dto.id;
this.walletId = dto.walletId;
this.amount = dto.amount;
this.description = dto.description;
this.createdAt = dto.createdAt;
this.type = dto.type;
}
// Note: The generated DTO doesn't have type field
// This will need to be added when the OpenAPI spec is updated
type: 'deposit' | 'withdrawal' = 'deposit';
/** UI-specific: Formatted amount with sign */
get formattedAmount(): string {
const sign = this.type === 'deposit' ? '+' : '-';

View File

@@ -1,5 +1,5 @@
import { WalletDto } from '../types/generated/WalletDto';
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
import { WalletTransactionViewModel, FullTransactionDto } from './WalletTransactionViewModel';
export class WalletViewModel implements WalletDto {
id: string;
@@ -11,7 +11,7 @@ export class WalletViewModel implements WalletDto {
createdAt: string;
currency: string;
constructor(dto: WalletDto & { transactions?: any[] }) {
constructor(dto: WalletDto & { transactions?: FullTransactionDto[] }) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.balance = dto.balance;
@@ -20,16 +20,11 @@ export class WalletViewModel implements WalletDto {
this.totalWithdrawn = dto.totalWithdrawn;
this.createdAt = dto.createdAt;
this.currency = dto.currency;
// Map transactions if provided
if (dto.transactions) {
this.transactions = dto.transactions.map(t => new WalletTransactionViewModel(t));
}
this.transactions = dto.transactions?.map(t => new WalletTransactionViewModel(t)) || [];
}
// Note: The generated DTO doesn't have driverId or transactions
// These will need to be added when the OpenAPI spec is updated
driverId: string = '';
transactions: WalletTransactionViewModel[] = [];
/** UI-specific: Formatted balance */

View File

@@ -1,48 +0,0 @@
/**
* View Models Index
*
* Central export file for all view models.
* View models represent fully prepared UI state and should only be consumed by UI components.
*/
export { AnalyticsDashboardViewModel } from './AnalyticsDashboardViewModel';
export { AnalyticsMetricsViewModel } from './AnalyticsMetricsViewModel';
export { AvatarViewModel } from './AvatarViewModel';
export { CompleteOnboardingViewModel } from './CompleteOnboardingViewModel';
export { DeleteMediaViewModel } from './DeleteMediaViewModel';
export { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
export { DriverLeaderboardViewModel } from './DriverLeaderboardViewModel';
export { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel';
export { DriverViewModel } from './DriverViewModel';
export { LeagueAdminViewModel } from './LeagueAdminViewModel';
export { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
export { LeagueMemberViewModel } from './LeagueMemberViewModel';
export { LeagueStandingsViewModel } from './LeagueStandingsViewModel';
export { LeagueSummaryViewModel } from './LeagueSummaryViewModel';
export { MediaViewModel } from './MediaViewModel';
export { MembershipFeeViewModel } from './MembershipFeeViewModel';
export { PaymentViewModel } from './PaymentViewModel';
export { PrizeViewModel } from './PrizeViewModel';
export { ProtestViewModel } from './ProtestViewModel';
export { RaceDetailViewModel } from './RaceDetailViewModel';
export { RaceListItemViewModel } from './RaceListItemViewModel';
export { RaceResultsDetailViewModel } from './RaceResultsDetailViewModel';
export { RaceResultViewModel } from './RaceResultViewModel';
export { RacesPageViewModel } from './RacesPageViewModel';
export { RequestAvatarGenerationViewModel } from './RequestAvatarGenerationViewModel';
export { SessionViewModel } from './SessionViewModel';
export { SponsorDashboardViewModel } from './SponsorDashboardViewModel';
export { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
export { SponsorshipPricingViewModel } from './SponsorshipPricingViewModel';
export { SponsorSponsorshipsViewModel } from './SponsorSponsorshipsViewModel';
export { SponsorViewModel } from './SponsorViewModel';
export { StandingEntryViewModel } from './StandingEntryViewModel';
export { TeamDetailsViewModel } from './TeamDetailsViewModel';
export { TeamJoinRequestViewModel } from './TeamJoinRequestViewModel';
export { TeamMemberViewModel } from './TeamMemberViewModel';
export { TeamSummaryViewModel } from './TeamSummaryViewModel';
export { UpdateAvatarViewModel } from './UpdateAvatarViewModel';
export { UploadMediaViewModel } from './UploadMediaViewModel';
export { UserProfileViewModel } from './UserProfileViewModel';
export { WalletTransactionViewModel } from './WalletTransactionViewModel';
export { WalletViewModel } from './WalletViewModel';

View File

@@ -7,7 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"type-check": "npx tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
@@ -35,6 +35,7 @@
"eslint": "^8.57.0",
"eslint-config-next": "15.5.7",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-unused-imports": "^3.0.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.6.0"

View File

@@ -8,6 +8,9 @@
"baseUrl": ".",
"jsx": "preserve",
"incremental": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmitOnError": true,
"plugins": [
{
"name": "next"