import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DashboardService } from '@/lib/services/analytics/DashboardService'; import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient'; import { AnalyticsApiClient } from '@/lib/api/analytics/AnalyticsApiClient'; import { ApiError } from '@/lib/api/base/ApiError'; import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO'; import type { GetAnalyticsMetricsOutputDTO } from '@/lib/types/generated/GetAnalyticsMetricsOutputDTO'; // Mock the API clients vi.mock('@/lib/api/dashboard/DashboardApiClient'); vi.mock('@/lib/api/analytics/AnalyticsApiClient'); describe('DashboardService', () => { let service: DashboardService; let mockDashboardApiClient: any; let mockAnalyticsApiClient: any; beforeEach(() => { vi.clearAllMocks(); service = new DashboardService(); mockDashboardApiClient = (service as any).apiClient; mockAnalyticsApiClient = (service as any).analyticsApiClient; }); describe('happy paths', () => { it('should successfully fetch dashboard overview', async () => { const mockOverview: DashboardOverviewDTO = { currentDriver: { id: 'driver-123', name: 'Test Driver', avatarUrl: 'https://example.com/avatar.jpg', rating: 1500, rank: 10, }, myUpcomingRaces: [ { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, ], otherUpcomingRaces: [ { id: 'race-2', name: 'Race 2', date: '2024-01-16T10:00:00.000Z', track: 'Track 2', league: 'League 2', }, ], upcomingRaces: [ { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, { id: 'race-2', name: 'Race 2', date: '2024-01-16T10:00:00.000Z', track: 'Track 2', league: 'League 2', }, ], activeLeaguesCount: 3, nextRace: { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, recentResults: [ { raceId: 'race-0', position: 5, points: 15, date: '2024-01-10T10:00:00.000Z', }, ], leagueStandingsSummaries: [ { leagueId: 'league-1', leagueName: 'League 1', position: 3, points: 150, }, ], feedSummary: { unreadCount: 5, latestPosts: [ { id: 'post-1', title: 'New Season Announcement', date: '2024-01-14T10:00:00.000Z', }, ], }, friends: [ { id: 'friend-1', name: 'Friend 1', avatarUrl: 'https://example.com/friend1.jpg', status: 'online', }, ], }; mockDashboardApiClient.getDashboardOverview.mockResolvedValue(mockOverview); const result = await service.getDashboardOverview(); expect(result.isOk()).toBe(true); expect(result.unwrap()).toEqual(mockOverview); expect(mockDashboardApiClient.getDashboardOverview).toHaveBeenCalledTimes(1); }); it('should successfully fetch analytics metrics', async () => { const mockMetrics: GetAnalyticsMetricsOutputDTO = { pageViews: 15000, uniqueVisitors: 8500, averageSessionDuration: 180, bounceRate: 0.35, }; mockAnalyticsApiClient.getAnalyticsMetrics.mockResolvedValue(mockMetrics); const result = await service.getAnalyticsMetrics(); expect(result.isOk()).toBe(true); expect(result.unwrap()).toEqual(mockMetrics); expect(mockAnalyticsApiClient.getAnalyticsMetrics).toHaveBeenCalledTimes(1); }); }); describe('failure modes', () => { it('should handle not found errors', async () => { const error = new ApiError( 'Dashboard not found', 'NOT_FOUND', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), statusCode: 404, } ); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: 'notFound', message: 'Dashboard not found', }); }); it('should handle unauthorized errors', async () => { const error = new ApiError( 'Unauthorized access', 'AUTH_ERROR', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), statusCode: 401, } ); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: 'unauthorized', message: 'Unauthorized access', }); }); it('should handle server errors', async () => { const error = new ApiError( 'Internal server error', 'SERVER_ERROR', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), statusCode: 500, } ); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: 'serverError', message: 'Internal server error', }); }); it('should handle network errors', async () => { const error = new ApiError( 'Network error: Unable to reach the API server', 'NETWORK_ERROR', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), } ); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: 'networkError', message: 'Network error: Unable to reach the API server', }); }); it('should handle timeout errors', async () => { const error = new ApiError( 'Request timed out after 30 seconds', 'TIMEOUT_ERROR', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), } ); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: 'networkError', message: 'Request timed out after 30 seconds', }); }); it('should handle unknown errors', async () => { const error = new Error('Something went wrong'); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: 'unknown', message: 'Something went wrong', }); }); }); describe('retries', () => { it('should retry on network failure', async () => { const mockOverview: DashboardOverviewDTO = { currentDriver: undefined, myUpcomingRaces: [], otherUpcomingRaces: [], upcomingRaces: [], activeLeaguesCount: 0, recentResults: [], leagueStandingsSummaries: [], feedSummary: { unreadCount: 0, latestPosts: [] }, friends: [], }; const error = new ApiError( 'Network error', 'NETWORK_ERROR', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), } ); // First call fails, second succeeds mockDashboardApiClient.getDashboardOverview .mockRejectedValueOnce(error) .mockResolvedValueOnce(mockOverview); const result = await service.getDashboardOverview(); expect(result.isOk()).toBe(true); expect(result.unwrap()).toEqual(mockOverview); expect(mockDashboardApiClient.getDashboardOverview).toHaveBeenCalledTimes(2); }); it('should retry on timeout', async () => { const mockOverview: DashboardOverviewDTO = { currentDriver: undefined, myUpcomingRaces: [], otherUpcomingRaces: [], upcomingRaces: [], activeLeaguesCount: 0, recentResults: [], leagueStandingsSummaries: [], feedSummary: { unreadCount: 0, latestPosts: [] }, friends: [], }; const error = new ApiError( 'Request timed out after 30 seconds', 'TIMEOUT_ERROR', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), } ); // First call times out, second succeeds mockDashboardApiClient.getDashboardOverview .mockRejectedValueOnce(error) .mockResolvedValueOnce(mockOverview); const result = await service.getDashboardOverview(); expect(result.isOk()).toBe(true); expect(result.unwrap()).toEqual(mockOverview); expect(mockDashboardApiClient.getDashboardOverview).toHaveBeenCalledTimes(2); }); }); describe('fallback logic', () => { it('should use fallback when primary API fails', async () => { const error = new ApiError( 'Unable to connect to server', 'NETWORK_ERROR', { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), } ); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); // The service should return an error result, not fallback data expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: 'networkError', message: 'Unable to connect to server', }); }); it('should handle partial data gracefully', async () => { const mockOverview: DashboardOverviewDTO = { currentDriver: undefined, // Missing driver data myUpcomingRaces: [], otherUpcomingRaces: [], upcomingRaces: [], activeLeaguesCount: 0, recentResults: [], leagueStandingsSummaries: [], feedSummary: { unreadCount: 0, latestPosts: [] }, friends: [], }; mockDashboardApiClient.getDashboardOverview.mockResolvedValue(mockOverview); const result = await service.getDashboardOverview(); expect(result.isOk()).toBe(true); const overview = result.unwrap(); expect(overview.currentDriver).toBeUndefined(); expect(overview.myUpcomingRaces).toHaveLength(0); expect(overview.activeLeaguesCount).toBe(0); }); }); describe('aggregation logic', () => { it('should aggregate dashboard data correctly', async () => { const mockOverview: DashboardOverviewDTO = { currentDriver: { id: 'driver-123', name: 'Test Driver', avatarUrl: 'https://example.com/avatar.jpg', rating: 1500, rank: 10, }, myUpcomingRaces: [ { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, { id: 'race-2', name: 'Race 2', date: '2024-01-16T10:00:00.000Z', track: 'Track 2', league: 'League 2', }, ], otherUpcomingRaces: [ { id: 'race-3', name: 'Race 3', date: '2024-01-17T10:00:00.000Z', track: 'Track 3', league: 'League 3', }, ], upcomingRaces: [ { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, { id: 'race-2', name: 'Race 2', date: '2024-01-16T10:00:00.000Z', track: 'Track 2', league: 'League 2', }, { id: 'race-3', name: 'Race 3', date: '2024-01-17T10:00:00.000Z', track: 'Track 3', league: 'League 3', }, ], activeLeaguesCount: 3, nextRace: { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, recentResults: [ { raceId: 'race-0', position: 5, points: 15, date: '2024-01-10T10:00:00.000Z', }, { raceId: 'race--1', position: 3, points: 20, date: '2024-01-09T10:00:00.000Z', }, ], leagueStandingsSummaries: [ { leagueId: 'league-1', leagueName: 'League 1', position: 3, points: 150, }, { leagueId: 'league-2', leagueName: 'League 2', position: 1, points: 200, }, ], feedSummary: { unreadCount: 5, latestPosts: [ { id: 'post-1', title: 'New Season Announcement', date: '2024-01-14T10:00:00.000Z', }, { id: 'post-2', title: 'Race Results Published', date: '2024-01-13T10:00:00.000Z', }, ], }, friends: [ { id: 'friend-1', name: 'Friend 1', avatarUrl: 'https://example.com/friend1.jpg', status: 'online', }, { id: 'friend-2', name: 'Friend 2', avatarUrl: 'https://example.com/friend2.jpg', status: 'offline', }, ], }; mockDashboardApiClient.getDashboardOverview.mockResolvedValue(mockOverview); const result = await service.getDashboardOverview(); expect(result.isOk()).toBe(true); const overview = result.unwrap(); // Verify aggregation expect(overview.currentDriver).toBeDefined(); expect(overview.currentDriver.id).toBe('driver-123'); expect(overview.currentDriver.rating).toBe(1500); // Verify race aggregation expect(overview.myUpcomingRaces).toHaveLength(2); expect(overview.otherUpcomingRaces).toHaveLength(1); expect(overview.upcomingRaces).toHaveLength(3); // Verify league aggregation expect(overview.activeLeaguesCount).toBe(3); expect(overview.leagueStandingsSummaries).toHaveLength(2); // Verify results aggregation expect(overview.recentResults).toHaveLength(2); const totalPoints = overview.recentResults.reduce((sum, r) => sum + r.points, 0); expect(totalPoints).toBe(35); // Verify feed aggregation expect(overview.feedSummary.unreadCount).toBe(5); expect(overview.feedSummary.latestPosts).toHaveLength(2); // Verify friends aggregation expect(overview.friends).toHaveLength(2); const onlineFriends = overview.friends.filter(f => f.status === 'online').length; expect(onlineFriends).toBe(1); }); it('should combine analytics metrics from multiple sources', async () => { const mockMetrics: GetAnalyticsMetricsOutputDTO = { pageViews: 15000, uniqueVisitors: 8500, averageSessionDuration: 180, bounceRate: 0.35, }; mockAnalyticsApiClient.getAnalyticsMetrics.mockResolvedValue(mockMetrics); const result = await service.getAnalyticsMetrics(); expect(result.isOk()).toBe(true); const metrics = result.unwrap(); // Verify metrics are returned correctly expect(metrics.pageViews).toBe(15000); expect(metrics.uniqueVisitors).toBe(8500); expect(metrics.averageSessionDuration).toBe(180); expect(metrics.bounceRate).toBe(0.35); // Verify derived metrics const pageViewsPerVisitor = metrics.pageViews / metrics.uniqueVisitors; expect(pageViewsPerVisitor).toBeCloseTo(1.76, 2); const bounceRatePercentage = metrics.bounceRate * 100; expect(bounceRatePercentage).toBe(35); }); }); describe('decision branches', () => { it('should handle different error types correctly', async () => { const errorTypes = [ { type: 'NOT_FOUND', expectedErrorType: 'notFound' }, { type: 'AUTH_ERROR', expectedErrorType: 'unauthorized' }, { type: 'SERVER_ERROR', expectedErrorType: 'serverError' }, { type: 'NETWORK_ERROR', expectedErrorType: 'networkError' }, { type: 'TIMEOUT_ERROR', expectedErrorType: 'networkError' }, ]; for (const { type, expectedErrorType } of errorTypes) { const error = new ApiError( `Error of type ${type}`, type as any, { endpoint: '/dashboard/overview', method: 'GET', timestamp: new Date().toISOString(), } ); mockDashboardApiClient.getDashboardOverview.mockRejectedValue(error); const result = await service.getDashboardOverview(); expect(result.isErr()).toBe(true); expect(result.getError()).toEqual({ type: expectedErrorType, message: `Error of type ${type}`, }); } }); it('should handle different API response formats', async () => { // Test with minimal response const minimalOverview: DashboardOverviewDTO = { currentDriver: undefined, myUpcomingRaces: [], otherUpcomingRaces: [], upcomingRaces: [], activeLeaguesCount: 0, recentResults: [], leagueStandingsSummaries: [], feedSummary: { unreadCount: 0, latestPosts: [] }, friends: [], }; mockDashboardApiClient.getDashboardOverview.mockResolvedValue(minimalOverview); const result = await service.getDashboardOverview(); expect(result.isOk()).toBe(true); const overview = result.unwrap(); expect(overview.activeLeaguesCount).toBe(0); expect(overview.upcomingRaces).toHaveLength(0); // Test with full response const fullOverview: DashboardOverviewDTO = { currentDriver: { id: 'driver-123', name: 'Test Driver', avatarUrl: 'https://example.com/avatar.jpg', rating: 1500, rank: 10, }, myUpcomingRaces: [ { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, ], otherUpcomingRaces: [], upcomingRaces: [ { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, ], activeLeaguesCount: 1, nextRace: { id: 'race-1', name: 'Race 1', date: '2024-01-15T10:00:00.000Z', track: 'Track 1', league: 'League 1', }, recentResults: [], leagueStandingsSummaries: [], feedSummary: { unreadCount: 0, latestPosts: [] }, friends: [], }; mockDashboardApiClient.getDashboardOverview.mockResolvedValue(fullOverview); const result2 = await service.getDashboardOverview(); expect(result2.isOk()).toBe(true); const overview2 = result2.unwrap(); expect(overview2.currentDriver).toBeDefined(); expect(overview2.currentDriver.id).toBe('driver-123'); expect(overview2.activeLeaguesCount).toBe(1); }); it('should handle different user permission levels', async () => { // Test with driver data (normal user) const driverOverview: DashboardOverviewDTO = { currentDriver: { id: 'driver-123', name: 'Test Driver', avatarUrl: 'https://example.com/avatar.jpg', rating: 1500, rank: 10, }, myUpcomingRaces: [], otherUpcomingRaces: [], upcomingRaces: [], activeLeaguesCount: 0, recentResults: [], leagueStandingsSummaries: [], feedSummary: { unreadCount: 0, latestPosts: [] }, friends: [], }; mockDashboardApiClient.getDashboardOverview.mockResolvedValue(driverOverview); const result = await service.getDashboardOverview(); expect(result.isOk()).toBe(true); const overview = result.unwrap(); expect(overview.currentDriver).toBeDefined(); expect(overview.currentDriver.id).toBe('driver-123'); // Test without driver data (guest user or no driver assigned) const guestOverview: DashboardOverviewDTO = { currentDriver: undefined, myUpcomingRaces: [], otherUpcomingRaces: [], upcomingRaces: [], activeLeaguesCount: 0, recentResults: [], leagueStandingsSummaries: [], feedSummary: { unreadCount: 0, latestPosts: [] }, friends: [], }; mockDashboardApiClient.getDashboardOverview.mockResolvedValue(guestOverview); const result2 = await service.getDashboardOverview(); expect(result2.isOk()).toBe(true); const overview2 = result2.unwrap(); expect(overview2.currentDriver).toBeUndefined(); }); }); });