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"

View File

@@ -1,10 +1,10 @@
Form Models
Command Models
This document defines Form Models as a first-class concept in the frontend architecture.
Form Models are UX-only write models used to collect, validate, and prepare user input
This document defines Command Models as a first-class concept in the frontend architecture.
Command Models are UX-only write models used to collect, validate, and prepare user input
before it is sent to the backend as a Command DTO.
Form Models are not View Models and not Domain Models.
Command Models are not View Models and not Domain Models.
@@ -14,7 +14,7 @@ A Form Model answers the question:
“What does the UI need in order to safely submit user input?”
Form Models exist to:
Command Models exist to:
• centralize form state
• reduce logic inside components
• provide consistent client-side validation
@@ -24,13 +24,13 @@ Form Models exist to:
Core Rules
Form Models:
Command Models:
• exist only in the frontend
• are write-only (never reused for reads)
• are created per form
• are discarded after submission
Form Models MUST NOT:
Command Models MUST NOT:
• contain business logic
• enforce domain rules
• reference View Models
@@ -46,7 +46,7 @@ API DTO (read) → ViewModel → UI
UI Input → FormModel → Command DTO → API
• View Models are read-only
Form Models are write-only
Command Models are write-only
• No model is reused across read/write boundaries
@@ -137,20 +137,20 @@ The component:
Testing
Form Models SHOULD be tested when they contain:
Command Models SHOULD be tested when they contain:
• validation rules
• non-trivial state transitions
• command construction logic
Form Models do NOT need tests if they only hold fields without logic.
Command Models do NOT need tests if they only hold fields without logic.
Summary
Form Models are UX helpers for writes
Command Models are UX helpers for writes
• They protect components from complexity
• They never replace backend validation
• They never leak into read flows
Form Models help users.
Command Models help users.
Use Cases protect the system.

View File

@@ -40,7 +40,7 @@
"smoke:website": "npm run website:build && npx playwright test -c playwright.website.config.ts",
"test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/",
"test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts",
"typecheck": "tsc --noEmit",
"typecheck": "npx tsc --noEmit",
"test:types": "tsc --noEmit -p tsconfig.tests.json",
"companion:dev": "npm run dev --workspace=@gridpilot/companion",
"companion:build": "npm run build --workspace=@gridpilot/companion",

View File

@@ -24,6 +24,8 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"paths": {
"@core/*": ["./core/*"],
"@adapters/*": ["./adapters/*"],