view models
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"noEmit": false,
|
||||
"noEmitOnError": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"removeComments": true,
|
||||
|
||||
@@ -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": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
146
apps/website/lib/services/auth/AuthService.test.ts
Normal file
146
apps/website/lib/services/auth/AuthService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
57
apps/website/lib/services/auth/SessionService.test.ts
Normal file
57
apps/website/lib/services/auth/SessionService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
130
apps/website/lib/services/drivers/DriverService.test.ts
Normal file
130
apps/website/lib/services/drivers/DriverService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
299
apps/website/lib/services/leagues/LeagueService.test.ts
Normal file
299
apps/website/lib/services/leagues/LeagueService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
121
apps/website/lib/services/media/AvatarService.test.ts
Normal file
121
apps/website/lib/services/media/AvatarService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
159
apps/website/lib/services/media/MediaService.test.ts
Normal file
159
apps/website/lib/services/media/MediaService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
244
apps/website/lib/services/payments/PaymentService.test.ts
Normal file
244
apps/website/lib/services/payments/PaymentService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
77
apps/website/lib/services/payments/WalletService.test.ts
Normal file
77
apps/website/lib/services/payments/WalletService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[] });
|
||||
}
|
||||
}
|
||||
149
apps/website/lib/services/races/RaceResultsService.test.ts
Normal file
149
apps/website/lib/services/races/RaceResultsService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
134
apps/website/lib/services/races/RaceService.test.ts
Normal file
134
apps/website/lib/services/races/RaceService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
180
apps/website/lib/services/sponsors/SponsorService.test.ts
Normal file
180
apps/website/lib/services/sponsors/SponsorService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*
|
||||
|
||||
80
apps/website/lib/services/teams/TeamJoinService.test.ts
Normal file
80
apps/website/lib/services/teams/TeamJoinService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
205
apps/website/lib/services/teams/TeamService.test.ts
Normal file
205
apps/website/lib/services/teams/TeamService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
21
apps/website/lib/view-models/CreateLeagueViewModel.ts
Normal file
21
apps/website/lib/view-models/CreateLeagueViewModel.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
19
apps/website/lib/view-models/CreateTeamViewModel.ts
Normal file
19
apps/website/lib/view-models/CreateTeamViewModel.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
26
apps/website/lib/view-models/DriverTeamViewModel.ts
Normal file
26
apps/website/lib/view-models/DriverTeamViewModel.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
25
apps/website/lib/view-models/LeagueMembershipsViewModel.ts
Normal file
25
apps/website/lib/view-models/LeagueMembershipsViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
apps/website/lib/view-models/LeagueScheduleViewModel.ts
Normal file
22
apps/website/lib/view-models/LeagueScheduleViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
apps/website/lib/view-models/LeagueStatsViewModel.ts
Normal file
17
apps/website/lib/view-models/LeagueStatsViewModel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
18
apps/website/lib/view-models/RaceStatsViewModel.ts
Normal file
18
apps/website/lib/view-models/RaceStatsViewModel.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
15
apps/website/lib/view-models/RaceWithSOFViewModel.ts
Normal file
15
apps/website/lib/view-models/RaceWithSOFViewModel.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
19
apps/website/lib/view-models/RemoveMemberViewModel.ts
Normal file
19
apps/website/lib/view-models/RemoveMemberViewModel.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Note: No generated DTO available for TeamJoinRequest yet
|
||||
interface TeamJoinRequestDTO {
|
||||
export interface TeamJoinRequestDTO {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
17
apps/website/lib/view-models/UpdateTeamViewModel.ts
Normal file
17
apps/website/lib/view-models/UpdateTeamViewModel.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
@@ -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' ? '+' : '-';
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"baseUrl": ".",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmitOnError": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"paths": {
|
||||
"@core/*": ["./core/*"],
|
||||
"@adapters/*": ["./adapters/*"],
|
||||
|
||||
Reference in New Issue
Block a user