diff --git a/core/notifications/application/ports/NotificationGateway.test.ts b/core/notifications/application/ports/NotificationGateway.test.ts new file mode 100644 index 000000000..e29ce7af6 --- /dev/null +++ b/core/notifications/application/ports/NotificationGateway.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Notification } from '../../domain/entities/Notification'; +import { + NotificationGateway, + NotificationGatewayRegistry, + NotificationDeliveryResult, +} from './NotificationGateway'; + +describe('NotificationGateway - Interface Contract', () => { + it('NotificationGateway interface defines send method', () => { + const mockGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('in_app'), + }; + + const notification = Notification.create({ + id: 'test-id', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test', + body: 'Test body', + channel: 'in_app', + }); + + expect(mockGateway.send).toBeDefined(); + expect(typeof mockGateway.send).toBe('function'); + }); + + it('NotificationGateway interface defines supportsChannel method', () => { + const mockGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('in_app'), + }; + + expect(mockGateway.supportsChannel).toBeDefined(); + expect(typeof mockGateway.supportsChannel).toBe('function'); + }); + + it('NotificationGateway interface defines isConfigured method', () => { + const mockGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('in_app'), + }; + + expect(mockGateway.isConfigured).toBeDefined(); + expect(typeof mockGateway.isConfigured).toBe('function'); + }); + + it('NotificationGateway interface defines getChannel method', () => { + const mockGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('in_app'), + }; + + expect(mockGateway.getChannel).toBeDefined(); + expect(typeof mockGateway.getChannel).toBe('function'); + }); + + it('NotificationDeliveryResult has required properties', () => { + const result: NotificationDeliveryResult = { + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }; + + expect(result).toHaveProperty('success'); + expect(result).toHaveProperty('channel'); + expect(result).toHaveProperty('attemptedAt'); + }); + + it('NotificationDeliveryResult can have optional externalId', () => { + const result: NotificationDeliveryResult = { + success: true, + channel: 'email', + externalId: 'email-123', + attemptedAt: new Date(), + }; + + expect(result.externalId).toBe('email-123'); + }); + + it('NotificationDeliveryResult can have optional error', () => { + const result: NotificationDeliveryResult = { + success: false, + channel: 'discord', + error: 'Failed to send to Discord', + attemptedAt: new Date(), + }; + + expect(result.error).toBe('Failed to send to Discord'); + }); +}); + +describe('NotificationGatewayRegistry - Interface Contract', () => { + it('NotificationGatewayRegistry interface defines register method', () => { + const mockRegistry: NotificationGatewayRegistry = { + register: vi.fn(), + getGateway: vi.fn().mockReturnValue(null), + getAllGateways: vi.fn().mockReturnValue([]), + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + }; + + expect(mockRegistry.register).toBeDefined(); + expect(typeof mockRegistry.register).toBe('function'); + }); + + it('NotificationGatewayRegistry interface defines getGateway method', () => { + const mockRegistry: NotificationGatewayRegistry = { + register: vi.fn(), + getGateway: vi.fn().mockReturnValue(null), + getAllGateways: vi.fn().mockReturnValue([]), + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + }; + + expect(mockRegistry.getGateway).toBeDefined(); + expect(typeof mockRegistry.getGateway).toBe('function'); + }); + + it('NotificationGatewayRegistry interface defines getAllGateways method', () => { + const mockRegistry: NotificationGatewayRegistry = { + register: vi.fn(), + getGateway: vi.fn().mockReturnValue(null), + getAllGateways: vi.fn().mockReturnValue([]), + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + }; + + expect(mockRegistry.getAllGateways).toBeDefined(); + expect(typeof mockRegistry.getAllGateways).toBe('function'); + }); + + it('NotificationGatewayRegistry interface defines send method', () => { + const mockRegistry: NotificationGatewayRegistry = { + register: vi.fn(), + getGateway: vi.fn().mockReturnValue(null), + getAllGateways: vi.fn().mockReturnValue([]), + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + }; + + expect(mockRegistry.send).toBeDefined(); + expect(typeof mockRegistry.send).toBe('function'); + }); +}); + +describe('NotificationGateway - Integration with Notification', () => { + it('gateway can send notification and return delivery result', async () => { + const mockGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + externalId: 'msg-123', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('in_app'), + }; + + const notification = Notification.create({ + id: 'test-id', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test', + body: 'Test body', + channel: 'in_app', + }); + + const result = await mockGateway.send(notification); + + expect(result.success).toBe(true); + expect(result.channel).toBe('in_app'); + expect(result.externalId).toBe('msg-123'); + expect(mockGateway.send).toHaveBeenCalledWith(notification); + }); + + it('gateway can handle failed delivery', async () => { + const mockGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: false, + channel: 'email', + error: 'SMTP server unavailable', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('email'), + }; + + const notification = Notification.create({ + id: 'test-id', + recipientId: 'driver-1', + type: 'race_registration_open', + title: 'Test', + body: 'Test body', + channel: 'email', + }); + + const result = await mockGateway.send(notification); + + expect(result.success).toBe(false); + expect(result.channel).toBe('email'); + expect(result.error).toBe('SMTP server unavailable'); + }); +}); + +describe('NotificationGatewayRegistry - Integration', () => { + it('registry can route notification to appropriate gateway', async () => { + const inAppGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'in_app', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('in_app'), + }; + + const emailGateway: NotificationGateway = { + send: vi.fn().mockResolvedValue({ + success: true, + channel: 'email', + externalId: 'email-456', + attemptedAt: new Date(), + }), + supportsChannel: vi.fn().mockReturnValue(true), + isConfigured: vi.fn().mockReturnValue(true), + getChannel: vi.fn().mockReturnValue('email'), + }; + + const mockRegistry: NotificationGatewayRegistry = { + register: vi.fn(), + getGateway: vi.fn().mockImplementation((channel) => { + if (channel === 'in_app') return inAppGateway; + if (channel === 'email') return emailGateway; + return null; + }), + getAllGateways: vi.fn().mockReturnValue([inAppGateway, emailGateway]), + send: vi.fn().mockImplementation(async (notification) => { + const gateway = mockRegistry.getGateway(notification.channel); + if (gateway) { + return gateway.send(notification); + } + return { + success: false, + channel: notification.channel, + error: 'No gateway found', + attemptedAt: new Date(), + }; + }), + }; + + const inAppNotification = Notification.create({ + id: 'test-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test', + body: 'Test body', + channel: 'in_app', + }); + + const emailNotification = Notification.create({ + id: 'test-2', + recipientId: 'driver-1', + type: 'race_registration_open', + title: 'Test', + body: 'Test body', + channel: 'email', + }); + + const inAppResult = await mockRegistry.send(inAppNotification); + expect(inAppResult.success).toBe(true); + expect(inAppResult.channel).toBe('in_app'); + + const emailResult = await mockRegistry.send(emailNotification); + expect(emailResult.success).toBe(true); + expect(emailResult.channel).toBe('email'); + expect(emailResult.externalId).toBe('email-456'); + }); +}); diff --git a/core/notifications/application/ports/NotificationService.test.ts b/core/notifications/application/ports/NotificationService.test.ts new file mode 100644 index 000000000..9fe2c0905 --- /dev/null +++ b/core/notifications/application/ports/NotificationService.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + NotificationService, + SendNotificationCommand, + NotificationData, + NotificationAction, +} from './NotificationService'; + +describe('NotificationService - Interface Contract', () => { + it('NotificationService interface defines sendNotification method', () => { + const mockService: NotificationService = { + sendNotification: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockService.sendNotification).toBeDefined(); + expect(typeof mockService.sendNotification).toBe('function'); + }); + + it('SendNotificationCommand has required properties', () => { + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test Notification', + body: 'This is a test notification', + channel: 'in_app', + urgency: 'toast', + }; + + expect(command).toHaveProperty('recipientId'); + expect(command).toHaveProperty('type'); + expect(command).toHaveProperty('title'); + expect(command).toHaveProperty('body'); + expect(command).toHaveProperty('channel'); + expect(command).toHaveProperty('urgency'); + }); + + it('SendNotificationCommand can have optional data', () => { + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'race_results_posted', + title: 'Race Results', + body: 'Your race results are available', + channel: 'email', + urgency: 'toast', + data: { + raceEventId: 'event-123', + sessionId: 'session-456', + position: 5, + positionChange: 2, + }, + }; + + expect(command.data).toBeDefined(); + expect(command.data?.raceEventId).toBe('event-123'); + expect(command.data?.position).toBe(5); + }); + + it('SendNotificationCommand can have optional actionUrl', () => { + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'protest_vote_required', + title: 'Vote Required', + body: 'You need to vote on a protest', + channel: 'in_app', + urgency: 'modal', + actionUrl: '/protests/vote/123', + }; + + expect(command.actionUrl).toBe('/protests/vote/123'); + }); + + it('SendNotificationCommand can have optional actions array', () => { + const actions: NotificationAction[] = [ + { + label: 'View Details', + type: 'primary', + href: '/protests/123', + }, + { + label: 'Dismiss', + type: 'secondary', + actionId: 'dismiss', + }, + ]; + + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'protest_filed', + title: 'Protest Filed', + body: 'A protest has been filed against you', + channel: 'in_app', + urgency: 'modal', + actions, + }; + + expect(command.actions).toBeDefined(); + expect(command.actions?.length).toBe(2); + expect(command.actions?.[0].label).toBe('View Details'); + expect(command.actions?.[1].type).toBe('secondary'); + }); + + it('SendNotificationCommand can have optional requiresResponse', () => { + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'protest_vote_required', + title: 'Vote Required', + body: 'You need to vote on a protest', + channel: 'in_app', + urgency: 'modal', + requiresResponse: true, + }; + + expect(command.requiresResponse).toBe(true); + }); + + it('NotificationData can have various optional fields', () => { + const data: NotificationData = { + raceEventId: 'event-123', + sessionId: 'session-456', + leagueId: 'league-789', + position: 3, + positionChange: 1, + incidents: 2, + provisionalRatingChange: 15, + finalRatingChange: 10, + hadPenaltiesApplied: true, + deadline: new Date('2024-01-01'), + protestId: 'protest-999', + customField: 'custom value', + }; + + expect(data.raceEventId).toBe('event-123'); + expect(data.sessionId).toBe('session-456'); + expect(data.leagueId).toBe('league-789'); + expect(data.position).toBe(3); + expect(data.positionChange).toBe(1); + expect(data.incidents).toBe(2); + expect(data.provisionalRatingChange).toBe(15); + expect(data.finalRatingChange).toBe(10); + expect(data.hadPenaltiesApplied).toBe(true); + expect(data.deadline).toBeInstanceOf(Date); + expect(data.protestId).toBe('protest-999'); + expect(data.customField).toBe('custom value'); + }); + + it('NotificationData can have minimal fields', () => { + const data: NotificationData = { + raceEventId: 'event-123', + }; + + expect(data.raceEventId).toBe('event-123'); + }); + + it('NotificationAction has required properties', () => { + const action: NotificationAction = { + label: 'View Details', + type: 'primary', + }; + + expect(action).toHaveProperty('label'); + expect(action).toHaveProperty('type'); + }); + + it('NotificationAction can have optional href', () => { + const action: NotificationAction = { + label: 'View Details', + type: 'primary', + href: '/protests/123', + }; + + expect(action.href).toBe('/protests/123'); + }); + + it('NotificationAction can have optional actionId', () => { + const action: NotificationAction = { + label: 'Dismiss', + type: 'secondary', + actionId: 'dismiss', + }; + + expect(action.actionId).toBe('dismiss'); + }); + + it('NotificationAction type can be primary, secondary, or danger', () => { + const primaryAction: NotificationAction = { + label: 'Accept', + type: 'primary', + }; + + const secondaryAction: NotificationAction = { + label: 'Cancel', + type: 'secondary', + }; + + const dangerAction: NotificationAction = { + label: 'Delete', + type: 'danger', + }; + + expect(primaryAction.type).toBe('primary'); + expect(secondaryAction.type).toBe('secondary'); + expect(dangerAction.type).toBe('danger'); + }); +}); + +describe('NotificationService - Integration', () => { + it('service can send notification with all optional fields', async () => { + const mockService: NotificationService = { + sendNotification: vi.fn().mockResolvedValue(undefined), + }; + + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'race_performance_summary', + title: 'Performance Summary', + body: 'Your performance summary is ready', + channel: 'email', + urgency: 'toast', + data: { + raceEventId: 'event-123', + sessionId: 'session-456', + position: 5, + positionChange: 2, + incidents: 1, + provisionalRatingChange: 10, + finalRatingChange: 8, + hadPenaltiesApplied: false, + }, + actionUrl: '/performance/summary/123', + actions: [ + { + label: 'View Details', + type: 'primary', + href: '/performance/summary/123', + }, + { + label: 'Dismiss', + type: 'secondary', + actionId: 'dismiss', + }, + ], + requiresResponse: false, + }; + + await mockService.sendNotification(command); + + expect(mockService.sendNotification).toHaveBeenCalledWith(command); + }); + + it('service can send notification with minimal fields', async () => { + const mockService: NotificationService = { + sendNotification: vi.fn().mockResolvedValue(undefined), + }; + + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'system_announcement', + title: 'System Update', + body: 'System will be down for maintenance', + channel: 'in_app', + urgency: 'toast', + }; + + await mockService.sendNotification(command); + + expect(mockService.sendNotification).toHaveBeenCalledWith(command); + }); + + it('service can send notification with different urgency levels', async () => { + const mockService: NotificationService = { + sendNotification: vi.fn().mockResolvedValue(undefined), + }; + + const silentCommand: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'race_reminder', + title: 'Race Reminder', + body: 'Your race starts in 30 minutes', + channel: 'in_app', + urgency: 'silent', + }; + + const toastCommand: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'league_invite', + title: 'League Invite', + body: 'You have been invited to a league', + channel: 'in_app', + urgency: 'toast', + }; + + const modalCommand: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'protest_vote_required', + title: 'Vote Required', + body: 'You need to vote on a protest', + channel: 'in_app', + urgency: 'modal', + }; + + await mockService.sendNotification(silentCommand); + await mockService.sendNotification(toastCommand); + await mockService.sendNotification(modalCommand); + + expect(mockService.sendNotification).toHaveBeenCalledTimes(3); + }); + + it('service can send notification through different channels', async () => { + const mockService: NotificationService = { + sendNotification: vi.fn().mockResolvedValue(undefined), + }; + + const inAppCommand: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'system_announcement', + title: 'System Update', + body: 'System will be down for maintenance', + channel: 'in_app', + urgency: 'toast', + }; + + const emailCommand: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'race_results_posted', + title: 'Race Results', + body: 'Your race results are available', + channel: 'email', + urgency: 'toast', + }; + + const discordCommand: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'sponsorship_request_received', + title: 'Sponsorship Request', + body: 'A sponsor wants to sponsor you', + channel: 'discord', + urgency: 'toast', + }; + + await mockService.sendNotification(inAppCommand); + await mockService.sendNotification(emailCommand); + await mockService.sendNotification(discordCommand); + + expect(mockService.sendNotification).toHaveBeenCalledTimes(3); + }); +}); diff --git a/core/notifications/application/use-cases/GetAllNotificationsUseCase.test.ts b/core/notifications/application/use-cases/GetAllNotificationsUseCase.test.ts new file mode 100644 index 000000000..04ed93330 --- /dev/null +++ b/core/notifications/application/use-cases/GetAllNotificationsUseCase.test.ts @@ -0,0 +1,143 @@ +import type { Logger } from '@core/shared/domain/Logger'; +import { Result } from '@core/shared/domain/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { Notification } from '../../domain/entities/Notification'; +import { NotificationRepository } from '../../domain/repositories/NotificationRepository'; +import { + GetAllNotificationsUseCase, + type GetAllNotificationsInput, +} from './GetAllNotificationsUseCase'; + +interface NotificationRepositoryMock { + findByRecipientId: Mock; +} + +describe('GetAllNotificationsUseCase', () => { + let notificationRepository: NotificationRepositoryMock; + let logger: Logger; + let useCase: GetAllNotificationsUseCase; + + beforeEach(() => { + notificationRepository = { + findByRecipientId: vi.fn(), + }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new GetAllNotificationsUseCase( + notificationRepository as unknown as NotificationRepository, + logger, + ); + }); + + it('returns all notifications and total count for recipient', async () => { + const recipientId = 'driver-1'; + const notifications: Notification[] = [ + Notification.create({ + id: 'n1', + recipientId, + type: 'system_announcement', + title: 'Test 1', + body: 'Body 1', + channel: 'in_app', + }), + Notification.create({ + id: 'n2', + recipientId, + type: 'race_registration_open', + title: 'Test 2', + body: 'Body 2', + channel: 'email', + }), + ]; + + notificationRepository.findByRecipientId.mockResolvedValue(notifications); + + const input: GetAllNotificationsInput = { recipientId }; + + const result = await useCase.execute(input); + + expect(notificationRepository.findByRecipientId).toHaveBeenCalledWith(recipientId); + expect(result).toBeInstanceOf(Result); + expect(result.isOk()).toBe(true); + + const successResult = result.unwrap(); + expect(successResult.notifications).toEqual(notifications); + expect(successResult.totalCount).toBe(2); + }); + + it('returns empty array when no notifications exist', async () => { + const recipientId = 'driver-1'; + notificationRepository.findByRecipientId.mockResolvedValue([]); + + const input: GetAllNotificationsInput = { recipientId }; + + const result = await useCase.execute(input); + + expect(notificationRepository.findByRecipientId).toHaveBeenCalledWith(recipientId); + expect(result.isOk()).toBe(true); + + const successResult = result.unwrap(); + expect(successResult.notifications).toEqual([]); + expect(successResult.totalCount).toBe(0); + }); + + it('handles repository errors by logging and returning error result', async () => { + const recipientId = 'driver-1'; + const error = new Error('DB error'); + notificationRepository.findByRecipientId.mockRejectedValue(error); + + const input: GetAllNotificationsInput = { recipientId }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); + + it('logs debug message when starting execution', async () => { + const recipientId = 'driver-1'; + notificationRepository.findByRecipientId.mockResolvedValue([]); + + const input: GetAllNotificationsInput = { recipientId }; + + await useCase.execute(input); + + expect(logger.debug).toHaveBeenCalledWith( + `Attempting to retrieve all notifications for recipient ID: ${recipientId}`, + ); + }); + + it('logs info message on successful retrieval', async () => { + const recipientId = 'driver-1'; + const notifications: Notification[] = [ + Notification.create({ + id: 'n1', + recipientId, + type: 'system_announcement', + title: 'Test', + body: 'Body', + channel: 'in_app', + }), + ]; + + notificationRepository.findByRecipientId.mockResolvedValue(notifications); + + const input: GetAllNotificationsInput = { recipientId }; + + await useCase.execute(input); + + expect(logger.info).toHaveBeenCalledWith( + `Successfully retrieved 1 notifications for recipient ID: ${recipientId}`, + ); + }); +}); diff --git a/core/notifications/domain/errors/NotificationDomainError.test.ts b/core/notifications/domain/errors/NotificationDomainError.test.ts new file mode 100644 index 000000000..692468582 --- /dev/null +++ b/core/notifications/domain/errors/NotificationDomainError.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { NotificationDomainError } from './NotificationDomainError'; + +describe('NotificationDomainError', () => { + it('creates an error with default validation kind', () => { + const error = new NotificationDomainError('Invalid notification data'); + + expect(error.name).toBe('NotificationDomainError'); + expect(error.type).toBe('domain'); + expect(error.context).toBe('notifications'); + expect(error.kind).toBe('validation'); + expect(error.message).toBe('Invalid notification data'); + }); + + it('creates an error with custom kind', () => { + const error = new NotificationDomainError('Notification not found', 'not_found'); + + expect(error.kind).toBe('not_found'); + expect(error.message).toBe('Notification not found'); + }); + + it('creates an error with business rule kind', () => { + const error = new NotificationDomainError('Cannot send notification during quiet hours', 'business_rule'); + + expect(error.kind).toBe('business_rule'); + expect(error.message).toBe('Cannot send notification during quiet hours'); + }); + + it('creates an error with conflict kind', () => { + const error = new NotificationDomainError('Notification already read', 'conflict'); + + expect(error.kind).toBe('conflict'); + expect(error.message).toBe('Notification already read'); + }); + + it('creates an error with unauthorized kind', () => { + const error = new NotificationDomainError('Cannot access notification', 'unauthorized'); + + expect(error.kind).toBe('unauthorized'); + expect(error.message).toBe('Cannot access notification'); + }); + + it('inherits from Error', () => { + const error = new NotificationDomainError('Test error'); + + expect(error).toBeInstanceOf(Error); + expect(error.stack).toBeDefined(); + }); + + it('has correct error properties', () => { + const error = new NotificationDomainError('Test error', 'validation'); + + expect(error.name).toBe('NotificationDomainError'); + expect(error.type).toBe('domain'); + expect(error.context).toBe('notifications'); + expect(error.kind).toBe('validation'); + }); +}); diff --git a/core/notifications/domain/repositories/NotificationPreferenceRepository.test.ts b/core/notifications/domain/repositories/NotificationPreferenceRepository.test.ts new file mode 100644 index 000000000..f4be10577 --- /dev/null +++ b/core/notifications/domain/repositories/NotificationPreferenceRepository.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from 'vitest'; +import { NotificationPreference } from '../entities/NotificationPreference'; +import { NotificationPreferenceRepository } from './NotificationPreferenceRepository'; + +describe('NotificationPreferenceRepository - Interface Contract', () => { + it('NotificationPreferenceRepository interface defines findByDriverId method', () => { + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference), + }; + + expect(mockRepository.findByDriverId).toBeDefined(); + expect(typeof mockRepository.findByDriverId).toBe('function'); + }); + + it('NotificationPreferenceRepository interface defines save method', () => { + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference), + }; + + expect(mockRepository.save).toBeDefined(); + expect(typeof mockRepository.save).toBe('function'); + }); + + it('NotificationPreferenceRepository interface defines delete method', () => { + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference), + }; + + expect(mockRepository.delete).toBeDefined(); + expect(typeof mockRepository.delete).toBe('function'); + }); + + it('NotificationPreferenceRepository interface defines getOrCreateDefault method', () => { + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference), + }; + + expect(mockRepository.getOrCreateDefault).toBeDefined(); + expect(typeof mockRepository.getOrCreateDefault).toBe('function'); + }); +}); + +describe('NotificationPreferenceRepository - Integration', () => { + it('can find preferences by driver ID', async () => { + const mockPreference = NotificationPreference.create({ + id: 'driver-1', + driverId: 'driver-1', + channels: { + in_app: { enabled: true }, + email: { enabled: true }, + discord: { enabled: false }, + push: { enabled: false }, + }, + quietHoursStart: 22, + quietHoursEnd: 7, + }); + + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(mockPreference), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue(mockPreference), + }; + + const result = await mockRepository.findByDriverId('driver-1'); + + expect(result).toBe(mockPreference); + expect(mockRepository.findByDriverId).toHaveBeenCalledWith('driver-1'); + }); + + it('returns null when preferences not found', async () => { + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference), + }; + + const result = await mockRepository.findByDriverId('driver-999'); + + expect(result).toBeNull(); + expect(mockRepository.findByDriverId).toHaveBeenCalledWith('driver-999'); + }); + + it('can save preferences', async () => { + const mockPreference = NotificationPreference.create({ + id: 'driver-1', + driverId: 'driver-1', + channels: { + in_app: { enabled: true }, + email: { enabled: true }, + discord: { enabled: false }, + push: { enabled: false }, + }, + quietHoursStart: 22, + quietHoursEnd: 7, + }); + + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(mockPreference), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue(mockPreference), + }; + + await mockRepository.save(mockPreference); + + expect(mockRepository.save).toHaveBeenCalledWith(mockPreference); + }); + + it('can delete preferences by driver ID', async () => { + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference), + }; + + await mockRepository.delete('driver-1'); + + expect(mockRepository.delete).toHaveBeenCalledWith('driver-1'); + }); + + it('can get or create default preferences', async () => { + const defaultPreference = NotificationPreference.createDefault('driver-1'); + + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue(defaultPreference), + }; + + const result = await mockRepository.getOrCreateDefault('driver-1'); + + expect(result).toBe(defaultPreference); + expect(mockRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1'); + }); + + it('handles workflow: find, update, save', async () => { + const existingPreference = NotificationPreference.create({ + id: 'driver-1', + driverId: 'driver-1', + channels: { + in_app: { enabled: true }, + email: { enabled: false }, + discord: { enabled: false }, + push: { enabled: false }, + }, + }); + + const updatedPreference = NotificationPreference.create({ + id: 'driver-1', + driverId: 'driver-1', + channels: { + in_app: { enabled: true }, + email: { enabled: true }, + discord: { enabled: true }, + push: { enabled: false }, + }, + }); + + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn() + .mockResolvedValueOnce(existingPreference) + .mockResolvedValueOnce(updatedPreference), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue(existingPreference), + }; + + // Find existing preferences + const found = await mockRepository.findByDriverId('driver-1'); + expect(found).toBe(existingPreference); + + // Update preferences + const updated = found!.updateChannel('email', { enabled: true }); + const updated2 = updated.updateChannel('discord', { enabled: true }); + + // Save updated preferences + await mockRepository.save(updated2); + expect(mockRepository.save).toHaveBeenCalledWith(updated2); + + // Verify update + const updatedFound = await mockRepository.findByDriverId('driver-1'); + expect(updatedFound).toBe(updatedPreference); + }); + + it('handles workflow: get or create, then update', async () => { + const defaultPreference = NotificationPreference.createDefault('driver-1'); + + const updatedPreference = NotificationPreference.create({ + id: 'driver-1', + driverId: 'driver-1', + channels: { + in_app: { enabled: true }, + email: { enabled: true }, + discord: { enabled: false }, + push: { enabled: false }, + }, + }); + + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue(defaultPreference), + }; + + // Get or create default preferences + const preferences = await mockRepository.getOrCreateDefault('driver-1'); + expect(preferences).toBe(defaultPreference); + + // Update preferences + const updated = preferences.updateChannel('email', { enabled: true }); + + // Save updated preferences + await mockRepository.save(updated); + expect(mockRepository.save).toHaveBeenCalledWith(updated); + }); + + it('handles workflow: delete preferences', async () => { + const mockRepository: NotificationPreferenceRepository = { + findByDriverId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference), + }; + + // Delete preferences + await mockRepository.delete('driver-1'); + expect(mockRepository.delete).toHaveBeenCalledWith('driver-1'); + + // Verify deletion + const result = await mockRepository.findByDriverId('driver-1'); + expect(result).toBeNull(); + }); +}); diff --git a/core/notifications/domain/repositories/NotificationRepository.test.ts b/core/notifications/domain/repositories/NotificationRepository.test.ts new file mode 100644 index 000000000..611cdd0b7 --- /dev/null +++ b/core/notifications/domain/repositories/NotificationRepository.test.ts @@ -0,0 +1,539 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Notification } from '../entities/Notification'; +import { NotificationRepository } from './NotificationRepository'; + +describe('NotificationRepository - Interface Contract', () => { + it('NotificationRepository interface defines findById method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.findById).toBeDefined(); + expect(typeof mockRepository.findById).toBe('function'); + }); + + it('NotificationRepository interface defines findByRecipientId method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.findByRecipientId).toBeDefined(); + expect(typeof mockRepository.findByRecipientId).toBe('function'); + }); + + it('NotificationRepository interface defines findUnreadByRecipientId method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.findUnreadByRecipientId).toBeDefined(); + expect(typeof mockRepository.findUnreadByRecipientId).toBe('function'); + }); + + it('NotificationRepository interface defines findByRecipientIdAndType method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.findByRecipientIdAndType).toBeDefined(); + expect(typeof mockRepository.findByRecipientIdAndType).toBe('function'); + }); + + it('NotificationRepository interface defines countUnreadByRecipientId method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.countUnreadByRecipientId).toBeDefined(); + expect(typeof mockRepository.countUnreadByRecipientId).toBe('function'); + }); + + it('NotificationRepository interface defines create method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.create).toBeDefined(); + expect(typeof mockRepository.create).toBe('function'); + }); + + it('NotificationRepository interface defines update method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.update).toBeDefined(); + expect(typeof mockRepository.update).toBe('function'); + }); + + it('NotificationRepository interface defines delete method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.delete).toBeDefined(); + expect(typeof mockRepository.delete).toBe('function'); + }); + + it('NotificationRepository interface defines deleteAllByRecipientId method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.deleteAllByRecipientId).toBeDefined(); + expect(typeof mockRepository.deleteAllByRecipientId).toBe('function'); + }); + + it('NotificationRepository interface defines markAllAsReadByRecipientId method', () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + expect(mockRepository.markAllAsReadByRecipientId).toBeDefined(); + expect(typeof mockRepository.markAllAsReadByRecipientId).toBe('function'); + }); +}); + +describe('NotificationRepository - Integration', () => { + it('can find notification by ID', async () => { + const notification = Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test', + body: 'Test body', + channel: 'in_app', + }); + + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(notification), + findByRecipientId: vi.fn().mockResolvedValue([notification]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([notification]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([notification]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(1), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + const result = await mockRepository.findById('notification-1'); + + expect(result).toBe(notification); + expect(mockRepository.findById).toHaveBeenCalledWith('notification-1'); + }); + + it('returns null when notification not found by ID', async () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + const result = await mockRepository.findById('notification-999'); + + expect(result).toBeNull(); + expect(mockRepository.findById).toHaveBeenCalledWith('notification-999'); + }); + + it('can find all notifications for a recipient', async () => { + const notifications = [ + Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test 1', + body: 'Body 1', + channel: 'in_app', + }), + Notification.create({ + id: 'notification-2', + recipientId: 'driver-1', + type: 'race_registration_open', + title: 'Test 2', + body: 'Body 2', + channel: 'email', + }), + ]; + + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue(notifications), + findUnreadByRecipientId: vi.fn().mockResolvedValue(notifications), + findByRecipientIdAndType: vi.fn().mockResolvedValue(notifications), + countUnreadByRecipientId: vi.fn().mockResolvedValue(2), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + const result = await mockRepository.findByRecipientId('driver-1'); + + expect(result).toBe(notifications); + expect(mockRepository.findByRecipientId).toHaveBeenCalledWith('driver-1'); + }); + + it('can find unread notifications for a recipient', async () => { + const unreadNotifications = [ + Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test 1', + body: 'Body 1', + channel: 'in_app', + }), + ]; + + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue(unreadNotifications), + findByRecipientIdAndType: vi.fn().mockResolvedValue(unreadNotifications), + countUnreadByRecipientId: vi.fn().mockResolvedValue(1), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + const result = await mockRepository.findUnreadByRecipientId('driver-1'); + + expect(result).toBe(unreadNotifications); + expect(mockRepository.findUnreadByRecipientId).toHaveBeenCalledWith('driver-1'); + }); + + it('can find notifications by type for a recipient', async () => { + const protestNotifications = [ + Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'protest_filed', + title: 'Protest Filed', + body: 'A protest has been filed', + channel: 'in_app', + }), + ]; + + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue(protestNotifications), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + const result = await mockRepository.findByRecipientIdAndType('driver-1', 'protest_filed'); + + expect(result).toBe(protestNotifications); + expect(mockRepository.findByRecipientIdAndType).toHaveBeenCalledWith('driver-1', 'protest_filed'); + }); + + it('can count unread notifications for a recipient', async () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(3), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + const count = await mockRepository.countUnreadByRecipientId('driver-1'); + + expect(count).toBe(3); + expect(mockRepository.countUnreadByRecipientId).toHaveBeenCalledWith('driver-1'); + }); + + it('can create a new notification', async () => { + const notification = Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test', + body: 'Test body', + channel: 'in_app', + }); + + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + await mockRepository.create(notification); + + expect(mockRepository.create).toHaveBeenCalledWith(notification); + }); + + it('can update an existing notification', async () => { + const notification = Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test', + body: 'Test body', + channel: 'in_app', + }); + + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(notification), + findByRecipientId: vi.fn().mockResolvedValue([notification]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([notification]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([notification]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(1), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + await mockRepository.update(notification); + + expect(mockRepository.update).toHaveBeenCalledWith(notification); + }); + + it('can delete a notification by ID', async () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + await mockRepository.delete('notification-1'); + + expect(mockRepository.delete).toHaveBeenCalledWith('notification-1'); + }); + + it('can delete all notifications for a recipient', async () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + await mockRepository.deleteAllByRecipientId('driver-1'); + + expect(mockRepository.deleteAllByRecipientId).toHaveBeenCalledWith('driver-1'); + }); + + it('can mark all notifications as read for a recipient', async () => { + const mockRepository: NotificationRepository = { + findById: vi.fn().mockResolvedValue(null), + findByRecipientId: vi.fn().mockResolvedValue([]), + findUnreadByRecipientId: vi.fn().mockResolvedValue([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + await mockRepository.markAllAsReadByRecipientId('driver-1'); + + expect(mockRepository.markAllAsReadByRecipientId).toHaveBeenCalledWith('driver-1'); + }); + + it('handles workflow: create, find, update, delete', async () => { + const notification = Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Test', + body: 'Test body', + channel: 'in_app', + }); + + const updatedNotification = Notification.create({ + id: 'notification-1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'Updated Test', + body: 'Updated body', + channel: 'in_app', + }); + + const mockRepository: NotificationRepository = { + findById: vi.fn() + .mockResolvedValueOnce(notification) + .mockResolvedValueOnce(updatedNotification) + .mockResolvedValueOnce(null), + findByRecipientId: vi.fn() + .mockResolvedValueOnce([notification]) + .mockResolvedValueOnce([updatedNotification]) + .mockResolvedValueOnce([]), + findUnreadByRecipientId: vi.fn() + .mockResolvedValueOnce([notification]) + .mockResolvedValueOnce([updatedNotification]) + .mockResolvedValueOnce([]), + findByRecipientIdAndType: vi.fn().mockResolvedValue([]), + countUnreadByRecipientId: vi.fn() + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined), + markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined), + }; + + // Create notification + await mockRepository.create(notification); + expect(mockRepository.create).toHaveBeenCalledWith(notification); + + // Find notification + const found = await mockRepository.findById('notification-1'); + expect(found).toBe(notification); + + // Update notification + await mockRepository.update(updatedNotification); + expect(mockRepository.update).toHaveBeenCalledWith(updatedNotification); + + // Verify update + const updatedFound = await mockRepository.findById('notification-1'); + expect(updatedFound).toBe(updatedNotification); + + // Delete notification + await mockRepository.delete('notification-1'); + expect(mockRepository.delete).toHaveBeenCalledWith('notification-1'); + + // Verify deletion + const deletedFound = await mockRepository.findById('notification-1'); + expect(deletedFound).toBeNull(); + }); +}); diff --git a/core/notifications/domain/types/NotificationTypes.test.ts b/core/notifications/domain/types/NotificationTypes.test.ts new file mode 100644 index 000000000..02b684a15 --- /dev/null +++ b/core/notifications/domain/types/NotificationTypes.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, it } from 'vitest'; +import { + getChannelDisplayName, + isExternalChannel, + DEFAULT_ENABLED_CHANNELS, + ALL_CHANNELS, + getNotificationTypeTitle, + getNotificationTypePriority, + type NotificationChannel, + type NotificationType, +} from './NotificationTypes'; + +describe('NotificationTypes - Channel Functions', () => { + describe('getChannelDisplayName', () => { + it('returns correct display name for in_app channel', () => { + expect(getChannelDisplayName('in_app')).toBe('In-App'); + }); + + it('returns correct display name for email channel', () => { + expect(getChannelDisplayName('email')).toBe('Email'); + }); + + it('returns correct display name for discord channel', () => { + expect(getChannelDisplayName('discord')).toBe('Discord'); + }); + + it('returns correct display name for push channel', () => { + expect(getChannelDisplayName('push')).toBe('Push Notification'); + }); + }); + + describe('isExternalChannel', () => { + it('returns false for in_app channel', () => { + expect(isExternalChannel('in_app')).toBe(false); + }); + + it('returns true for email channel', () => { + expect(isExternalChannel('email')).toBe(true); + }); + + it('returns true for discord channel', () => { + expect(isExternalChannel('discord')).toBe(true); + }); + + it('returns true for push channel', () => { + expect(isExternalChannel('push')).toBe(true); + }); + }); + + describe('DEFAULT_ENABLED_CHANNELS', () => { + it('contains only in_app channel', () => { + expect(DEFAULT_ENABLED_CHANNELS).toEqual(['in_app']); + }); + + it('is an array', () => { + expect(Array.isArray(DEFAULT_ENABLED_CHANNELS)).toBe(true); + }); + }); + + describe('ALL_CHANNELS', () => { + it('contains all notification channels', () => { + expect(ALL_CHANNELS).toEqual(['in_app', 'email', 'discord', 'push']); + }); + + it('is an array', () => { + expect(Array.isArray(ALL_CHANNELS)).toBe(true); + }); + + it('has correct length', () => { + expect(ALL_CHANNELS.length).toBe(4); + }); + }); +}); + +describe('NotificationTypes - Notification Type Functions', () => { + describe('getNotificationTypeTitle', () => { + it('returns correct title for protest_filed', () => { + expect(getNotificationTypeTitle('protest_filed')).toBe('Protest Filed'); + }); + + it('returns correct title for protest_defense_requested', () => { + expect(getNotificationTypeTitle('protest_defense_requested')).toBe('Defense Requested'); + }); + + it('returns correct title for protest_defense_submitted', () => { + expect(getNotificationTypeTitle('protest_defense_submitted')).toBe('Defense Submitted'); + }); + + it('returns correct title for protest_comment_added', () => { + expect(getNotificationTypeTitle('protest_comment_added')).toBe('New Comment'); + }); + + it('returns correct title for protest_vote_required', () => { + expect(getNotificationTypeTitle('protest_vote_required')).toBe('Vote Required'); + }); + + it('returns correct title for protest_vote_cast', () => { + expect(getNotificationTypeTitle('protest_vote_cast')).toBe('Vote Cast'); + }); + + it('returns correct title for protest_resolved', () => { + expect(getNotificationTypeTitle('protest_resolved')).toBe('Protest Resolved'); + }); + + it('returns correct title for penalty_issued', () => { + expect(getNotificationTypeTitle('penalty_issued')).toBe('Penalty Issued'); + }); + + it('returns correct title for penalty_appealed', () => { + expect(getNotificationTypeTitle('penalty_appealed')).toBe('Penalty Appealed'); + }); + + it('returns correct title for penalty_appeal_resolved', () => { + expect(getNotificationTypeTitle('penalty_appeal_resolved')).toBe('Appeal Resolved'); + }); + + it('returns correct title for race_registration_open', () => { + expect(getNotificationTypeTitle('race_registration_open')).toBe('Registration Open'); + }); + + it('returns correct title for race_reminder', () => { + expect(getNotificationTypeTitle('race_reminder')).toBe('Race Reminder'); + }); + + it('returns correct title for race_results_posted', () => { + expect(getNotificationTypeTitle('race_results_posted')).toBe('Results Posted'); + }); + + it('returns correct title for race_performance_summary', () => { + expect(getNotificationTypeTitle('race_performance_summary')).toBe('Performance Summary'); + }); + + it('returns correct title for race_final_results', () => { + expect(getNotificationTypeTitle('race_final_results')).toBe('Final Results'); + }); + + it('returns correct title for league_invite', () => { + expect(getNotificationTypeTitle('league_invite')).toBe('League Invitation'); + }); + + it('returns correct title for league_join_request', () => { + expect(getNotificationTypeTitle('league_join_request')).toBe('Join Request'); + }); + + it('returns correct title for league_join_approved', () => { + expect(getNotificationTypeTitle('league_join_approved')).toBe('Request Approved'); + }); + + it('returns correct title for league_join_rejected', () => { + expect(getNotificationTypeTitle('league_join_rejected')).toBe('Request Rejected'); + }); + + it('returns correct title for league_role_changed', () => { + expect(getNotificationTypeTitle('league_role_changed')).toBe('Role Changed'); + }); + + it('returns correct title for team_invite', () => { + expect(getNotificationTypeTitle('team_invite')).toBe('Team Invitation'); + }); + + it('returns correct title for team_join_request', () => { + expect(getNotificationTypeTitle('team_join_request')).toBe('Team Join Request'); + }); + + it('returns correct title for team_join_approved', () => { + expect(getNotificationTypeTitle('team_join_approved')).toBe('Team Request Approved'); + }); + + it('returns correct title for sponsorship_request_received', () => { + expect(getNotificationTypeTitle('sponsorship_request_received')).toBe('Sponsorship Request'); + }); + + it('returns correct title for sponsorship_request_accepted', () => { + expect(getNotificationTypeTitle('sponsorship_request_accepted')).toBe('Sponsorship Accepted'); + }); + + it('returns correct title for sponsorship_request_rejected', () => { + expect(getNotificationTypeTitle('sponsorship_request_rejected')).toBe('Sponsorship Rejected'); + }); + + it('returns correct title for sponsorship_request_withdrawn', () => { + expect(getNotificationTypeTitle('sponsorship_request_withdrawn')).toBe('Sponsorship Withdrawn'); + }); + + it('returns correct title for sponsorship_activated', () => { + expect(getNotificationTypeTitle('sponsorship_activated')).toBe('Sponsorship Active'); + }); + + it('returns correct title for sponsorship_payment_received', () => { + expect(getNotificationTypeTitle('sponsorship_payment_received')).toBe('Payment Received'); + }); + + it('returns correct title for system_announcement', () => { + expect(getNotificationTypeTitle('system_announcement')).toBe('Announcement'); + }); + }); + + describe('getNotificationTypePriority', () => { + it('returns correct priority for protest_filed', () => { + expect(getNotificationTypePriority('protest_filed')).toBe(8); + }); + + it('returns correct priority for protest_defense_requested', () => { + expect(getNotificationTypePriority('protest_defense_requested')).toBe(9); + }); + + it('returns correct priority for protest_defense_submitted', () => { + expect(getNotificationTypePriority('protest_defense_submitted')).toBe(6); + }); + + it('returns correct priority for protest_comment_added', () => { + expect(getNotificationTypePriority('protest_comment_added')).toBe(4); + }); + + it('returns correct priority for protest_vote_required', () => { + expect(getNotificationTypePriority('protest_vote_required')).toBe(8); + }); + + it('returns correct priority for protest_vote_cast', () => { + expect(getNotificationTypePriority('protest_vote_cast')).toBe(3); + }); + + it('returns correct priority for protest_resolved', () => { + expect(getNotificationTypePriority('protest_resolved')).toBe(7); + }); + + it('returns correct priority for penalty_issued', () => { + expect(getNotificationTypePriority('penalty_issued')).toBe(9); + }); + + it('returns correct priority for penalty_appealed', () => { + expect(getNotificationTypePriority('penalty_appealed')).toBe(7); + }); + + it('returns correct priority for penalty_appeal_resolved', () => { + expect(getNotificationTypePriority('penalty_appeal_resolved')).toBe(7); + }); + + it('returns correct priority for race_registration_open', () => { + expect(getNotificationTypePriority('race_registration_open')).toBe(5); + }); + + it('returns correct priority for race_reminder', () => { + expect(getNotificationTypePriority('race_reminder')).toBe(8); + }); + + it('returns correct priority for race_results_posted', () => { + expect(getNotificationTypePriority('race_results_posted')).toBe(5); + }); + + it('returns correct priority for race_performance_summary', () => { + expect(getNotificationTypePriority('race_performance_summary')).toBe(9); + }); + + it('returns correct priority for race_final_results', () => { + expect(getNotificationTypePriority('race_final_results')).toBe(7); + }); + + it('returns correct priority for league_invite', () => { + expect(getNotificationTypePriority('league_invite')).toBe(6); + }); + + it('returns correct priority for league_join_request', () => { + expect(getNotificationTypePriority('league_join_request')).toBe(5); + }); + + it('returns correct priority for league_join_approved', () => { + expect(getNotificationTypePriority('league_join_approved')).toBe(7); + }); + + it('returns correct priority for league_join_rejected', () => { + expect(getNotificationTypePriority('league_join_rejected')).toBe(7); + }); + + it('returns correct priority for league_role_changed', () => { + expect(getNotificationTypePriority('league_role_changed')).toBe(6); + }); + + it('returns correct priority for team_invite', () => { + expect(getNotificationTypePriority('team_invite')).toBe(5); + }); + + it('returns correct priority for team_join_request', () => { + expect(getNotificationTypePriority('team_join_request')).toBe(4); + }); + + it('returns correct priority for team_join_approved', () => { + expect(getNotificationTypePriority('team_join_approved')).toBe(6); + }); + + it('returns correct priority for sponsorship_request_received', () => { + expect(getNotificationTypePriority('sponsorship_request_received')).toBe(7); + }); + + it('returns correct priority for sponsorship_request_accepted', () => { + expect(getNotificationTypePriority('sponsorship_request_accepted')).toBe(8); + }); + + it('returns correct priority for sponsorship_request_rejected', () => { + expect(getNotificationTypePriority('sponsorship_request_rejected')).toBe(6); + }); + + it('returns correct priority for sponsorship_request_withdrawn', () => { + expect(getNotificationTypePriority('sponsorship_request_withdrawn')).toBe(5); + }); + + it('returns correct priority for sponsorship_activated', () => { + expect(getNotificationTypePriority('sponsorship_activated')).toBe(7); + }); + + it('returns correct priority for sponsorship_payment_received', () => { + expect(getNotificationTypePriority('sponsorship_payment_received')).toBe(8); + }); + + it('returns correct priority for system_announcement', () => { + expect(getNotificationTypePriority('system_announcement')).toBe(10); + }); + }); +}); + +describe('NotificationTypes - Type Safety', () => { + it('ALL_CHANNELS contains all NotificationChannel values', () => { + const channels: NotificationChannel[] = ['in_app', 'email', 'discord', 'push']; + channels.forEach(channel => { + expect(ALL_CHANNELS).toContain(channel); + }); + }); + + it('DEFAULT_ENABLED_CHANNELS is a subset of ALL_CHANNELS', () => { + DEFAULT_ENABLED_CHANNELS.forEach(channel => { + expect(ALL_CHANNELS).toContain(channel); + }); + }); + + it('all notification types have titles', () => { + const types: NotificationType[] = [ + 'protest_filed', + 'protest_defense_requested', + 'protest_defense_submitted', + 'protest_comment_added', + 'protest_vote_required', + 'protest_vote_cast', + 'protest_resolved', + 'penalty_issued', + 'penalty_appealed', + 'penalty_appeal_resolved', + 'race_registration_open', + 'race_reminder', + 'race_results_posted', + 'race_performance_summary', + 'race_final_results', + 'league_invite', + 'league_join_request', + 'league_join_approved', + 'league_join_rejected', + 'league_role_changed', + 'team_invite', + 'team_join_request', + 'team_join_approved', + 'sponsorship_request_received', + 'sponsorship_request_accepted', + 'sponsorship_request_rejected', + 'sponsorship_request_withdrawn', + 'sponsorship_activated', + 'sponsorship_payment_received', + 'system_announcement', + ]; + + types.forEach(type => { + const title = getNotificationTypeTitle(type); + expect(title).toBeDefined(); + expect(typeof title).toBe('string'); + expect(title.length).toBeGreaterThan(0); + }); + }); + + it('all notification types have priorities', () => { + const types: NotificationType[] = [ + 'protest_filed', + 'protest_defense_requested', + 'protest_defense_submitted', + 'protest_comment_added', + 'protest_vote_required', + 'protest_vote_cast', + 'protest_resolved', + 'penalty_issued', + 'penalty_appealed', + 'penalty_appeal_resolved', + 'race_registration_open', + 'race_reminder', + 'race_results_posted', + 'race_performance_summary', + 'race_final_results', + 'league_invite', + 'league_join_request', + 'league_join_approved', + 'league_join_rejected', + 'league_role_changed', + 'team_invite', + 'team_join_request', + 'team_join_approved', + 'sponsorship_request_received', + 'sponsorship_request_accepted', + 'sponsorship_request_rejected', + 'sponsorship_request_withdrawn', + 'sponsorship_activated', + 'sponsorship_payment_received', + 'system_announcement', + ]; + + types.forEach(type => { + const priority = getNotificationTypePriority(type); + expect(priority).toBeDefined(); + expect(typeof priority).toBe('number'); + expect(priority).toBeGreaterThanOrEqual(0); + expect(priority).toBeLessThanOrEqual(10); + }); + }); +}); diff --git a/core/payments/domain/entities/MemberPayment.test.ts b/core/payments/domain/entities/MemberPayment.test.ts index f58ed5929..f0de77c67 100644 --- a/core/payments/domain/entities/MemberPayment.test.ts +++ b/core/payments/domain/entities/MemberPayment.test.ts @@ -1,8 +1,174 @@ -import * as mod from '@core/payments/domain/entities/MemberPayment'; +import { + MemberPayment, + MemberPaymentStatus, +} from '@core/payments/domain/entities/MemberPayment'; import { describe, expect, it } from 'vitest'; -describe('payments/domain/entities/MemberPayment.ts', () => { - it('imports', () => { - expect(mod).toBeTruthy(); +describe('payments/domain/entities/MemberPayment', () => { + describe('MemberPaymentStatus enum', () => { + it('should have correct status values', () => { + expect(MemberPaymentStatus.PENDING).toBe('pending'); + expect(MemberPaymentStatus.PAID).toBe('paid'); + expect(MemberPaymentStatus.OVERDUE).toBe('overdue'); + }); + }); + + describe('MemberPayment interface', () => { + it('should have all required properties', () => { + const payment: MemberPayment = { + id: 'payment-123', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2024-01-01'), + }; + + expect(payment.id).toBe('payment-123'); + expect(payment.feeId).toBe('fee-456'); + expect(payment.driverId).toBe('driver-789'); + expect(payment.amount).toBe(100); + expect(payment.platformFee).toBe(10); + expect(payment.netAmount).toBe(90); + expect(payment.status).toBe(MemberPaymentStatus.PENDING); + expect(payment.dueDate).toEqual(new Date('2024-01-01')); + }); + + it('should support optional paidAt property', () => { + const payment: MemberPayment = { + id: 'payment-123', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PAID, + dueDate: new Date('2024-01-01'), + paidAt: new Date('2024-01-15'), + }; + + expect(payment.paidAt).toEqual(new Date('2024-01-15')); + }); + }); + + describe('MemberPayment.rehydrate', () => { + it('should rehydrate a MemberPayment from props', () => { + const props: MemberPayment = { + id: 'payment-123', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2024-01-01'), + }; + + const rehydrated = MemberPayment.rehydrate(props); + + expect(rehydrated).toEqual(props); + expect(rehydrated.id).toBe('payment-123'); + expect(rehydrated.feeId).toBe('fee-456'); + expect(rehydrated.driverId).toBe('driver-789'); + expect(rehydrated.amount).toBe(100); + expect(rehydrated.platformFee).toBe(10); + expect(rehydrated.netAmount).toBe(90); + expect(rehydrated.status).toBe(MemberPaymentStatus.PENDING); + expect(rehydrated.dueDate).toEqual(new Date('2024-01-01')); + }); + + it('should preserve optional paidAt when rehydrating', () => { + const props: MemberPayment = { + id: 'payment-123', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PAID, + dueDate: new Date('2024-01-01'), + paidAt: new Date('2024-01-15'), + }; + + const rehydrated = MemberPayment.rehydrate(props); + + expect(rehydrated.paidAt).toEqual(new Date('2024-01-15')); + }); + }); + + describe('Business rules and invariants', () => { + it('should calculate netAmount correctly (amount - platformFee)', () => { + const payment: MemberPayment = { + id: 'payment-123', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2024-01-01'), + }; + + expect(payment.netAmount).toBe(payment.amount - payment.platformFee); + }); + + it('should support different payment statuses', () => { + const pendingPayment: MemberPayment = { + id: 'payment-123', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2024-01-01'), + }; + + const paidPayment: MemberPayment = { + id: 'payment-124', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PAID, + dueDate: new Date('2024-01-01'), + paidAt: new Date('2024-01-15'), + }; + + const overduePayment: MemberPayment = { + id: 'payment-125', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.OVERDUE, + dueDate: new Date('2024-01-01'), + }; + + expect(pendingPayment.status).toBe(MemberPaymentStatus.PENDING); + expect(paidPayment.status).toBe(MemberPaymentStatus.PAID); + expect(overduePayment.status).toBe(MemberPaymentStatus.OVERDUE); + }); + + it('should handle zero and negative amounts', () => { + const zeroPayment: MemberPayment = { + id: 'payment-123', + feeId: 'fee-456', + driverId: 'driver-789', + amount: 0, + platformFee: 0, + netAmount: 0, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2024-01-01'), + }; + + expect(zeroPayment.amount).toBe(0); + expect(zeroPayment.platformFee).toBe(0); + expect(zeroPayment.netAmount).toBe(0); + }); }); }); diff --git a/core/payments/domain/entities/MembershipFee.test.ts b/core/payments/domain/entities/MembershipFee.test.ts index 928671780..403ee7a73 100644 --- a/core/payments/domain/entities/MembershipFee.test.ts +++ b/core/payments/domain/entities/MembershipFee.test.ts @@ -1,8 +1,200 @@ -import * as mod from '@core/payments/domain/entities/MembershipFee'; +import { + MembershipFee, + MembershipFeeType, +} from '@core/payments/domain/entities/MembershipFee'; import { describe, expect, it } from 'vitest'; -describe('payments/domain/entities/MembershipFee.ts', () => { - it('imports', () => { - expect(mod).toBeTruthy(); +describe('payments/domain/entities/MembershipFee', () => { + describe('MembershipFeeType enum', () => { + it('should have correct fee type values', () => { + expect(MembershipFeeType.SEASON).toBe('season'); + expect(MembershipFeeType.MONTHLY).toBe('monthly'); + expect(MembershipFeeType.PER_RACE).toBe('per_race'); + }); + }); + + describe('MembershipFee interface', () => { + it('should have all required properties', () => { + const fee: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + expect(fee.id).toBe('fee-123'); + expect(fee.leagueId).toBe('league-456'); + expect(fee.type).toBe(MembershipFeeType.SEASON); + expect(fee.amount).toBe(100); + expect(fee.enabled).toBe(true); + expect(fee.createdAt).toEqual(new Date('2024-01-01')); + expect(fee.updatedAt).toEqual(new Date('2024-01-01')); + }); + + it('should support optional seasonId property', () => { + const fee: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + seasonId: 'season-789', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + expect(fee.seasonId).toBe('season-789'); + }); + }); + + describe('MembershipFee.rehydrate', () => { + it('should rehydrate a MembershipFee from props', () => { + const props: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + const rehydrated = MembershipFee.rehydrate(props); + + expect(rehydrated).toEqual(props); + expect(rehydrated.id).toBe('fee-123'); + expect(rehydrated.leagueId).toBe('league-456'); + expect(rehydrated.type).toBe(MembershipFeeType.SEASON); + expect(rehydrated.amount).toBe(100); + expect(rehydrated.enabled).toBe(true); + expect(rehydrated.createdAt).toEqual(new Date('2024-01-01')); + expect(rehydrated.updatedAt).toEqual(new Date('2024-01-01')); + }); + + it('should preserve optional seasonId when rehydrating', () => { + const props: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + seasonId: 'season-789', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + const rehydrated = MembershipFee.rehydrate(props); + + expect(rehydrated.seasonId).toBe('season-789'); + }); + }); + + describe('Business rules and invariants', () => { + it('should support different fee types', () => { + const seasonFee: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + const monthlyFee: MembershipFee = { + id: 'fee-124', + leagueId: 'league-456', + type: MembershipFeeType.MONTHLY, + amount: 50, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + const perRaceFee: MembershipFee = { + id: 'fee-125', + leagueId: 'league-456', + type: MembershipFeeType.PER_RACE, + amount: 10, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + expect(seasonFee.type).toBe(MembershipFeeType.SEASON); + expect(monthlyFee.type).toBe(MembershipFeeType.MONTHLY); + expect(perRaceFee.type).toBe(MembershipFeeType.PER_RACE); + }); + + it('should handle enabled/disabled state', () => { + const enabledFee: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + const disabledFee: MembershipFee = { + id: 'fee-124', + leagueId: 'league-456', + type: MembershipFeeType.SEASON, + amount: 0, + enabled: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + expect(enabledFee.enabled).toBe(true); + expect(disabledFee.enabled).toBe(false); + }); + + it('should handle zero and negative amounts', () => { + const zeroFee: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + type: MembershipFeeType.SEASON, + amount: 0, + enabled: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + expect(zeroFee.amount).toBe(0); + expect(zeroFee.enabled).toBe(false); + }); + + it('should handle different league and season combinations', () => { + const leagueOnlyFee: MembershipFee = { + id: 'fee-123', + leagueId: 'league-456', + type: MembershipFeeType.MONTHLY, + amount: 50, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + const leagueAndSeasonFee: MembershipFee = { + id: 'fee-124', + leagueId: 'league-456', + seasonId: 'season-789', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + expect(leagueOnlyFee.leagueId).toBe('league-456'); + expect(leagueOnlyFee.seasonId).toBeUndefined(); + expect(leagueAndSeasonFee.leagueId).toBe('league-456'); + expect(leagueAndSeasonFee.seasonId).toBe('season-789'); + }); }); }); diff --git a/core/payments/domain/entities/Payment.test.ts b/core/payments/domain/entities/Payment.test.ts index d4c5828d3..1268fdc4e 100644 --- a/core/payments/domain/entities/Payment.test.ts +++ b/core/payments/domain/entities/Payment.test.ts @@ -1,8 +1,311 @@ -import * as mod from '@core/payments/domain/entities/Payment'; +import { + Payment, + PaymentStatus, + PaymentType, + PayerType, +} from '@core/payments/domain/entities/Payment'; import { describe, expect, it } from 'vitest'; -describe('payments/domain/entities/Payment.ts', () => { - it('imports', () => { - expect(mod).toBeTruthy(); +describe('payments/domain/entities/Payment', () => { + describe('PaymentType enum', () => { + it('should have correct payment type values', () => { + expect(PaymentType.SPONSORSHIP).toBe('sponsorship'); + expect(PaymentType.MEMBERSHIP_FEE).toBe('membership_fee'); + }); + }); + + describe('PayerType enum', () => { + it('should have correct payer type values', () => { + expect(PayerType.SPONSOR).toBe('sponsor'); + expect(PayerType.DRIVER).toBe('driver'); + }); + }); + + describe('PaymentStatus enum', () => { + it('should have correct status values', () => { + expect(PaymentStatus.PENDING).toBe('pending'); + expect(PaymentStatus.COMPLETED).toBe('completed'); + expect(PaymentStatus.FAILED).toBe('failed'); + expect(PaymentStatus.REFUNDED).toBe('refunded'); + }); + }); + + describe('Payment interface', () => { + it('should have all required properties', () => { + const payment: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-01-01'), + }; + + expect(payment.id).toBe('payment-123'); + expect(payment.type).toBe(PaymentType.SPONSORSHIP); + expect(payment.amount).toBe(1000); + expect(payment.platformFee).toBe(50); + expect(payment.netAmount).toBe(950); + expect(payment.payerId).toBe('sponsor-456'); + expect(payment.payerType).toBe(PayerType.SPONSOR); + expect(payment.leagueId).toBe('league-789'); + expect(payment.status).toBe(PaymentStatus.PENDING); + expect(payment.createdAt).toEqual(new Date('2024-01-01')); + }); + + it('should support optional seasonId property', () => { + const payment: Payment = { + id: 'payment-123', + type: PaymentType.MEMBERSHIP_FEE, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'driver-456', + payerType: PayerType.DRIVER, + leagueId: 'league-789', + seasonId: 'season-999', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-01-01'), + completedAt: new Date('2024-01-15'), + }; + + expect(payment.seasonId).toBe('season-999'); + expect(payment.completedAt).toEqual(new Date('2024-01-15')); + }); + + it('should support optional completedAt property', () => { + const payment: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-01-01'), + completedAt: new Date('2024-01-15'), + }; + + expect(payment.completedAt).toEqual(new Date('2024-01-15')); + }); + }); + + describe('Payment.rehydrate', () => { + it('should rehydrate a Payment from props', () => { + const props: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-01-01'), + }; + + const rehydrated = Payment.rehydrate(props); + + expect(rehydrated).toEqual(props); + expect(rehydrated.id).toBe('payment-123'); + expect(rehydrated.type).toBe(PaymentType.SPONSORSHIP); + expect(rehydrated.amount).toBe(1000); + expect(rehydrated.platformFee).toBe(50); + expect(rehydrated.netAmount).toBe(950); + expect(rehydrated.payerId).toBe('sponsor-456'); + expect(rehydrated.payerType).toBe(PayerType.SPONSOR); + expect(rehydrated.leagueId).toBe('league-789'); + expect(rehydrated.status).toBe(PaymentStatus.PENDING); + expect(rehydrated.createdAt).toEqual(new Date('2024-01-01')); + }); + + it('should preserve optional seasonId when rehydrating', () => { + const props: Payment = { + id: 'payment-123', + type: PaymentType.MEMBERSHIP_FEE, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'driver-456', + payerType: PayerType.DRIVER, + leagueId: 'league-789', + seasonId: 'season-999', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-01-01'), + completedAt: new Date('2024-01-15'), + }; + + const rehydrated = Payment.rehydrate(props); + + expect(rehydrated.seasonId).toBe('season-999'); + expect(rehydrated.completedAt).toEqual(new Date('2024-01-15')); + }); + }); + + describe('Business rules and invariants', () => { + it('should calculate netAmount correctly (amount - platformFee)', () => { + const payment: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-01-01'), + }; + + expect(payment.netAmount).toBe(payment.amount - payment.platformFee); + }); + + it('should support different payment types', () => { + const sponsorshipPayment: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-01-01'), + }; + + const membershipFeePayment: Payment = { + id: 'payment-124', + type: PaymentType.MEMBERSHIP_FEE, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'driver-456', + payerType: PayerType.DRIVER, + leagueId: 'league-789', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-01-01'), + }; + + expect(sponsorshipPayment.type).toBe(PaymentType.SPONSORSHIP); + expect(membershipFeePayment.type).toBe(PaymentType.MEMBERSHIP_FEE); + }); + + it('should support different payer types', () => { + const sponsorPayment: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-01-01'), + }; + + const driverPayment: Payment = { + id: 'payment-124', + type: PaymentType.MEMBERSHIP_FEE, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'driver-456', + payerType: PayerType.DRIVER, + leagueId: 'league-789', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-01-01'), + }; + + expect(sponsorPayment.payerType).toBe(PayerType.SPONSOR); + expect(driverPayment.payerType).toBe(PayerType.DRIVER); + }); + + it('should support different payment statuses', () => { + const pendingPayment: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-01-01'), + }; + + const completedPayment: Payment = { + id: 'payment-124', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-01-01'), + completedAt: new Date('2024-01-15'), + }; + + const failedPayment: Payment = { + id: 'payment-125', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.FAILED, + createdAt: new Date('2024-01-01'), + }; + + const refundedPayment: Payment = { + id: 'payment-126', + type: PaymentType.SPONSORSHIP, + amount: 1000, + platformFee: 50, + netAmount: 950, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.REFUNDED, + createdAt: new Date('2024-01-01'), + }; + + expect(pendingPayment.status).toBe(PaymentStatus.PENDING); + expect(completedPayment.status).toBe(PaymentStatus.COMPLETED); + expect(failedPayment.status).toBe(PaymentStatus.FAILED); + expect(refundedPayment.status).toBe(PaymentStatus.REFUNDED); + }); + + it('should handle zero and negative amounts', () => { + const zeroPayment: Payment = { + id: 'payment-123', + type: PaymentType.SPONSORSHIP, + amount: 0, + platformFee: 0, + netAmount: 0, + payerId: 'sponsor-456', + payerType: PayerType.SPONSOR, + leagueId: 'league-789', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-01-01'), + }; + + expect(zeroPayment.amount).toBe(0); + expect(zeroPayment.platformFee).toBe(0); + expect(zeroPayment.netAmount).toBe(0); + }); }); }); diff --git a/core/payments/domain/entities/Prize.test.ts b/core/payments/domain/entities/Prize.test.ts index 4b0e76833..78a544fe3 100644 --- a/core/payments/domain/entities/Prize.test.ts +++ b/core/payments/domain/entities/Prize.test.ts @@ -1,8 +1,298 @@ -import * as mod from '@core/payments/domain/entities/Prize'; +import { Prize, PrizeType } from '@core/payments/domain/entities/Prize'; import { describe, expect, it } from 'vitest'; -describe('payments/domain/entities/Prize.ts', () => { - it('imports', () => { - expect(mod).toBeTruthy(); +describe('payments/domain/entities/Prize', () => { + describe('PrizeType enum', () => { + it('should have correct prize type values', () => { + expect(PrizeType.CASH).toBe('cash'); + expect(PrizeType.MERCHANDISE).toBe('merchandise'); + expect(PrizeType.OTHER).toBe('other'); + }); + }); + + describe('Prize interface', () => { + it('should have all required properties', () => { + const prize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + expect(prize.id).toBe('prize-123'); + expect(prize.leagueId).toBe('league-456'); + expect(prize.seasonId).toBe('season-789'); + expect(prize.position).toBe(1); + expect(prize.name).toBe('Champion Prize'); + expect(prize.amount).toBe(1000); + expect(prize.type).toBe(PrizeType.CASH); + expect(prize.awarded).toBe(false); + expect(prize.createdAt).toEqual(new Date('2024-01-01')); + }); + + it('should support optional description property', () => { + const prize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + description: 'Awarded to the champion of the season', + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + expect(prize.description).toBe('Awarded to the champion of the season'); + }); + + it('should support optional awardedTo and awardedAt properties', () => { + const prize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: true, + awardedTo: 'driver-999', + awardedAt: new Date('2024-06-01'), + createdAt: new Date('2024-01-01'), + }; + + expect(prize.awardedTo).toBe('driver-999'); + expect(prize.awardedAt).toEqual(new Date('2024-06-01')); + }); + }); + + describe('Prize.rehydrate', () => { + it('should rehydrate a Prize from props', () => { + const props: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + const rehydrated = Prize.rehydrate(props); + + expect(rehydrated).toEqual(props); + expect(rehydrated.id).toBe('prize-123'); + expect(rehydrated.leagueId).toBe('league-456'); + expect(rehydrated.seasonId).toBe('season-789'); + expect(rehydrated.position).toBe(1); + expect(rehydrated.name).toBe('Champion Prize'); + expect(rehydrated.amount).toBe(1000); + expect(rehydrated.type).toBe(PrizeType.CASH); + expect(rehydrated.awarded).toBe(false); + expect(rehydrated.createdAt).toEqual(new Date('2024-01-01')); + }); + + it('should preserve optional description when rehydrating', () => { + const props: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + description: 'Awarded to the champion of the season', + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + const rehydrated = Prize.rehydrate(props); + + expect(rehydrated.description).toBe('Awarded to the champion of the season'); + }); + + it('should preserve optional awardedTo and awardedAt when rehydrating', () => { + const props: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: true, + awardedTo: 'driver-999', + awardedAt: new Date('2024-06-01'), + createdAt: new Date('2024-01-01'), + }; + + const rehydrated = Prize.rehydrate(props); + + expect(rehydrated.awardedTo).toBe('driver-999'); + expect(rehydrated.awardedAt).toEqual(new Date('2024-06-01')); + }); + }); + + describe('Business rules and invariants', () => { + it('should support different prize types', () => { + const cashPrize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + const merchandisePrize: Prize = { + id: 'prize-124', + leagueId: 'league-456', + seasonId: 'season-789', + position: 2, + name: 'T-Shirt', + amount: 50, + type: PrizeType.MERCHANDISE, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + const otherPrize: Prize = { + id: 'prize-125', + leagueId: 'league-456', + seasonId: 'season-789', + position: 3, + name: 'Special Recognition', + amount: 0, + type: PrizeType.OTHER, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + expect(cashPrize.type).toBe(PrizeType.CASH); + expect(merchandisePrize.type).toBe(PrizeType.MERCHANDISE); + expect(otherPrize.type).toBe(PrizeType.OTHER); + }); + + it('should handle awarded and unawarded prizes', () => { + const unawardedPrize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + const awardedPrize: Prize = { + id: 'prize-124', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: true, + awardedTo: 'driver-999', + awardedAt: new Date('2024-06-01'), + createdAt: new Date('2024-01-01'), + }; + + expect(unawardedPrize.awarded).toBe(false); + expect(unawardedPrize.awardedTo).toBeUndefined(); + expect(unawardedPrize.awardedAt).toBeUndefined(); + + expect(awardedPrize.awarded).toBe(true); + expect(awardedPrize.awardedTo).toBe('driver-999'); + expect(awardedPrize.awardedAt).toEqual(new Date('2024-06-01')); + }); + + it('should handle different positions', () => { + const firstPlacePrize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + const secondPlacePrize: Prize = { + id: 'prize-124', + leagueId: 'league-456', + seasonId: 'season-789', + position: 2, + name: 'Runner-Up Prize', + amount: 500, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + const thirdPlacePrize: Prize = { + id: 'prize-125', + leagueId: 'league-456', + seasonId: 'season-789', + position: 3, + name: 'Third Place Prize', + amount: 250, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + expect(firstPlacePrize.position).toBe(1); + expect(secondPlacePrize.position).toBe(2); + expect(thirdPlacePrize.position).toBe(3); + }); + + it('should handle zero and negative amounts', () => { + const zeroPrize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Participation Prize', + amount: 0, + type: PrizeType.OTHER, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + expect(zeroPrize.amount).toBe(0); + }); + + it('should handle different league and season combinations', () => { + const leagueOnlyPrize: Prize = { + id: 'prize-123', + leagueId: 'league-456', + seasonId: 'season-789', + position: 1, + name: 'Champion Prize', + amount: 1000, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01'), + }; + + expect(leagueOnlyPrize.leagueId).toBe('league-456'); + expect(leagueOnlyPrize.seasonId).toBe('season-789'); + }); }); }); diff --git a/core/payments/domain/entities/Wallet.test.ts b/core/payments/domain/entities/Wallet.test.ts index afc734547..4f11932bf 100644 --- a/core/payments/domain/entities/Wallet.test.ts +++ b/core/payments/domain/entities/Wallet.test.ts @@ -1,8 +1,284 @@ -import * as mod from '@core/payments/domain/entities/Wallet'; +import { + ReferenceType, + Transaction, + TransactionType, + Wallet, +} from '@core/payments/domain/entities/Wallet'; import { describe, expect, it } from 'vitest'; -describe('payments/domain/entities/Wallet.ts', () => { - it('imports', () => { - expect(mod).toBeTruthy(); +describe('payments/domain/entities/Wallet', () => { + describe('TransactionType enum', () => { + it('should have correct transaction type values', () => { + expect(TransactionType.DEPOSIT).toBe('deposit'); + expect(TransactionType.WITHDRAWAL).toBe('withdrawal'); + expect(TransactionType.PLATFORM_FEE).toBe('platform_fee'); + }); + }); + + describe('ReferenceType enum', () => { + it('should have correct reference type values', () => { + expect(ReferenceType.SPONSORSHIP).toBe('sponsorship'); + expect(ReferenceType.MEMBERSHIP_FEE).toBe('membership_fee'); + expect(ReferenceType.PRIZE).toBe('prize'); + }); + }); + + describe('Wallet interface', () => { + it('should have all required properties', () => { + const wallet: Wallet = { + id: 'wallet-123', + leagueId: 'league-456', + balance: 1000, + totalRevenue: 5000, + totalPlatformFees: 250, + totalWithdrawn: 3750, + currency: 'USD', + createdAt: new Date('2024-01-01'), + }; + + expect(wallet.id).toBe('wallet-123'); + expect(wallet.leagueId).toBe('league-456'); + expect(wallet.balance).toBe(1000); + expect(wallet.totalRevenue).toBe(5000); + expect(wallet.totalPlatformFees).toBe(250); + expect(wallet.totalWithdrawn).toBe(3750); + expect(wallet.currency).toBe('USD'); + expect(wallet.createdAt).toEqual(new Date('2024-01-01')); + }); + }); + + describe('Wallet.rehydrate', () => { + it('should rehydrate a Wallet from props', () => { + const props: Wallet = { + id: 'wallet-123', + leagueId: 'league-456', + balance: 1000, + totalRevenue: 5000, + totalPlatformFees: 250, + totalWithdrawn: 3750, + currency: 'USD', + createdAt: new Date('2024-01-01'), + }; + + const rehydrated = Wallet.rehydrate(props); + + expect(rehydrated).toEqual(props); + expect(rehydrated.id).toBe('wallet-123'); + expect(rehydrated.leagueId).toBe('league-456'); + expect(rehydrated.balance).toBe(1000); + expect(rehydrated.totalRevenue).toBe(5000); + expect(rehydrated.totalPlatformFees).toBe(250); + expect(rehydrated.totalWithdrawn).toBe(3750); + expect(rehydrated.currency).toBe('USD'); + expect(rehydrated.createdAt).toEqual(new Date('2024-01-01')); + }); + }); + + describe('Transaction interface', () => { + it('should have all required properties', () => { + const transaction: Transaction = { + id: 'txn-123', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 1000, + description: 'Sponsorship payment', + createdAt: new Date('2024-01-01'), + }; + + expect(transaction.id).toBe('txn-123'); + expect(transaction.walletId).toBe('wallet-456'); + expect(transaction.type).toBe(TransactionType.DEPOSIT); + expect(transaction.amount).toBe(1000); + expect(transaction.description).toBe('Sponsorship payment'); + expect(transaction.createdAt).toEqual(new Date('2024-01-01')); + }); + + it('should support optional referenceId and referenceType properties', () => { + const transaction: Transaction = { + id: 'txn-123', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 1000, + description: 'Sponsorship payment', + referenceId: 'payment-789', + referenceType: ReferenceType.SPONSORSHIP, + createdAt: new Date('2024-01-01'), + }; + + expect(transaction.referenceId).toBe('payment-789'); + expect(transaction.referenceType).toBe(ReferenceType.SPONSORSHIP); + }); + }); + + describe('Transaction.rehydrate', () => { + it('should rehydrate a Transaction from props', () => { + const props: Transaction = { + id: 'txn-123', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 1000, + description: 'Sponsorship payment', + createdAt: new Date('2024-01-01'), + }; + + const rehydrated = Transaction.rehydrate(props); + + expect(rehydrated).toEqual(props); + expect(rehydrated.id).toBe('txn-123'); + expect(rehydrated.walletId).toBe('wallet-456'); + expect(rehydrated.type).toBe(TransactionType.DEPOSIT); + expect(rehydrated.amount).toBe(1000); + expect(rehydrated.description).toBe('Sponsorship payment'); + expect(rehydrated.createdAt).toEqual(new Date('2024-01-01')); + }); + + it('should preserve optional referenceId and referenceType when rehydrating', () => { + const props: Transaction = { + id: 'txn-123', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 1000, + description: 'Sponsorship payment', + referenceId: 'payment-789', + referenceType: ReferenceType.SPONSORSHIP, + createdAt: new Date('2024-01-01'), + }; + + const rehydrated = Transaction.rehydrate(props); + + expect(rehydrated.referenceId).toBe('payment-789'); + expect(rehydrated.referenceType).toBe(ReferenceType.SPONSORSHIP); + }); + }); + + describe('Business rules and invariants', () => { + it('should calculate balance correctly', () => { + const wallet: Wallet = { + id: 'wallet-123', + leagueId: 'league-456', + balance: 1000, + totalRevenue: 5000, + totalPlatformFees: 250, + totalWithdrawn: 3750, + currency: 'USD', + createdAt: new Date('2024-01-01'), + }; + + // Balance should be: totalRevenue - totalPlatformFees - totalWithdrawn + const expectedBalance = wallet.totalRevenue - wallet.totalPlatformFees - wallet.totalWithdrawn; + expect(wallet.balance).toBe(expectedBalance); + }); + + it('should support different transaction types', () => { + const depositTransaction: Transaction = { + id: 'txn-123', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 1000, + description: 'Sponsorship payment', + createdAt: new Date('2024-01-01'), + }; + + const withdrawalTransaction: Transaction = { + id: 'txn-124', + walletId: 'wallet-456', + type: TransactionType.WITHDRAWAL, + amount: 500, + description: 'Withdrawal to bank', + createdAt: new Date('2024-01-01'), + }; + + const platformFeeTransaction: Transaction = { + id: 'txn-125', + walletId: 'wallet-456', + type: TransactionType.PLATFORM_FEE, + amount: 50, + description: 'Platform fee deduction', + createdAt: new Date('2024-01-01'), + }; + + expect(depositTransaction.type).toBe(TransactionType.DEPOSIT); + expect(withdrawalTransaction.type).toBe(TransactionType.WITHDRAWAL); + expect(platformFeeTransaction.type).toBe(TransactionType.PLATFORM_FEE); + }); + + it('should support different reference types', () => { + const sponsorshipTransaction: Transaction = { + id: 'txn-123', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 1000, + description: 'Sponsorship payment', + referenceId: 'payment-789', + referenceType: ReferenceType.SPONSORSHIP, + createdAt: new Date('2024-01-01'), + }; + + const membershipFeeTransaction: Transaction = { + id: 'txn-124', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 100, + description: 'Membership fee payment', + referenceId: 'payment-790', + referenceType: ReferenceType.MEMBERSHIP_FEE, + createdAt: new Date('2024-01-01'), + }; + + const prizeTransaction: Transaction = { + id: 'txn-125', + walletId: 'wallet-456', + type: TransactionType.WITHDRAWAL, + amount: 500, + description: 'Prize payout', + referenceId: 'prize-791', + referenceType: ReferenceType.PRIZE, + createdAt: new Date('2024-01-01'), + }; + + expect(sponsorshipTransaction.referenceType).toBe(ReferenceType.SPONSORSHIP); + expect(membershipFeeTransaction.referenceType).toBe(ReferenceType.MEMBERSHIP_FEE); + expect(prizeTransaction.referenceType).toBe(ReferenceType.PRIZE); + }); + + it('should handle zero and negative amounts', () => { + const zeroTransaction: Transaction = { + id: 'txn-123', + walletId: 'wallet-456', + type: TransactionType.DEPOSIT, + amount: 0, + description: 'Zero amount transaction', + createdAt: new Date('2024-01-01'), + }; + + expect(zeroTransaction.amount).toBe(0); + }); + + it('should handle different currencies', () => { + const usdWallet: Wallet = { + id: 'wallet-123', + leagueId: 'league-456', + balance: 1000, + totalRevenue: 5000, + totalPlatformFees: 250, + totalWithdrawn: 3750, + currency: 'USD', + createdAt: new Date('2024-01-01'), + }; + + const eurWallet: Wallet = { + id: 'wallet-124', + leagueId: 'league-457', + balance: 1000, + totalRevenue: 5000, + totalPlatformFees: 250, + totalWithdrawn: 3750, + currency: 'EUR', + createdAt: new Date('2024-01-01'), + }; + + expect(usdWallet.currency).toBe('USD'); + expect(eurWallet.currency).toBe('EUR'); + }); }); }); diff --git a/core/ports/media/MediaResolverPort.comprehensive.test.ts b/core/ports/media/MediaResolverPort.comprehensive.test.ts new file mode 100644 index 000000000..290313201 --- /dev/null +++ b/core/ports/media/MediaResolverPort.comprehensive.test.ts @@ -0,0 +1,501 @@ +/** + * Comprehensive Tests for MediaResolverPort + * + * Tests cover: + * - Interface contract compliance + * - ResolutionStrategies for all reference types + * - resolveWithDefaults helper function + * - isMediaResolverPort type guard + * - Edge cases and error handling + * - Business logic decisions + */ + +import { MediaReference } from '@core/domain/media/MediaReference'; +import { describe, expect, it } from 'vitest'; +import { + MediaResolverPort, + ResolutionStrategies, + resolveWithDefaults, + isMediaResolverPort, +} from './MediaResolverPort'; + +describe('MediaResolverPort - Comprehensive Tests', () => { + describe('Interface Contract Compliance', () => { + it('should define resolve method signature correctly', () => { + // Verify the interface has the correct method signature + const testInterface: MediaResolverPort = { + resolve: async (ref: MediaReference): Promise => { + return null; + }, + }; + + expect(testInterface).toBeDefined(); + expect(typeof testInterface.resolve).toBe('function'); + }); + + it('should accept MediaReference and return Promise', async () => { + const mockResolver: MediaResolverPort = { + resolve: async (ref: MediaReference): Promise => { + // Verify ref is a MediaReference instance + expect(ref).toBeInstanceOf(MediaReference); + return '/test/path'; + }, + }; + + const ref = MediaReference.createSystemDefault('avatar'); + const result = await mockResolver.resolve(ref); + + expect(result).toBe('/test/path'); + }); + }); + + describe('ResolutionStrategies - System Default', () => { + it('should resolve system-default avatar without variant', () => { + const ref = MediaReference.createSystemDefault('avatar'); + const result = ResolutionStrategies.systemDefault(ref); + + expect(result).toBe('/media/default/neutral-default-avatar.png'); + }); + + it('should resolve system-default avatar with male variant', () => { + const ref = MediaReference.createSystemDefault('avatar', 'male'); + const result = ResolutionStrategies.systemDefault(ref); + + expect(result).toBe('/media/default/male-default-avatar.png'); + }); + + it('should resolve system-default avatar with female variant', () => { + const ref = MediaReference.createSystemDefault('avatar', 'female'); + const result = ResolutionStrategies.systemDefault(ref); + + expect(result).toBe('/media/default/female-default-avatar.png'); + }); + + it('should resolve system-default avatar with neutral variant', () => { + const ref = MediaReference.createSystemDefault('avatar', 'neutral'); + const result = ResolutionStrategies.systemDefault(ref); + + expect(result).toBe('/media/default/neutral-default-avatar.png'); + }); + + it('should resolve system-default logo', () => { + const ref = MediaReference.createSystemDefault('logo'); + const result = ResolutionStrategies.systemDefault(ref); + + expect(result).toBe('/media/default/logo.png'); + }); + + it('should return null for non-system-default reference', () => { + const ref = MediaReference.createGenerated('team-123'); + const result = ResolutionStrategies.systemDefault(ref); + + expect(result).toBeNull(); + }); + }); + + describe('ResolutionStrategies - Generated', () => { + it('should resolve generated reference for team', () => { + const ref = MediaReference.createGenerated('team-123'); + const result = ResolutionStrategies.generated(ref); + + expect(result).toBe('/media/teams/123/logo'); + }); + + it('should resolve generated reference for league', () => { + const ref = MediaReference.createGenerated('league-456'); + const result = ResolutionStrategies.generated(ref); + + expect(result).toBe('/media/leagues/456/logo'); + }); + + it('should resolve generated reference for driver', () => { + const ref = MediaReference.createGenerated('driver-789'); + const result = ResolutionStrategies.generated(ref); + + expect(result).toBe('/media/avatar/789'); + }); + + it('should resolve generated reference for unknown type', () => { + const ref = MediaReference.createGenerated('unknown-999'); + const result = ResolutionStrategies.generated(ref); + + expect(result).toBe('/media/generated/unknown/999'); + }); + + it('should return null for generated reference without generationRequestId', () => { + // Create a reference with missing generationRequestId + const ref = MediaReference.createGenerated('valid-id'); + // Manually create an invalid reference + const invalidRef = { type: 'generated' } as MediaReference; + const result = ResolutionStrategies.generated(invalidRef); + + expect(result).toBeNull(); + }); + + it('should return null for non-generated reference', () => { + const ref = MediaReference.createSystemDefault('avatar'); + const result = ResolutionStrategies.generated(ref); + + expect(result).toBeNull(); + }); + + it('should handle generated reference with special characters in ID', () => { + const ref = MediaReference.createGenerated('team-abc-123_XYZ'); + const result = ResolutionStrategies.generated(ref); + + expect(result).toBe('/media/teams/abc-123_XYZ/logo'); + }); + + it('should handle generated reference with multiple hyphens', () => { + const ref = MediaReference.createGenerated('team-abc-def-123'); + const result = ResolutionStrategies.generated(ref); + + expect(result).toBe('/media/teams/abc-def-123/logo'); + }); + }); + + describe('ResolutionStrategies - Uploaded', () => { + it('should resolve uploaded reference', () => { + const ref = MediaReference.createUploaded('media-456'); + const result = ResolutionStrategies.uploaded(ref); + + expect(result).toBe('/media/uploaded/media-456'); + }); + + it('should return null for uploaded reference without mediaId', () => { + // Create a reference with missing mediaId + const ref = MediaReference.createUploaded('valid-id'); + // Manually create an invalid reference + const invalidRef = { type: 'uploaded' } as MediaReference; + const result = ResolutionStrategies.uploaded(invalidRef); + + expect(result).toBeNull(); + }); + + it('should return null for non-uploaded reference', () => { + const ref = MediaReference.createSystemDefault('avatar'); + const result = ResolutionStrategies.uploaded(ref); + + expect(result).toBeNull(); + }); + + it('should handle uploaded reference with special characters', () => { + const ref = MediaReference.createUploaded('media-abc-123_XYZ'); + const result = ResolutionStrategies.uploaded(ref); + + expect(result).toBe('/media/uploaded/media-abc-123_XYZ'); + }); + + it('should handle uploaded reference with very long ID', () => { + const longId = 'a'.repeat(1000); + const ref = MediaReference.createUploaded(longId); + const result = ResolutionStrategies.uploaded(ref); + + expect(result).toBe(`/media/uploaded/${longId}`); + }); + }); + + describe('ResolutionStrategies - None', () => { + it('should return null for none reference', () => { + const ref = MediaReference.createNone(); + const result = ResolutionStrategies.none(ref); + + expect(result).toBeNull(); + }); + + it('should return null for any reference passed to none strategy', () => { + const ref = MediaReference.createSystemDefault('avatar'); + const result = ResolutionStrategies.none(ref); + + expect(result).toBeNull(); + }); + }); + + describe('resolveWithDefaults - Integration Tests', () => { + it('should resolve system-default reference using resolveWithDefaults', () => { + const ref = MediaReference.createSystemDefault('avatar'); + const result = resolveWithDefaults(ref); + + expect(result).toBe('/media/default/neutral-default-avatar.png'); + }); + + it('should resolve system-default avatar with male variant using resolveWithDefaults', () => { + const ref = MediaReference.createSystemDefault('avatar', 'male'); + const result = resolveWithDefaults(ref); + + expect(result).toBe('/media/default/male-default-avatar.png'); + }); + + it('should resolve system-default logo using resolveWithDefaults', () => { + const ref = MediaReference.createSystemDefault('logo'); + const result = resolveWithDefaults(ref); + + expect(result).toBe('/media/default/logo.png'); + }); + + it('should resolve generated reference using resolveWithDefaults', () => { + const ref = MediaReference.createGenerated('team-123'); + const result = resolveWithDefaults(ref); + + expect(result).toBe('/media/teams/123/logo'); + }); + + it('should resolve uploaded reference using resolveWithDefaults', () => { + const ref = MediaReference.createUploaded('media-456'); + const result = resolveWithDefaults(ref); + + expect(result).toBe('/media/uploaded/media-456'); + }); + + it('should resolve none reference using resolveWithDefaults', () => { + const ref = MediaReference.createNone(); + const result = resolveWithDefaults(ref); + + expect(result).toBeNull(); + }); + + it('should handle all reference types in sequence', () => { + const refs = [ + MediaReference.createSystemDefault('avatar'), + MediaReference.createSystemDefault('avatar', 'male'), + MediaReference.createSystemDefault('logo'), + MediaReference.createGenerated('team-123'), + MediaReference.createGenerated('league-456'), + MediaReference.createGenerated('driver-789'), + MediaReference.createUploaded('media-456'), + MediaReference.createNone(), + ]; + + const results = refs.map(ref => resolveWithDefaults(ref)); + + expect(results).toEqual([ + '/media/default/neutral-default-avatar.png', + '/media/default/male-default-avatar.png', + '/media/default/logo.png', + '/media/teams/123/logo', + '/media/leagues/456/logo', + '/media/avatar/789', + '/media/uploaded/media-456', + null, + ]); + }); + }); + + describe('isMediaResolverPort Type Guard', () => { + it('should return true for valid MediaResolverPort implementation', () => { + const validResolver: MediaResolverPort = { + resolve: async (ref: MediaReference): Promise => { + return '/test/path'; + }, + }; + + expect(isMediaResolverPort(validResolver)).toBe(true); + }); + + it('should return false for null', () => { + expect(isMediaResolverPort(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isMediaResolverPort(undefined)).toBe(false); + }); + + it('should return false for non-object', () => { + expect(isMediaResolverPort('string')).toBe(false); + expect(isMediaResolverPort(123)).toBe(false); + expect(isMediaResolverPort(true)).toBe(false); + }); + + it('should return false for object without resolve method', () => { + const invalidResolver = { + someOtherMethod: () => {}, + }; + + expect(isMediaResolverPort(invalidResolver)).toBe(false); + }); + + it('should return false for object with resolve property but not a function', () => { + const invalidResolver = { + resolve: 'not a function', + }; + + expect(isMediaResolverPort(invalidResolver)).toBe(false); + }); + + it('should return false for object with resolve as non-function property', () => { + const invalidResolver = { + resolve: 123, + }; + + expect(isMediaResolverPort(invalidResolver)).toBe(false); + }); + + it('should return true for object with resolve method and other properties', () => { + const validResolver = { + resolve: async (ref: MediaReference): Promise => { + return '/test/path'; + }, + extraProperty: 'value', + anotherMethod: () => {}, + }; + + expect(isMediaResolverPort(validResolver)).toBe(true); + }); + }); + + describe('Business Logic Decisions', () => { + it('should make correct decision for system-default avatar without variant', () => { + const ref = MediaReference.createSystemDefault('avatar'); + const result = resolveWithDefaults(ref); + + // Decision: Should use neutral default avatar + expect(result).toBe('/media/default/neutral-default-avatar.png'); + }); + + it('should make correct decision for system-default avatar with specific variant', () => { + const ref = MediaReference.createSystemDefault('avatar', 'female'); + const result = resolveWithDefaults(ref); + + // Decision: Should use the specified variant + expect(result).toBe('/media/default/female-default-avatar.png'); + }); + + it('should make correct decision for generated team reference', () => { + const ref = MediaReference.createGenerated('team-123'); + const result = resolveWithDefaults(ref); + + // Decision: Should resolve to team logo path + expect(result).toBe('/media/teams/123/logo'); + }); + + it('should make correct decision for generated league reference', () => { + const ref = MediaReference.createGenerated('league-456'); + const result = resolveWithDefaults(ref); + + // Decision: Should resolve to league logo path + expect(result).toBe('/media/leagues/456/logo'); + }); + + it('should make correct decision for generated driver reference', () => { + const ref = MediaReference.createGenerated('driver-789'); + const result = resolveWithDefaults(ref); + + // Decision: Should resolve to avatar path + expect(result).toBe('/media/avatar/789'); + }); + + it('should make correct decision for uploaded reference', () => { + const ref = MediaReference.createUploaded('media-456'); + const result = resolveWithDefaults(ref); + + // Decision: Should resolve to uploaded media path + expect(result).toBe('/media/uploaded/media-456'); + }); + + it('should make correct decision for none reference', () => { + const ref = MediaReference.createNone(); + const result = resolveWithDefaults(ref); + + // Decision: Should return null (no media) + expect(result).toBeNull(); + }); + + it('should make correct decision for unknown generated type', () => { + const ref = MediaReference.createGenerated('unknown-999'); + const result = resolveWithDefaults(ref); + + // Decision: Should fall back to generic generated path + expect(result).toBe('/media/generated/unknown/999'); + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should handle empty string IDs gracefully', () => { + // MediaReference factory methods throw on empty strings + // This tests that the strategies handle invalid refs gracefully + const invalidRef = { type: 'generated' } as MediaReference; + const result = ResolutionStrategies.generated(invalidRef); + + expect(result).toBeNull(); + }); + + it('should handle references with missing properties', () => { + const invalidRef = { type: 'uploaded' } as MediaReference; + const result = ResolutionStrategies.uploaded(invalidRef); + + expect(result).toBeNull(); + }); + + it('should handle very long IDs without performance issues', () => { + const longId = 'a'.repeat(10000); + const ref = MediaReference.createUploaded(longId); + const result = resolveWithDefaults(ref); + + expect(result).toBe(`/media/uploaded/${longId}`); + }); + + it('should handle Unicode characters in IDs', () => { + const ref = MediaReference.createUploaded('media-日本語-123'); + const result = resolveWithDefaults(ref); + + expect(result).toBe('/media/uploaded/media-日本語-123'); + }); + + it('should handle special characters in generated IDs', () => { + const ref = MediaReference.createGenerated('team-abc_def-123'); + const result = resolveWithDefaults(ref); + + expect(result).toBe('/media/teams/abc_def-123/logo'); + }); + }); + + describe('Path Format Consistency', () => { + it('should maintain consistent path format for system-default', () => { + const ref = MediaReference.createSystemDefault('avatar'); + const result = resolveWithDefaults(ref); + + // Should start with /media/default/ + expect(result).toMatch(/^\/media\/default\//); + }); + + it('should maintain consistent path format for generated team', () => { + const ref = MediaReference.createGenerated('team-123'); + const result = resolveWithDefaults(ref); + + // Should start with /media/teams/ + expect(result).toMatch(/^\/media\/teams\//); + }); + + it('should maintain consistent path format for generated league', () => { + const ref = MediaReference.createGenerated('league-456'); + const result = resolveWithDefaults(ref); + + // Should start with /media/leagues/ + expect(result).toMatch(/^\/media\/leagues\//); + }); + + it('should maintain consistent path format for generated driver', () => { + const ref = MediaReference.createGenerated('driver-789'); + const result = resolveWithDefaults(ref); + + // Should start with /media/avatar/ + expect(result).toMatch(/^\/media\/avatar\//); + }); + + it('should maintain consistent path format for uploaded', () => { + const ref = MediaReference.createUploaded('media-456'); + const result = resolveWithDefaults(ref); + + // Should start with /media/uploaded/ + expect(result).toMatch(/^\/media\/uploaded\//); + }); + + it('should maintain consistent path format for unknown generated type', () => { + const ref = MediaReference.createGenerated('unknown-999'); + const result = resolveWithDefaults(ref); + + // Should start with /media/generated/ + expect(result).toMatch(/^\/media\/generated\//); + }); + }); +}); diff --git a/core/racing/application/use-cases/DriverStatsUseCase.test.ts b/core/racing/application/use-cases/DriverStatsUseCase.test.ts new file mode 100644 index 000000000..aa55d7c4a --- /dev/null +++ b/core/racing/application/use-cases/DriverStatsUseCase.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; +import { DriverStatsUseCase, type DriverStats } from './DriverStatsUseCase'; +import type { ResultRepository } from '../../domain/repositories/ResultRepository'; +import type { StandingRepository } from '../../domain/repositories/StandingRepository'; +import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository'; +import type { Logger } from '@core/shared/domain/Logger'; + +describe('DriverStatsUseCase', () => { + const mockResultRepository = {} as ResultRepository; + const mockStandingRepository = {} as StandingRepository; + const mockDriverStatsRepository = { + getDriverStats: vi.fn(), + } as unknown as DriverStatsRepository; + const mockLogger = { + debug: vi.fn(), + } as unknown as Logger; + + const useCase = new DriverStatsUseCase( + mockResultRepository, + mockStandingRepository, + mockDriverStatsRepository, + mockLogger + ); + + it('should return driver stats when found', async () => { + const mockStats: DriverStats = { + rating: 1500, + safetyRating: 4.5, + sportsmanshipRating: 4.8, + totalRaces: 10, + wins: 2, + podiums: 5, + dnfs: 0, + avgFinish: 3.5, + bestFinish: 1, + worstFinish: 8, + consistency: 0.9, + experienceLevel: 'Intermediate', + overallRank: 42, + }; + vi.mocked(mockDriverStatsRepository.getDriverStats).mockResolvedValue(mockStats); + + const result = await useCase.getDriverStats('driver-1'); + + expect(result).toEqual(mockStats); + expect(mockLogger.debug).toHaveBeenCalledWith('Getting stats for driver driver-1'); + expect(mockDriverStatsRepository.getDriverStats).toHaveBeenCalledWith('driver-1'); + }); + + it('should return null when stats are not found', async () => { + vi.mocked(mockDriverStatsRepository.getDriverStats).mockResolvedValue(null); + + const result = await useCase.getDriverStats('non-existent'); + + expect(result).toBeNull(); + }); +}); diff --git a/core/racing/application/use-cases/GetDriverUseCase.test.ts b/core/racing/application/use-cases/GetDriverUseCase.test.ts new file mode 100644 index 000000000..3181cb92b --- /dev/null +++ b/core/racing/application/use-cases/GetDriverUseCase.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { GetDriverUseCase } from './GetDriverUseCase'; +import { Result } from '@core/shared/domain/Result'; +import type { DriverRepository } from '../../domain/repositories/DriverRepository'; +import type { Driver } from '../../domain/entities/Driver'; + +describe('GetDriverUseCase', () => { + const mockDriverRepository = { + findById: vi.fn(), + } as unknown as DriverRepository; + + const useCase = new GetDriverUseCase(mockDriverRepository); + + it('should return a driver when found', async () => { + const mockDriver = { id: 'driver-1', name: 'John Doe' } as unknown as Driver; + vi.mocked(mockDriverRepository.findById).mockResolvedValue(mockDriver); + + const result = await useCase.execute({ driverId: 'driver-1' }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBe(mockDriver); + expect(mockDriverRepository.findById).toHaveBeenCalledWith('driver-1'); + }); + + it('should return null when driver is not found', async () => { + vi.mocked(mockDriverRepository.findById).mockResolvedValue(null); + + const result = await useCase.execute({ driverId: 'non-existent' }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeNull(); + }); + + it('should return an error when repository throws', async () => { + const error = new Error('Repository error'); + vi.mocked(mockDriverRepository.findById).mockRejectedValue(error); + + const result = await useCase.execute({ driverId: 'driver-1' }); + + expect(result.isErr()).toBe(true); + expect(result.error).toBe(error); + }); +}); diff --git a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts new file mode 100644 index 000000000..9e202b9eb --- /dev/null +++ b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { GetTeamsLeaderboardUseCase } from './GetTeamsLeaderboardUseCase'; +import { Result } from '@core/shared/domain/Result'; +import type { TeamRepository } from '../../domain/repositories/TeamRepository'; +import type { TeamMembershipRepository } from '../../domain/repositories/TeamMembershipRepository'; +import type { Logger } from '@core/shared/domain/Logger'; +import type { Team } from '../../domain/entities/Team'; + +describe('GetTeamsLeaderboardUseCase', () => { + const mockTeamRepository = { + findAll: vi.fn(), + } as unknown as TeamRepository; + + const mockTeamMembershipRepository = { + getTeamMembers: vi.fn(), + } as unknown as TeamMembershipRepository; + + const mockGetDriverStats = vi.fn(); + + const mockLogger = { + error: vi.fn(), + } as unknown as Logger; + + const useCase = new GetTeamsLeaderboardUseCase( + mockTeamRepository, + mockTeamMembershipRepository, + mockGetDriverStats, + mockLogger + ); + + it('should return teams leaderboard with calculated stats', async () => { + const mockTeam1 = { id: 'team-1', name: 'Team 1' } as unknown as Team; + const mockTeam2 = { id: 'team-2', name: 'Team 2' } as unknown as Team; + vi.mocked(mockTeamRepository.findAll).mockResolvedValue([mockTeam1, mockTeam2]); + + vi.mocked(mockTeamMembershipRepository.getTeamMembers).mockImplementation(async (teamId) => { + if (teamId === 'team-1') return [{ driverId: 'driver-1' }, { driverId: 'driver-2' }] as any; + if (teamId === 'team-2') return [{ driverId: 'driver-3' }] as any; + return []; + }); + + mockGetDriverStats.mockImplementation((driverId) => { + if (driverId === 'driver-1') return { rating: 1000, wins: 1, totalRaces: 5 }; + if (driverId === 'driver-2') return { rating: 2000, wins: 2, totalRaces: 10 }; + if (driverId === 'driver-3') return { rating: 1500, wins: 0, totalRaces: 2 }; + return null; + }); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data.items).toHaveLength(2); + + const item1 = data.items.find(i => i.team.id === 'team-1'); + expect(item1?.rating).toBe(1500); // (1000 + 2000) / 2 + expect(item1?.totalWins).toBe(3); + expect(item1?.totalRaces).toBe(15); + + const item2 = data.items.find(i => i.team.id === 'team-2'); + expect(item2?.rating).toBe(1500); + expect(item2?.totalWins).toBe(0); + expect(item2?.totalRaces).toBe(2); + + expect(data.topItems).toHaveLength(2); + }); + + it('should handle teams with no members', async () => { + const mockTeam = { id: 'team-empty', name: 'Empty Team' } as unknown as Team; + vi.mocked(mockTeamRepository.findAll).mockResolvedValue([mockTeam]); + vi.mocked(mockTeamMembershipRepository.getTeamMembers).mockResolvedValue([]); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data.items[0].rating).toBeNull(); + expect(data.items[0].performanceLevel).toBe('beginner'); + }); + + it('should return error when repository fails', async () => { + vi.mocked(mockTeamRepository.findAll).mockRejectedValue(new Error('DB Error')); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isErr()).toBe(true); + expect(result.error.code).toBe('REPOSITORY_ERROR'); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/RankingUseCase.test.ts b/core/racing/application/use-cases/RankingUseCase.test.ts new file mode 100644 index 000000000..ab449c29e --- /dev/null +++ b/core/racing/application/use-cases/RankingUseCase.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { RankingUseCase, type DriverRanking } from './RankingUseCase'; +import type { StandingRepository } from '../../domain/repositories/StandingRepository'; +import type { DriverRepository } from '../../domain/repositories/DriverRepository'; +import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository'; +import type { Logger } from '@core/shared/domain/Logger'; + +describe('RankingUseCase', () => { + const mockStandingRepository = {} as StandingRepository; + const mockDriverRepository = {} as DriverRepository; + const mockDriverStatsRepository = { + getAllStats: vi.fn(), + } as unknown as DriverStatsRepository; + const mockLogger = { + debug: vi.fn(), + } as unknown as Logger; + + const useCase = new RankingUseCase( + mockStandingRepository, + mockDriverRepository, + mockDriverStatsRepository, + mockLogger + ); + + it('should return all driver rankings', async () => { + const mockStatsMap = new Map([ + ['driver-1', { rating: 1500, wins: 2, totalRaces: 10, overallRank: 1 }], + ['driver-2', { rating: 1200, wins: 0, totalRaces: 5, overallRank: 2 }], + ]); + vi.mocked(mockDriverStatsRepository.getAllStats).mockResolvedValue(mockStatsMap as any); + + const result = await useCase.getAllDriverRankings(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual({ + driverId: 'driver-1', + rating: 1500, + wins: 2, + totalRaces: 10, + overallRank: 1, + }); + expect(result).toContainEqual({ + driverId: 'driver-2', + rating: 1200, + wins: 0, + totalRaces: 5, + overallRank: 2, + }); + expect(mockLogger.debug).toHaveBeenCalledWith('Getting all driver rankings'); + }); + + it('should return empty array when no stats exist', async () => { + vi.mocked(mockDriverStatsRepository.getAllStats).mockResolvedValue(new Map()); + + const result = await useCase.getAllDriverRankings(); + + expect(result).toEqual([]); + }); +}); diff --git a/core/racing/application/utils/RaceResultGenerator.test.ts b/core/racing/application/utils/RaceResultGenerator.test.ts new file mode 100644 index 000000000..7084ff131 --- /dev/null +++ b/core/racing/application/utils/RaceResultGenerator.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest'; +import { RaceResultGenerator } from './RaceResultGenerator'; + +describe('RaceResultGenerator', () => { + it('should generate results for all drivers', () => { + const raceId = 'race-1'; + const driverIds = ['d1', 'd2', 'd3']; + const driverRatings = new Map([ + ['d1', 2000], + ['d2', 1500], + ['d3', 1000], + ]); + + const results = RaceResultGenerator.generateRaceResults(raceId, driverIds, driverRatings); + + expect(results).toHaveLength(3); + const resultDriverIds = results.map(r => r.driverId.toString()); + expect(resultDriverIds).toContain('d1'); + expect(resultDriverIds).toContain('d2'); + expect(resultDriverIds).toContain('d3'); + + results.forEach(r => { + expect(r.raceId.toString()).toBe(raceId); + expect(r.position.toNumber()).toBeGreaterThan(0); + expect(r.position.toNumber()).toBeLessThanOrEqual(3); + }); + }); + + it('should provide incident descriptions', () => { + expect(RaceResultGenerator.getIncidentDescription(0)).toBe('Clean race'); + expect(RaceResultGenerator.getIncidentDescription(1)).toBe('Track limits violation'); + expect(RaceResultGenerator.getIncidentDescription(2)).toBe('Contact with another car'); + expect(RaceResultGenerator.getIncidentDescription(3)).toBe('Off-track incident'); + expect(RaceResultGenerator.getIncidentDescription(4)).toBe('Collision requiring safety car'); + expect(RaceResultGenerator.getIncidentDescription(5)).toBe('5 incidents'); + }); + + it('should calculate incident penalty points', () => { + expect(RaceResultGenerator.getIncidentPenaltyPoints(0)).toBe(0); + expect(RaceResultGenerator.getIncidentPenaltyPoints(1)).toBe(0); + expect(RaceResultGenerator.getIncidentPenaltyPoints(2)).toBe(2); + expect(RaceResultGenerator.getIncidentPenaltyPoints(3)).toBe(4); + }); +}); diff --git a/core/racing/application/utils/RaceResultGeneratorWithIncidents.test.ts b/core/racing/application/utils/RaceResultGeneratorWithIncidents.test.ts new file mode 100644 index 000000000..d27ed8768 --- /dev/null +++ b/core/racing/application/utils/RaceResultGeneratorWithIncidents.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { RaceResultGeneratorWithIncidents } from './RaceResultGeneratorWithIncidents'; +import { RaceIncidents } from '../../domain/value-objects/RaceIncidents'; + +describe('RaceResultGeneratorWithIncidents', () => { + it('should generate results for all drivers', () => { + const raceId = 'race-1'; + const driverIds = ['d1', 'd2']; + const driverRatings = new Map([ + ['d1', 2000], + ['d2', 1500], + ]); + + const results = RaceResultGeneratorWithIncidents.generateRaceResults(raceId, driverIds, driverRatings); + + expect(results).toHaveLength(2); + results.forEach(r => { + expect(r.raceId.toString()).toBe(raceId); + expect(r.incidents).toBeInstanceOf(RaceIncidents); + }); + }); + + it('should calculate incident penalty points', () => { + const incidents = new RaceIncidents([ + { type: 'contact', lap: 1, description: 'desc', penaltyPoints: 2 }, + { type: 'unsafe_rejoin', lap: 5, description: 'desc', penaltyPoints: 3 }, + ]); + + expect(RaceResultGeneratorWithIncidents.getIncidentPenaltyPoints(incidents)).toBe(5); + }); + + it('should get incident description', () => { + const incidents = new RaceIncidents([ + { type: 'contact', lap: 1, description: 'desc', penaltyPoints: 2 }, + ]); + + const description = RaceResultGeneratorWithIncidents.getIncidentDescription(incidents); + expect(description).toContain('1 incidents'); + }); +}); diff --git a/core/racing/domain/services/ChampionshipAggregator.test.ts b/core/racing/domain/services/ChampionshipAggregator.test.ts new file mode 100644 index 000000000..cf500779e --- /dev/null +++ b/core/racing/domain/services/ChampionshipAggregator.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ChampionshipAggregator } from './ChampionshipAggregator'; +import type { DropScoreApplier } from './DropScoreApplier'; +import { Points } from '../value-objects/Points'; + +describe('ChampionshipAggregator', () => { + const mockDropScoreApplier = { + apply: vi.fn(), + } as unknown as DropScoreApplier; + + const aggregator = new ChampionshipAggregator(mockDropScoreApplier); + + it('should aggregate points and sort standings by total points', () => { + const seasonId = 'season-1'; + const championship = { + id: 'champ-1', + dropScorePolicy: { strategy: 'none' }, + } as any; + + const eventPointsByEventId = { + 'event-1': [ + { + participant: { id: 'p1', type: 'driver' }, + totalPoints: 10, + basePoints: 10, + bonusPoints: 0, + penaltyPoints: 0 + }, + { + participant: { id: 'p2', type: 'driver' }, + totalPoints: 20, + basePoints: 20, + bonusPoints: 0, + penaltyPoints: 0 + }, + ], + 'event-2': [ + { + participant: { id: 'p1', type: 'driver' }, + totalPoints: 15, + basePoints: 15, + bonusPoints: 0, + penaltyPoints: 0 + }, + ], + } as any; + + vi.mocked(mockDropScoreApplier.apply).mockImplementation((policy, events) => { + const total = events.reduce((sum, e) => sum + e.points, 0); + return { + totalPoints: total, + counted: events, + dropped: [], + }; + }); + + const standings = aggregator.aggregate({ + seasonId, + championship, + eventPointsByEventId, + }); + + expect(standings).toHaveLength(2); + + // p1 should be first (10 + 15 = 25 points) + expect(standings[0].participant.id).toBe('p1'); + expect(standings[0].totalPoints.toNumber()).toBe(25); + expect(standings[0].position.toNumber()).toBe(1); + + // p2 should be second (20 points) + expect(standings[1].participant.id).toBe('p2'); + expect(standings[1].totalPoints.toNumber()).toBe(20); + expect(standings[1].position.toNumber()).toBe(2); + }); +}); diff --git a/core/racing/domain/services/ChampionshipAggregator.ts b/core/racing/domain/services/ChampionshipAggregator.ts index 32d8df888..a5352e189 100644 --- a/core/racing/domain/services/ChampionshipAggregator.ts +++ b/core/racing/domain/services/ChampionshipAggregator.ts @@ -59,7 +59,7 @@ export class ChampionshipAggregator { totalPoints, resultsCounted, resultsDropped, - position: 0, + position: 1, }), ); } diff --git a/core/racing/domain/services/SeasonScheduleGenerator.test.ts b/core/racing/domain/services/SeasonScheduleGenerator.test.ts new file mode 100644 index 000000000..a2a0df6e1 --- /dev/null +++ b/core/racing/domain/services/SeasonScheduleGenerator.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { SeasonScheduleGenerator } from './SeasonScheduleGenerator'; +import { SeasonSchedule } from '../value-objects/SeasonSchedule'; +import { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy'; +import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay'; +import { WeekdaySet } from '../value-objects/WeekdaySet'; +import { LeagueTimezone } from '../value-objects/LeagueTimezone'; +import { MonthlyRecurrencePattern } from '../value-objects/MonthlyRecurrencePattern'; + +describe('SeasonScheduleGenerator', () => { + it('should generate weekly slots', () => { + const startDate = new Date(2024, 0, 1); // Monday, Jan 1st 2024 + const schedule = new SeasonSchedule({ + startDate, + plannedRounds: 4, + timeOfDay: new RaceTimeOfDay(20, 0), + timezone: LeagueTimezone.create('UTC'), + recurrence: RecurrenceStrategy.weekly(WeekdaySet.fromArray(['Mon'])), + }); + + const slots = SeasonScheduleGenerator.generateSlots(schedule); + + expect(slots).toHaveLength(4); + expect(slots[0].roundNumber).toBe(1); + expect(slots[0].scheduledAt.getHours()).toBe(20); + expect(slots[0].scheduledAt.getMinutes()).toBe(0); + expect(slots[0].scheduledAt.getFullYear()).toBe(2024); + expect(slots[0].scheduledAt.getMonth()).toBe(0); + expect(slots[0].scheduledAt.getDate()).toBe(1); + + expect(slots[1].roundNumber).toBe(2); + expect(slots[1].scheduledAt.getDate()).toBe(8); + expect(slots[2].roundNumber).toBe(3); + expect(slots[2].scheduledAt.getDate()).toBe(15); + expect(slots[3].roundNumber).toBe(4); + expect(slots[3].scheduledAt.getDate()).toBe(22); + }); + + it('should generate slots every 2 weeks', () => { + const startDate = new Date(2024, 0, 1); + const schedule = new SeasonSchedule({ + startDate, + plannedRounds: 2, + timeOfDay: new RaceTimeOfDay(20, 0), + timezone: LeagueTimezone.create('UTC'), + recurrence: RecurrenceStrategy.everyNWeeks(2, WeekdaySet.fromArray(['Mon'])), + }); + + const slots = SeasonScheduleGenerator.generateSlots(schedule); + + expect(slots).toHaveLength(2); + expect(slots[0].scheduledAt.getDate()).toBe(1); + expect(slots[1].scheduledAt.getDate()).toBe(15); + }); + + it('should generate monthly slots (nth weekday)', () => { + const startDate = new Date(2024, 0, 1); + const schedule = new SeasonSchedule({ + startDate, + plannedRounds: 2, + timeOfDay: new RaceTimeOfDay(20, 0), + timezone: LeagueTimezone.create('UTC'), + recurrence: RecurrenceStrategy.monthlyNthWeekday(MonthlyRecurrencePattern.create(1, 'Mon')), + }); + + const slots = SeasonScheduleGenerator.generateSlots(schedule); + + expect(slots).toHaveLength(2); + expect(slots[0].scheduledAt.getMonth()).toBe(0); + expect(slots[0].scheduledAt.getDate()).toBe(1); + expect(slots[1].scheduledAt.getMonth()).toBe(1); + expect(slots[1].scheduledAt.getDate()).toBe(5); + }); +}); diff --git a/core/racing/domain/services/SkillLevelService.test.ts b/core/racing/domain/services/SkillLevelService.test.ts new file mode 100644 index 000000000..e3bd6c297 --- /dev/null +++ b/core/racing/domain/services/SkillLevelService.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { SkillLevelService } from './SkillLevelService'; + +describe('SkillLevelService', () => { + describe('getSkillLevel', () => { + it('should return pro for rating >= 3000', () => { + expect(SkillLevelService.getSkillLevel(3000)).toBe('pro'); + expect(SkillLevelService.getSkillLevel(5000)).toBe('pro'); + }); + + it('should return advanced for rating >= 2500 and < 3000', () => { + expect(SkillLevelService.getSkillLevel(2500)).toBe('advanced'); + expect(SkillLevelService.getSkillLevel(2999)).toBe('advanced'); + }); + + it('should return intermediate for rating >= 1800 and < 2500', () => { + expect(SkillLevelService.getSkillLevel(1800)).toBe('intermediate'); + expect(SkillLevelService.getSkillLevel(2499)).toBe('intermediate'); + }); + + it('should return beginner for rating < 1800', () => { + expect(SkillLevelService.getSkillLevel(1799)).toBe('beginner'); + expect(SkillLevelService.getSkillLevel(500)).toBe('beginner'); + }); + }); + + describe('getTeamPerformanceLevel', () => { + it('should return beginner for null rating', () => { + expect(SkillLevelService.getTeamPerformanceLevel(null)).toBe('beginner'); + }); + + it('should return pro for rating >= 4500', () => { + expect(SkillLevelService.getTeamPerformanceLevel(4500)).toBe('pro'); + }); + + it('should return advanced for rating >= 3000 and < 4500', () => { + expect(SkillLevelService.getTeamPerformanceLevel(3000)).toBe('advanced'); + expect(SkillLevelService.getTeamPerformanceLevel(4499)).toBe('advanced'); + }); + + it('should return intermediate for rating >= 2000 and < 3000', () => { + expect(SkillLevelService.getTeamPerformanceLevel(2000)).toBe('intermediate'); + expect(SkillLevelService.getTeamPerformanceLevel(2999)).toBe('intermediate'); + }); + + it('should return beginner for rating < 2000', () => { + expect(SkillLevelService.getTeamPerformanceLevel(1999)).toBe('beginner'); + }); + }); +}); diff --git a/core/racing/domain/services/StrengthOfFieldCalculator.test.ts b/core/racing/domain/services/StrengthOfFieldCalculator.test.ts new file mode 100644 index 000000000..1346aeff7 --- /dev/null +++ b/core/racing/domain/services/StrengthOfFieldCalculator.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { AverageStrengthOfFieldCalculator } from './StrengthOfFieldCalculator'; + +describe('AverageStrengthOfFieldCalculator', () => { + const calculator = new AverageStrengthOfFieldCalculator(); + + it('should calculate average SOF and round it', () => { + const ratings = [ + { driverId: 'd1', rating: 1500 }, + { driverId: 'd2', rating: 2000 }, + { driverId: 'd3', rating: 1750 }, + ]; + + const sof = calculator.calculate(ratings); + + expect(sof).toBe(1750); + }); + + it('should handle rounding correctly', () => { + const ratings = [ + { driverId: 'd1', rating: 1000 }, + { driverId: 'd2', rating: 1001 }, + ]; + + const sof = calculator.calculate(ratings); + + expect(sof).toBe(1001); // (1000 + 1001) / 2 = 1000.5 -> 1001 + }); + + it('should return null for empty ratings', () => { + expect(calculator.calculate([])).toBeNull(); + }); + + it('should filter out non-positive ratings', () => { + const ratings = [ + { driverId: 'd1', rating: 1500 }, + { driverId: 'd2', rating: 0 }, + { driverId: 'd3', rating: -100 }, + ]; + + const sof = calculator.calculate(ratings); + + expect(sof).toBe(1500); + }); + + it('should return null if all ratings are non-positive', () => { + const ratings = [ + { driverId: 'd1', rating: 0 }, + { driverId: 'd2', rating: -500 }, + ]; + + expect(calculator.calculate(ratings)).toBeNull(); + }); +}); diff --git a/core/shared/domain/Entity.test.ts b/core/shared/domain/Entity.test.ts new file mode 100644 index 000000000..5034f8e70 --- /dev/null +++ b/core/shared/domain/Entity.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from 'vitest'; +import { Entity, EntityProps, EntityAlias } from './Entity'; + +// Concrete implementation for testing +class TestEntity extends Entity { + constructor(id: string) { + super(id); + } +} + +describe('Entity', () => { + describe('EntityProps interface', () => { + it('should have readonly id property', () => { + const props: EntityProps = { id: 'test-id' }; + expect(props.id).toBe('test-id'); + }); + + it('should support different id types', () => { + const stringProps: EntityProps = { id: 'test-id' }; + const numberProps: EntityProps = { id: 123 }; + const uuidProps: EntityProps = { id: '550e8400-e29b-41d4-a716-446655440000' }; + + expect(stringProps.id).toBe('test-id'); + expect(numberProps.id).toBe(123); + expect(uuidProps.id).toBe('550e8400-e29b-41d4-a716-446655440000'); + }); + }); + + describe('Entity class', () => { + it('should have id property', () => { + const entity = new TestEntity('entity-123'); + expect(entity.id).toBe('entity-123'); + }); + + it('should have equals method', () => { + const entity1 = new TestEntity('entity-123'); + const entity2 = new TestEntity('entity-123'); + const entity3 = new TestEntity('entity-456'); + + expect(entity1.equals(entity2)).toBe(true); + expect(entity1.equals(entity3)).toBe(false); + }); + + it('should return false when comparing with undefined', () => { + const entity = new TestEntity('entity-123'); + expect(entity.equals(undefined)).toBe(false); + }); + + it('should return false when comparing with null', () => { + const entity = new TestEntity('entity-123'); + expect(entity.equals(null)).toBe(false); + }); + + it('should return false when comparing with entity of different type', () => { + const entity1 = new TestEntity('entity-123'); + const entity2 = new TestEntity('entity-123'); + + // Even with same ID, if they're different entity types, equals should work + // since it only compares IDs + expect(entity1.equals(entity2)).toBe(true); + }); + + it('should support numeric IDs', () => { + class NumericEntity extends Entity { + constructor(id: number) { + super(id); + } + } + + const entity1 = new NumericEntity(123); + const entity2 = new NumericEntity(123); + const entity3 = new NumericEntity(456); + + expect(entity1.id).toBe(123); + expect(entity1.equals(entity2)).toBe(true); + expect(entity1.equals(entity3)).toBe(false); + }); + + it('should support UUID IDs', () => { + const uuid1 = '550e8400-e29b-41d4-a716-446655440000'; + const uuid2 = '550e8400-e29b-41d4-a716-446655440000'; + const uuid3 = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + + const entity1 = new TestEntity(uuid1); + const entity2 = new TestEntity(uuid2); + const entity3 = new TestEntity(uuid3); + + expect(entity1.equals(entity2)).toBe(true); + expect(entity1.equals(entity3)).toBe(false); + }); + + it('should be immutable - id cannot be changed', () => { + const entity = new TestEntity('entity-123'); + + // Try to change id (should not work in TypeScript, but testing runtime) + // @ts-expect-error - Testing immutability + entity.id = 'new-id'; + + // ID should remain unchanged + expect(entity.id).toBe('entity-123'); + }); + }); + + describe('EntityAlias type', () => { + it('should be assignable to EntityProps', () => { + const alias: EntityAlias = { id: 'test-id' }; + expect(alias.id).toBe('test-id'); + }); + + it('should work with different ID types', () => { + const stringAlias: EntityAlias = { id: 'test' }; + const numberAlias: EntityAlias = { id: 42 }; + const uuidAlias: EntityAlias = { id: '550e8400-e29b-41d4-a716-446655440000' }; + + expect(stringAlias.id).toBe('test'); + expect(numberAlias.id).toBe(42); + expect(uuidAlias.id).toBe('550e8400-e29b-41d4-a716-446655440000'); + }); + }); +}); + +describe('Entity behavior', () => { + it('should support entity identity', () => { + // Entities are identified by their ID + const entity1 = new TestEntity('same-id'); + const entity2 = new TestEntity('same-id'); + const entity3 = new TestEntity('different-id'); + + // Same ID = same identity + expect(entity1.equals(entity2)).toBe(true); + + // Different ID = different identity + expect(entity1.equals(entity3)).toBe(false); + }); + + it('should support entity reference equality', () => { + const entity = new TestEntity('entity-123'); + + // Same instance should equal itself + expect(entity.equals(entity)).toBe(true); + }); + + it('should work with complex ID types', () => { + interface ComplexId { + tenant: string; + sequence: number; + } + + class ComplexEntity extends Entity { + constructor(id: ComplexId) { + super(id); + } + + equals(other?: Entity): boolean { + if (!other) return false; + return ( + this.id.tenant === other.id.tenant && + this.id.sequence === other.id.sequence + ); + } + } + + const id1: ComplexId = { tenant: 'org-a', sequence: 1 }; + const id2: ComplexId = { tenant: 'org-a', sequence: 1 }; + const id3: ComplexId = { tenant: 'org-b', sequence: 1 }; + + const entity1 = new ComplexEntity(id1); + const entity2 = new ComplexEntity(id2); + const entity3 = new ComplexEntity(id3); + + expect(entity1.equals(entity2)).toBe(true); + expect(entity1.equals(entity3)).toBe(false); + }); +}); diff --git a/core/shared/domain/ValueObject.test.ts b/core/shared/domain/ValueObject.test.ts new file mode 100644 index 000000000..c36414d91 --- /dev/null +++ b/core/shared/domain/ValueObject.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { ValueObject, ValueObjectAlias } from './ValueObject'; + +// Concrete implementation for testing +class TestValueObject implements ValueObject<{ name: string; value: number }> { + readonly props: { name: string; value: number }; + + constructor(name: string, value: number) { + this.props = { name, value }; + } + + equals(other: ValueObject<{ name: string; value: number }>): boolean { + return ( + this.props.name === other.props.name && this.props.value === other.props.value + ); + } +} + +describe('ValueObject', () => { + describe('ValueObject interface', () => { + it('should have readonly props property', () => { + const vo = new TestValueObject('test', 42); + expect(vo.props).toEqual({ name: 'test', value: 42 }); + }); + + it('should have equals method', () => { + const vo1 = new TestValueObject('test', 42); + const vo2 = new TestValueObject('test', 42); + const vo3 = new TestValueObject('different', 42); + + expect(vo1.equals(vo2)).toBe(true); + expect(vo1.equals(vo3)).toBe(false); + }); + + it('should return false when comparing with undefined', () => { + const vo = new TestValueObject('test', 42); + // Testing that equals method handles undefined gracefully + const result = vo.equals as any; + expect(result(undefined)).toBe(false); + }); + + it('should return false when comparing with null', () => { + const vo = new TestValueObject('test', 42); + // Testing that equals method handles null gracefully + const result = vo.equals as any; + expect(result(null)).toBe(false); + }); + }); + + describe('ValueObjectAlias type', () => { + it('should be assignable to ValueObject', () => { + const vo: ValueObjectAlias<{ name: string }> = { + props: { name: 'test' }, + equals: (other) => other.props.name === 'test', + }; + + expect(vo.props.name).toBe('test'); + expect(vo.equals(vo)).toBe(true); + }); + }); +}); + +describe('ValueObject behavior', () => { + it('should support complex value objects', () => { + interface AddressProps { + street: string; + city: string; + zipCode: string; + } + + class Address implements ValueObject { + readonly props: AddressProps; + + constructor(street: string, city: string, zipCode: string) { + this.props = { street, city, zipCode }; + } + + equals(other: ValueObject): boolean { + return ( + this.props.street === other.props.street && + this.props.city === other.props.city && + this.props.zipCode === other.props.zipCode + ); + } + } + + const address1 = new Address('123 Main St', 'New York', '10001'); + const address2 = new Address('123 Main St', 'New York', '10001'); + const address3 = new Address('456 Oak Ave', 'Boston', '02101'); + + expect(address1.equals(address2)).toBe(true); + expect(address1.equals(address3)).toBe(false); + }); + + it('should support immutable value objects', () => { + class ImmutableValueObject implements ValueObject<{ readonly data: string[] }> { + readonly props: { readonly data: string[] }; + + constructor(data: string[]) { + this.props = { data: [...data] }; // Create a copy to ensure immutability + } + + equals(other: ValueObject<{ readonly data: string[] }>): boolean { + return ( + this.props.data.length === other.props.data.length && + this.props.data.every((item, index) => item === other.props.data[index]) + ); + } + } + + const vo1 = new ImmutableValueObject(['a', 'b', 'c']); + const vo2 = new ImmutableValueObject(['a', 'b', 'c']); + const vo3 = new ImmutableValueObject(['a', 'b', 'd']); + + expect(vo1.equals(vo2)).toBe(true); + expect(vo1.equals(vo3)).toBe(false); + }); +});