diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 38400f259..8d8cdeca8 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -8,6 +8,9 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "noEmit": false, + "noEmitOnError": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "declaration": true, "declarationMap": true, "removeComments": true, diff --git a/apps/website/.eslintrc.json b/apps/website/.eslintrc.json index 59e69a67d..0505de9b1 100644 --- a/apps/website/.eslintrc.json +++ b/apps/website/.eslintrc.json @@ -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": "^_" + } ] } } \ No newline at end of file diff --git a/apps/website/lib/api/auth/AuthApiClient.ts b/apps/website/lib/api/auth/AuthApiClient.ts index 50feb0ad4..aa278542e 100644 --- a/apps/website/lib/api/auth/AuthApiClient.ts +++ b/apps/website/lib/api/auth/AuthApiClient.ts @@ -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 { - return this.post('/auth/signup', params); + signup(params: SignupParamsDto): Promise { + return this.post('/auth/signup', params); } /** Login with email */ - login(params: LoginParamsDto): Promise { - return this.post('/auth/login', params); + login(params: LoginParamsDto): Promise { + return this.post('/auth/login', params); } /** Get current session */ - getSession(): Promise { - return this.get('/auth/session'); + getSession(): Promise { + return this.get('/auth/session'); } /** Logout */ diff --git a/apps/website/lib/api/drivers/DriversApiClient.ts b/apps/website/lib/api/drivers/DriversApiClient.ts index 86c47470f..9b76039fc 100644 --- a/apps/website/lib/api/drivers/DriversApiClient.ts +++ b/apps/website/lib/api/drivers/DriversApiClient.ts @@ -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 { - return this.post('/drivers/complete-onboarding', input); + return this.post('/drivers/complete-onboarding', input); } /** Get current driver (based on session) */ getCurrent(): Promise { - return this.get('/drivers/current'); + return this.get('/drivers/current'); } /** Get driver registration status for a specific race */ - getRegistrationStatus(driverId: string, raceId: string): Promise { - return this.get(`/drivers/${driverId}/races/${raceId}/registration-status`); + getRegistrationStatus(driverId: string, raceId: string): Promise { + return this.get(`/drivers/${driverId}/races/${raceId}/registration-status`); } } \ No newline at end of file diff --git a/apps/website/lib/api/payments/PaymentsApiClient.ts b/apps/website/lib/api/payments/PaymentsApiClient.ts index ae9863d99..99b1bd00d 100644 --- a/apps/website/lib/api/payments/PaymentsApiClient.ts +++ b/apps/website/lib/api/payments/PaymentsApiClient.ts @@ -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 diff --git a/apps/website/lib/api/sponsors/SponsorsApiClient.ts b/apps/website/lib/api/sponsors/SponsorsApiClient.ts index 81b467e17..dbca575d7 100644 --- a/apps/website/lib/api/sponsors/SponsorsApiClient.ts +++ b/apps/website/lib/api/sponsors/SponsorsApiClient.ts @@ -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 { + create(input: CreateSponsorInputDTO): Promise { return this.post('/sponsors', input); } /** Get sponsor dashboard */ - getDashboard(sponsorId: string): Promise { - return this.get(`/sponsors/dashboard/${sponsorId}`); + getDashboard(sponsorId: string): Promise { + return this.get(`/sponsors/dashboard/${sponsorId}`); } /** Get sponsor sponsorships */ - getSponsorships(sponsorId: string): Promise { - return this.get(`/sponsors/${sponsorId}/sponsorships`); + getSponsorships(sponsorId: string): Promise { + return this.get(`/sponsors/${sponsorId}/sponsorships`); } } \ No newline at end of file diff --git a/apps/website/lib/services/analytics/AnalyticsService.test.ts b/apps/website/lib/services/analytics/AnalyticsService.test.ts index 4f16c17cd..d5e43eeaa 100644 --- a/apps/website/lib/services/analytics/AnalyticsService.test.ts +++ b/apps/website/lib/services/analytics/AnalyticsService.test.ts @@ -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; @@ -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); }); }); }); \ No newline at end of file diff --git a/apps/website/lib/services/analytics/AnalyticsService.ts b/apps/website/lib/services/analytics/AnalyticsService.ts index a628ea57e..e871d14c1 100644 --- a/apps/website/lib/services/analytics/AnalyticsService.ts +++ b/apps/website/lib/services/analytics/AnalyticsService.ts @@ -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; +} /** * Analytics Service @@ -15,31 +26,18 @@ export class AnalyticsService { ) {} /** - * Record a page view - */ - async recordPageView(input: RecordPageViewInputViewModel): Promise { - 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 { + const result = await this.apiClient.recordPageView(input); + return new RecordPageViewOutputViewModel(result); + } /** - * Record an engagement event - */ - async recordEngagement(input: RecordEngagementInputViewModel): Promise { - const apiInput: { eventType: string; userId?: string; metadata?: Record } = { - 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 { + const result = await this.apiClient.recordEngagement(input); + return new RecordEngagementOutputViewModel(result); + } } \ No newline at end of file diff --git a/apps/website/lib/services/analytics/DashboardService.test.ts b/apps/website/lib/services/analytics/DashboardService.test.ts index 2bf654250..674edcddd 100644 --- a/apps/website/lib/services/analytics/DashboardService.test.ts +++ b/apps/website/lib/services/analytics/DashboardService.test.ts @@ -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); - }); - }); }); diff --git a/apps/website/lib/services/analytics/DashboardService.ts b/apps/website/lib/services/analytics/DashboardService.ts index 1af13e867..5a4914221 100644 --- a/apps/website/lib/services/analytics/DashboardService.ts +++ b/apps/website/lib/services/analytics/DashboardService.ts @@ -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 diff --git a/apps/website/lib/services/auth/AuthService.test.ts b/apps/website/lib/services/auth/AuthService.test.ts new file mode 100644 index 000000000..a0f4a1714 --- /dev/null +++ b/apps/website/lib/services/auth/AuthService.test.ts @@ -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; + let service: AuthService; + + beforeEach(() => { + mockApiClient = { + signup: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getIracingAuthUrl: vi.fn(), + } as Mocked; + + 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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/auth/AuthService.ts b/apps/website/lib/services/auth/AuthService.ts index b9420a04e..881f9d47e 100644 --- a/apps/website/lib/services/auth/AuthService.ts +++ b/apps/website/lib/services/auth/AuthService.ts @@ -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 { + async signup(params: SignupParamsDto): Promise { 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 { + async login(params: LoginParamsDto): Promise { try { - return await this.apiClient.login(params); + const dto = await this.apiClient.login(params); + return new SessionViewModel(dto.user); } catch (error) { throw error; } diff --git a/apps/website/lib/services/auth/SessionService.test.ts b/apps/website/lib/services/auth/SessionService.test.ts new file mode 100644 index 000000000..c380d3740 --- /dev/null +++ b/apps/website/lib/services/auth/SessionService.test.ts @@ -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; + let service: SessionService; + + beforeEach(() => { + mockApiClient = { + getSession: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/auth/SessionService.ts b/apps/website/lib/services/auth/SessionService.ts index 9fa934669..5690f09cc 100644 --- a/apps/website/lib/services/auth/SessionService.ts +++ b/apps/website/lib/services/auth/SessionService.ts @@ -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 { const dto = await this.apiClient.getSession(); - return dto ? new SessionViewModel(dto) : null; + return dto ? new SessionViewModel(dto.user) : null; } } \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverRegistrationService.test.ts b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts new file mode 100644 index 000000000..77bfb0426 --- /dev/null +++ b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts @@ -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; + let service: DriverRegistrationService; + + beforeEach(() => { + mockApiClient = { + getRegistrationStatus: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverService.test.ts b/apps/website/lib/services/drivers/DriverService.test.ts new file mode 100644 index 000000000..dbeadb18b --- /dev/null +++ b/apps/website/lib/services/drivers/DriverService.test.ts @@ -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; + let service: DriverService; + + beforeEach(() => { + mockApiClient = { + getLeaderboard: vi.fn(), + completeOnboarding: vi.fn(), + getCurrent: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverService.ts b/apps/website/lib/services/drivers/DriverService.ts index d169e88de..84e0222cd 100644 --- a/apps/website/lib/services/drivers/DriverService.ts +++ b/apps/website/lib/services/drivers/DriverService.ts @@ -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 diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.test.ts b/apps/website/lib/services/leagues/LeagueMembershipService.test.ts new file mode 100644 index 000000000..4d29cd91a --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueMembershipService.test.ts @@ -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; + let service: LeagueMembershipService; + + beforeEach(() => { + mockApiClient = { + getMemberships: vi.fn(), + removeMember: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.ts b/apps/website/lib/services/leagues/LeagueMembershipService.ts index 5b215a519..c56515069 100644 --- a/apps/website/lib/services/leagues/LeagueMembershipService.ts +++ b/apps/website/lib/services/leagues/LeagueMembershipService.ts @@ -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 { - 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)); } diff --git a/apps/website/lib/services/leagues/LeagueService.test.ts b/apps/website/lib/services/leagues/LeagueService.test.ts new file mode 100644 index 000000000..10bf54a27 --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueService.test.ts @@ -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; + 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; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index de93b2851..9551ec42d 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -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 }; -type LeagueMembershipsDto = { memberships: Array }; /** * League Service @@ -43,35 +46,40 @@ export class LeagueService { /** * Get league statistics */ - async getLeagueStats(): Promise { - return await this.apiClient.getTotal(); + async getLeagueStats(): Promise { + const dto = await this.apiClient.getTotal(); + return new LeagueStatsViewModel(dto); } /** * Get league schedule */ - async getLeagueSchedule(leagueId: string): Promise { - return await this.apiClient.getSchedule(leagueId); + async getLeagueSchedule(leagueId: string): Promise { + const dto = await this.apiClient.getSchedule(leagueId); + return new LeagueScheduleViewModel(dto); } /** * Get league memberships */ - async getLeagueMemberships(leagueId: string): Promise { - return await this.apiClient.getMemberships(leagueId); + async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { + const dto = await this.apiClient.getMemberships(leagueId); + return new LeagueMembershipsViewModel(dto, currentUserId); } /** * Create a new league */ - async createLeague(input: CreateLeagueInputDTO): Promise { - return await this.apiClient.create(input); + async createLeague(input: CreateLeagueInputDTO): Promise { + 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 { + const dto = await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId); + return new RemoveMemberViewModel(dto); } } \ No newline at end of file diff --git a/apps/website/lib/services/media/AvatarService.test.ts b/apps/website/lib/services/media/AvatarService.test.ts new file mode 100644 index 000000000..5979e3456 --- /dev/null +++ b/apps/website/lib/services/media/AvatarService.test.ts @@ -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; + let service: AvatarService; + + beforeEach(() => { + mockApiClient = { + requestAvatarGeneration: vi.fn(), + getAvatar: vi.fn(), + updateAvatar: vi.fn(), + } as Mocked; + + 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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/media/AvatarService.ts b/apps/website/lib/services/media/AvatarService.ts index 65612dad5..d9b7c6122 100644 --- a/apps/website/lib/services/media/AvatarService.ts +++ b/apps/website/lib/services/media/AvatarService.ts @@ -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 diff --git a/apps/website/lib/services/media/MediaService.test.ts b/apps/website/lib/services/media/MediaService.test.ts new file mode 100644 index 000000000..00555692b --- /dev/null +++ b/apps/website/lib/services/media/MediaService.test.ts @@ -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; + let service: MediaService; + + beforeEach(() => { + mockApiClient = { + uploadMedia: vi.fn(), + getMedia: vi.fn(), + deleteMedia: vi.fn(), + } as Mocked; + + 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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/media/MediaService.ts b/apps/website/lib/services/media/MediaService.ts index 09e5e1103..36fc88cf3 100644 --- a/apps/website/lib/services/media/MediaService.ts +++ b/apps/website/lib/services/media/MediaService.ts @@ -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 diff --git a/apps/website/lib/services/payments/MembershipFeeService.test.ts b/apps/website/lib/services/payments/MembershipFeeService.test.ts new file mode 100644 index 000000000..bdd175ca8 --- /dev/null +++ b/apps/website/lib/services/payments/MembershipFeeService.test.ts @@ -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; + let service: MembershipFeeService; + + beforeEach(() => { + mockApiClient = { + getMembershipFees: vi.fn(), + } as Mocked; + + 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([]); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/payments/MembershipFeeService.ts b/apps/website/lib/services/payments/MembershipFeeService.ts index cd749fa5e..1e2d0a297 100644 --- a/apps/website/lib/services/payments/MembershipFeeService.ts +++ b/apps/website/lib/services/payments/MembershipFeeService.ts @@ -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 diff --git a/apps/website/lib/services/payments/PaymentService.test.ts b/apps/website/lib/services/payments/PaymentService.test.ts new file mode 100644 index 000000000..c630609a0 --- /dev/null +++ b/apps/website/lib/services/payments/PaymentService.test.ts @@ -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; + let service: PaymentService; + + beforeEach(() => { + mockApiClient = { + getPayments: vi.fn(), + createPayment: vi.fn(), + getMembershipFees: vi.fn(), + getPrizes: vi.fn(), + getWallet: vi.fn(), + } as Mocked; + + 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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/payments/PaymentService.ts b/apps/website/lib/services/payments/PaymentService.ts index ef34c7fe1..079a50305 100644 --- a/apps/website/lib/services/payments/PaymentService.ts +++ b/apps/website/lib/services/payments/PaymentService.ts @@ -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 { 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 { // 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 { - return await this.apiClient.createPayment(input); + async createPayment(input: CreatePaymentInputDto): Promise { + const dto = await this.apiClient.createPayment(input); + return new PaymentViewModel(dto.payment); } /** * Get membership fees for a league */ - async getMembershipFees(leagueId: string): Promise { + async getMembershipFees(leagueId: string): Promise { 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 { const dto = await this.apiClient.getWallet(driverId); - return new WalletViewModel(dto); - } - - /** - * Process a payment (alias for createPayment) - */ - async processPayment(input: CreatePaymentInputDto): Promise { - return await this.createPayment(input); + return new WalletViewModel({ ...dto.wallet, transactions: dto.transactions }); } /** diff --git a/apps/website/lib/services/payments/WalletService.test.ts b/apps/website/lib/services/payments/WalletService.test.ts new file mode 100644 index 000000000..ea73b252c --- /dev/null +++ b/apps/website/lib/services/payments/WalletService.test.ts @@ -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; + let service: WalletService; + + beforeEach(() => { + mockApiClient = { + getWallet: vi.fn(), + } as Mocked; + + 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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/payments/WalletService.ts b/apps/website/lib/services/payments/WalletService.ts index 29b50dbe1..411bb6dcf 100644 --- a/apps/website/lib/services/payments/WalletService.ts +++ b/apps/website/lib/services/payments/WalletService.ts @@ -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 { - 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[] }); } } \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceResultsService.test.ts b/apps/website/lib/services/races/RaceResultsService.test.ts new file mode 100644 index 000000000..dd0785b89 --- /dev/null +++ b/apps/website/lib/services/races/RaceResultsService.test.ts @@ -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; + let service: RaceResultsService; + + beforeEach(() => { + mockApiClient = { + getResultsDetail: vi.fn(), + getWithSOF: vi.fn(), + importResults: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceResultsService.ts b/apps/website/lib/services/races/RaceResultsService.ts index b34bf93b9..1a630518c 100644 --- a/apps/website/lib/services/races/RaceResultsService.ts +++ b/apps/website/lib/services/races/RaceResultsService.ts @@ -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 }; +// TODO: Move this type to apps/website/lib/types/generated when available +type ImportRaceResultsInputDto = { raceId: string; results: Array }; -// 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 { - 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 { + 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 { - 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 { + const dto = await this.apiClient.importResults(raceId, input) as ImportRaceResultsSummaryDto; + return new ImportRaceResultsSummaryViewModel(dto); + } } \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.test.ts b/apps/website/lib/services/races/RaceService.test.ts new file mode 100644 index 000000000..d70fd35be --- /dev/null +++ b/apps/website/lib/services/races/RaceService.test.ts @@ -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; + let service: RaceService; + + beforeEach(() => { + mockApiClient = { + getDetail: vi.fn(), + getPageData: vi.fn(), + getTotal: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.ts b/apps/website/lib/services/races/RaceService.ts index 4f4323360..134babfd1 100644 --- a/apps/website/lib/services/races/RaceService.ts +++ b/apps/website/lib/services/races/RaceService.ts @@ -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 }; -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 { - return this.apiClient.getPageData(); + async getRacesPageData(): Promise { + 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 { - return this.apiClient.getTotal(); + async getRacesTotal(): Promise { + 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, + }; } } \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorService.test.ts b/apps/website/lib/services/sponsors/SponsorService.test.ts new file mode 100644 index 000000000..b363c3324 --- /dev/null +++ b/apps/website/lib/services/sponsors/SponsorService.test.ts @@ -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; + let service: SponsorService; + + beforeEach(() => { + mockApiClient = { + getAll: vi.fn(), + getDashboard: vi.fn(), + getSponsorships: vi.fn(), + create: vi.fn(), + getPricing: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorService.ts b/apps/website/lib/services/sponsors/SponsorService.ts index 083c2f40c..2eec40961 100644 --- a/apps/website/lib/services/sponsors/SponsorService.ts +++ b/apps/website/lib/services/sponsors/SponsorService.ts @@ -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 diff --git a/apps/website/lib/services/sponsors/SponsorshipService.test.ts b/apps/website/lib/services/sponsors/SponsorshipService.test.ts new file mode 100644 index 000000000..a6435f555 --- /dev/null +++ b/apps/website/lib/services/sponsors/SponsorshipService.test.ts @@ -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; + let service: SponsorshipService; + + beforeEach(() => { + mockApiClient = { + getPricing: vi.fn(), + getSponsorships: vi.fn(), + } as Mocked; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/sponsors/SponsorshipService.ts b/apps/website/lib/services/sponsors/SponsorshipService.ts index 6cc109797..fda5e352e 100644 --- a/apps/website/lib/services/sponsors/SponsorshipService.ts +++ b/apps/website/lib/services/sponsors/SponsorshipService.ts @@ -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 * diff --git a/apps/website/lib/services/teams/TeamJoinService.test.ts b/apps/website/lib/services/teams/TeamJoinService.test.ts new file mode 100644 index 000000000..fc7943bc6 --- /dev/null +++ b/apps/website/lib/services/teams/TeamJoinService.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/teams/TeamJoinService.ts b/apps/website/lib/services/teams/TeamJoinService.ts index 0135bb14b..9fe1d9512 100644 --- a/apps/website/lib/services/teams/TeamJoinService.ts +++ b/apps/website/lib/services/teams/TeamJoinService.ts @@ -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 { - 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 { + async approveJoinRequest(): Promise { // 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 { + async rejectJoinRequest(): Promise { // TODO: implement API call when endpoint is available throw new Error('Not implemented: API endpoint for rejecting join requests'); } diff --git a/apps/website/lib/services/teams/TeamService.test.ts b/apps/website/lib/services/teams/TeamService.test.ts new file mode 100644 index 000000000..e637c2068 --- /dev/null +++ b/apps/website/lib/services/teams/TeamService.test.ts @@ -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; + 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; + + 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'); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/services/teams/TeamService.ts b/apps/website/lib/services/teams/TeamService.ts index 911bcdb9a..d74c1db39 100644 --- a/apps/website/lib/services/teams/TeamService.ts +++ b/apps/website/lib/services/teams/TeamService.ts @@ -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 { - return await this.apiClient.create(input); + async createTeam(input: CreateTeamInputDto): Promise { + 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 { - return await this.apiClient.update(teamId, input); + async updateTeam(teamId: string, input: UpdateTeamInputDto): Promise { + 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 { - return await this.apiClient.getDriverTeam(driverId); + async getDriverTeam(driverId: string): Promise { + const dto = await this.apiClient.getDriverTeam(driverId); + return dto ? new DriverTeamViewModel(dto) : null; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/CreateLeagueViewModel.ts b/apps/website/lib/view-models/CreateLeagueViewModel.ts new file mode 100644 index 000000000..e132b8935 --- /dev/null +++ b/apps/website/lib/view-models/CreateLeagueViewModel.ts @@ -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.'; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/CreateTeamViewModel.ts b/apps/website/lib/view-models/CreateTeamViewModel.ts new file mode 100644 index 000000000..b5f8cb2ae --- /dev/null +++ b/apps/website/lib/view-models/CreateTeamViewModel.ts @@ -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.'; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverTeamViewModel.ts b/apps/website/lib/view-models/DriverTeamViewModel.ts new file mode 100644 index 000000000..efaa35c5b --- /dev/null +++ b/apps/website/lib/view-models/DriverTeamViewModel.ts @@ -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'; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.ts b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.ts new file mode 100644 index 000000000..7967c88ff --- /dev/null +++ b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.ts @@ -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 +} \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueMemberViewModel.ts b/apps/website/lib/view-models/LeagueMemberViewModel.ts index b2831fd42..32536be2f 100644 --- a/apps/website/lib/view-models/LeagueMemberViewModel.ts +++ b/apps/website/lib/view-models/LeagueMemberViewModel.ts @@ -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(); diff --git a/apps/website/lib/view-models/LeagueMembershipsViewModel.ts b/apps/website/lib/view-models/LeagueMembershipsViewModel.ts new file mode 100644 index 000000000..70569ab23 --- /dev/null +++ b/apps/website/lib/view-models/LeagueMembershipsViewModel.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueScheduleViewModel.ts b/apps/website/lib/view-models/LeagueScheduleViewModel.ts new file mode 100644 index 000000000..7f2dc7005 --- /dev/null +++ b/apps/website/lib/view-models/LeagueScheduleViewModel.ts @@ -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; + + constructor(dto: { races: Array }) { + 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; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueStatsViewModel.ts b/apps/website/lib/view-models/LeagueStatsViewModel.ts new file mode 100644 index 000000000..a5d5027aa --- /dev/null +++ b/apps/website/lib/view-models/LeagueStatsViewModel.ts @@ -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(); + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/MembershipFeeViewModel.ts b/apps/website/lib/view-models/MembershipFeeViewModel.ts index d808e6281..ce58db0de 100644 --- a/apps/website/lib/view-models/MembershipFeeViewModel.ts +++ b/apps/website/lib/view-models/MembershipFeeViewModel.ts @@ -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(); } } \ No newline at end of file diff --git a/apps/website/lib/view-models/PaymentViewModel.ts b/apps/website/lib/view-models/PaymentViewModel.ts index 6cedac993..5308d389c 100644 --- a/apps/website/lib/view-models/PaymentViewModel.ts +++ b/apps/website/lib/view-models/PaymentViewModel.ts @@ -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); + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/PrizeViewModel.ts b/apps/website/lib/view-models/PrizeViewModel.ts index d220ac414..b5719d498 100644 --- a/apps/website/lib/view-models/PrizeViewModel.ts +++ b/apps/website/lib/view-models/PrizeViewModel.ts @@ -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(); + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceStatsViewModel.ts b/apps/website/lib/view-models/RaceStatsViewModel.ts new file mode 100644 index 000000000..1e3b01752 --- /dev/null +++ b/apps/website/lib/view-models/RaceStatsViewModel.ts @@ -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(); + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceWithSOFViewModel.ts b/apps/website/lib/view-models/RaceWithSOFViewModel.ts new file mode 100644 index 000000000..74df9b0eb --- /dev/null +++ b/apps/website/lib/view-models/RaceWithSOFViewModel.ts @@ -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[]; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/RecordEngagementOutputViewModel.ts b/apps/website/lib/view-models/RecordEngagementOutputViewModel.ts new file mode 100644 index 000000000..c514d246c --- /dev/null +++ b/apps/website/lib/view-models/RecordEngagementOutputViewModel.ts @@ -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; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/RecordPageViewOutputViewModel.ts b/apps/website/lib/view-models/RecordPageViewOutputViewModel.ts new file mode 100644 index 000000000..50afce229 --- /dev/null +++ b/apps/website/lib/view-models/RecordPageViewOutputViewModel.ts @@ -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}`; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/RemoveMemberViewModel.ts b/apps/website/lib/view-models/RemoveMemberViewModel.ts new file mode 100644 index 000000000..b374a77f2 --- /dev/null +++ b/apps/website/lib/view-models/RemoveMemberViewModel.ts @@ -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.'; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/TeamDetailsViewModel.ts b/apps/website/lib/view-models/TeamDetailsViewModel.ts index eb0b6bd63..14def9b92 100644 --- a/apps/website/lib/view-models/TeamDetailsViewModel.ts +++ b/apps/website/lib/view-models/TeamDetailsViewModel.ts @@ -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 { diff --git a/apps/website/lib/view-models/TeamJoinRequestViewModel.ts b/apps/website/lib/view-models/TeamJoinRequestViewModel.ts index 76773b769..916454c6a 100644 --- a/apps/website/lib/view-models/TeamJoinRequestViewModel.ts +++ b/apps/website/lib/view-models/TeamJoinRequestViewModel.ts @@ -1,5 +1,5 @@ // Note: No generated DTO available for TeamJoinRequest yet -interface TeamJoinRequestDTO { +export interface TeamJoinRequestDTO { id: string; teamId: string; driverId: string; diff --git a/apps/website/lib/view-models/TeamMemberViewModel.ts b/apps/website/lib/view-models/TeamMemberViewModel.ts index c0db92702..2ae78a44d 100644 --- a/apps/website/lib/view-models/TeamMemberViewModel.ts +++ b/apps/website/lib/view-models/TeamMemberViewModel.ts @@ -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; } diff --git a/apps/website/lib/view-models/UpdateTeamViewModel.ts b/apps/website/lib/view-models/UpdateTeamViewModel.ts new file mode 100644 index 000000000..3111c0591 --- /dev/null +++ b/apps/website/lib/view-models/UpdateTeamViewModel.ts @@ -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.'; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/WalletTransactionViewModel.ts b/apps/website/lib/view-models/WalletTransactionViewModel.ts index 55c1d3259..ac2691e2e 100644 --- a/apps/website/lib/view-models/WalletTransactionViewModel.ts +++ b/apps/website/lib/view-models/WalletTransactionViewModel.ts @@ -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' ? '+' : '-'; diff --git a/apps/website/lib/view-models/WalletViewModel.ts b/apps/website/lib/view-models/WalletViewModel.ts index 4a4136ce8..6634aea9b 100644 --- a/apps/website/lib/view-models/WalletViewModel.ts +++ b/apps/website/lib/view-models/WalletViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/index.ts b/apps/website/lib/view-models/index.ts deleted file mode 100644 index a84c88363..000000000 --- a/apps/website/lib/view-models/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/apps/website/package.json b/apps/website/package.json index b6df17c02..d2ab1fbab 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -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" diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index e32833a09..5cf941062 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -8,6 +8,9 @@ "baseUrl": ".", "jsx": "preserve", "incremental": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, "plugins": [ { "name": "next" diff --git a/docs/architecture/FORM_MODELS.md b/docs/architecture/COMMAND_MODELS.md similarity index 82% rename from docs/architecture/FORM_MODELS.md rename to docs/architecture/COMMAND_MODELS.md index de52e701a..3c01bc53e 100644 --- a/docs/architecture/FORM_MODELS.md +++ b/docs/architecture/COMMAND_MODELS.md @@ -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. \ No newline at end of file diff --git a/package.json b/package.json index 39430c4c9..acc5917d1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tsconfig.base.json b/tsconfig.base.json index 9456a7861..eea8e772a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,8 @@ "declaration": true, "declarationMap": true, "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "paths": { "@core/*": ["./core/*"], "@adapters/*": ["./adapters/*"],