diff --git a/core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts b/core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts index 2353464cc..0184e7e4f 100644 --- a/core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts +++ b/core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; import { HandleAuthCallbackUseCase } from './HandleAuthCallbackUseCase'; -import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; -import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; -import type { AuthCallbackCommand, AuthenticatedUser } from '../ports/IdentityProviderPort'; -import type { AuthSession } from '../ports/IdentitySessionPort'; +import type { + AuthCallbackCommand, + AuthenticatedUser, + IdentityProviderPort, +} from '../ports/IdentityProviderPort'; +import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; describe('HandleAuthCallbackUseCase', () => { diff --git a/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts b/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts new file mode 100644 index 000000000..e1418140d --- /dev/null +++ b/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { + RequestAvatarGenerationUseCase, + type RequestAvatarGenerationInput, + type RequestAvatarGenerationErrorCode, + type RequestAvatarGenerationResult, +} from './RequestAvatarGenerationUseCase'; +import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; +import type { FaceValidationPort } from '../ports/FaceValidationPort'; +import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort'; + +vi.mock('uuid', () => ({ + v4: () => 'request-1', +})); + +interface TestOutputPort extends UseCaseOutputPort { + present: Mock; + result?: RequestAvatarGenerationResult; +} + +describe('RequestAvatarGenerationUseCase', () => { + let avatarRepo: { save: Mock }; + let faceValidation: { validateFacePhoto: Mock }; + let avatarGeneration: { generateAvatars: Mock }; + let output: TestOutputPort; + let logger: Logger; + let useCase: RequestAvatarGenerationUseCase; + + beforeEach(() => { + avatarRepo = { + save: vi.fn(), + }; + + faceValidation = { + validateFacePhoto: vi.fn(), + }; + + avatarGeneration = { + generateAvatars: vi.fn(), + }; + + output = { + present: vi.fn((result: RequestAvatarGenerationResult) => { + output.result = result; + }), + } as unknown as TestOutputPort; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new RequestAvatarGenerationUseCase( + avatarRepo as unknown as IAvatarGenerationRepository, + faceValidation as unknown as FaceValidationPort, + avatarGeneration as unknown as AvatarGenerationPort, + output, + logger, + ); + }); + + it('completes generation and presents avatar URLs', async () => { + faceValidation.validateFacePhoto.mockResolvedValue({ + isValid: true, + hasFace: true, + faceCount: 1, + }); + + avatarGeneration.generateAvatars.mockResolvedValue({ + success: true, + avatars: [{ url: 'https://example.com/a.png' }, { url: 'https://example.com/b.png' }], + }); + + const input: RequestAvatarGenerationInput = { + userId: 'user-1', + facePhotoData: 'data:image/png;base64,abc', + suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'], + style: 'cartoon', + }; + + const result = await useCase.execute(input); + + expect(result).toBeInstanceOf(Result); + expect(result.isOk()).toBe(true); + + expect(faceValidation.validateFacePhoto).toHaveBeenCalledWith(input.facePhotoData); + expect(avatarGeneration.generateAvatars).toHaveBeenCalled(); + expect(avatarRepo.save).toHaveBeenCalledTimes(4); + + expect(output.present).toHaveBeenCalledWith({ + requestId: 'request-1', + status: 'completed', + avatarUrls: ['https://example.com/a.png', 'https://example.com/b.png'], + }); + }); + + it('returns FACE_VALIDATION_FAILED when face validation fails', async () => { + faceValidation.validateFacePhoto.mockResolvedValue({ + isValid: false, + hasFace: true, + faceCount: 1, + errorMessage: 'Bad image', + }); + + const input: RequestAvatarGenerationInput = { + userId: 'user-1', + facePhotoData: 'data:image/png;base64,abc', + suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'], + }; + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('FACE_VALIDATION_FAILED'); + expect(err.details?.message).toBe('Bad image'); + + expect(avatarGeneration.generateAvatars).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + expect(avatarRepo.save).toHaveBeenCalledTimes(3); + }); + + it('returns GENERATION_FAILED when avatar generation fails', async () => { + faceValidation.validateFacePhoto.mockResolvedValue({ + isValid: true, + hasFace: true, + faceCount: 1, + }); + + avatarGeneration.generateAvatars.mockResolvedValue({ + success: false, + errorMessage: 'Generation service down', + avatars: [], + }); + + const input: RequestAvatarGenerationInput = { + userId: 'user-1', + facePhotoData: 'data:image/png;base64,abc', + suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'], + }; + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('GENERATION_FAILED'); + expect(err.details?.message).toBe('Generation service down'); + + expect(output.present).not.toHaveBeenCalled(); + expect(avatarRepo.save).toHaveBeenCalledTimes(4); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + avatarRepo.save.mockRejectedValueOnce(new Error('DB error')); + + const input: RequestAvatarGenerationInput = { + userId: 'user-1', + facePhotoData: 'data:image/png;base64,abc', + suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'], + }; + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('DB error'); + + expect(output.present).not.toHaveBeenCalled(); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/media/application/use-cases/SelectAvatarUseCase.test.ts b/core/media/application/use-cases/SelectAvatarUseCase.test.ts new file mode 100644 index 000000000..e2a43de19 --- /dev/null +++ b/core/media/application/use-cases/SelectAvatarUseCase.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { + SelectAvatarUseCase, + type SelectAvatarErrorCode, + type SelectAvatarInput, + type SelectAvatarResult, +} from './SelectAvatarUseCase'; +import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; +import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; + +interface TestOutputPort extends UseCaseOutputPort { + present: Mock; + result?: SelectAvatarResult; +} + +describe('SelectAvatarUseCase', () => { + let avatarRepo: { findById: Mock; save: Mock }; + let logger: Logger; + let output: TestOutputPort; + let useCase: SelectAvatarUseCase; + + beforeEach(() => { + avatarRepo = { + findById: vi.fn(), + save: vi.fn(), + }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + output = { + present: vi.fn((result: SelectAvatarResult) => { + output.result = result; + }), + } as unknown as TestOutputPort; + + useCase = new SelectAvatarUseCase( + avatarRepo as unknown as IAvatarGenerationRepository, + output, + logger, + ); + }); + + it('returns REQUEST_NOT_FOUND when request does not exist', async () => { + avatarRepo.findById.mockResolvedValue(null); + + const input: SelectAvatarInput = { requestId: 'missing', selectedIndex: 0 }; + const result = await useCase.execute(input); + + expect(avatarRepo.findById).toHaveBeenCalledWith('missing'); + expect(result).toBeInstanceOf(Result); + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('REQUEST_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns REQUEST_NOT_COMPLETED when request is not completed', async () => { + const request = AvatarGenerationRequest.create({ + id: 'req-1', + userId: 'user-1', + facePhotoUrl: 'data:image/png;base64,abc', + suitColor: 'red' as unknown as Parameters[0]['suitColor'], + }); + + avatarRepo.findById.mockResolvedValue(request); + + const input: SelectAvatarInput = { requestId: 'req-1', selectedIndex: 0 }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('REQUEST_NOT_COMPLETED'); + + expect(avatarRepo.save).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('selects avatar and presents selected URL when request is completed', async () => { + const request = AvatarGenerationRequest.create({ + id: 'req-1', + userId: 'user-1', + facePhotoUrl: 'data:image/png;base64,abc', + suitColor: 'red' as unknown as Parameters[0]['suitColor'], + }); + request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']); + + avatarRepo.findById.mockResolvedValue(request); + + const input: SelectAvatarInput = { requestId: 'req-1', selectedIndex: 1 }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(request.selectedAvatarIndex).toBe(1); + expect(request.selectedAvatarUrl).toBe('https://example.com/b.png'); + + expect(avatarRepo.save).toHaveBeenCalledWith(request); + expect(output.present).toHaveBeenCalledWith({ + requestId: 'req-1', + selectedAvatarUrl: 'https://example.com/b.png', + }); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + avatarRepo.findById.mockRejectedValue(new Error('DB error')); + + const input: SelectAvatarInput = { requestId: 'req-1', selectedIndex: 0 }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('DB error'); + + expect(output.present).not.toHaveBeenCalled(); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/media/application/use-cases/UpdateAvatarUseCase.test.ts b/core/media/application/use-cases/UpdateAvatarUseCase.test.ts new file mode 100644 index 000000000..57f1e1392 --- /dev/null +++ b/core/media/application/use-cases/UpdateAvatarUseCase.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { + UpdateAvatarUseCase, + type UpdateAvatarErrorCode, + type UpdateAvatarInput, + type UpdateAvatarResult, +} from './UpdateAvatarUseCase'; +import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository'; +import { Avatar } from '../../domain/entities/Avatar'; + +vi.mock('uuid', () => ({ + v4: () => 'avatar-1', +})); + +interface TestOutputPort extends UseCaseOutputPort { + present: Mock; + result?: UpdateAvatarResult; +} + +describe('UpdateAvatarUseCase', () => { + let avatarRepo: { findActiveByDriverId: Mock; save: Mock }; + let logger: Logger; + let output: TestOutputPort; + let useCase: UpdateAvatarUseCase; + + beforeEach(() => { + avatarRepo = { + findActiveByDriverId: vi.fn(), + save: vi.fn(), + }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + output = { + present: vi.fn((result: UpdateAvatarResult) => { + output.result = result; + }), + } as unknown as TestOutputPort; + + useCase = new UpdateAvatarUseCase( + avatarRepo as unknown as IAvatarRepository, + output, + logger, + ); + }); + + it('creates new avatar when no current active avatar exists', async () => { + avatarRepo.findActiveByDriverId.mockResolvedValue(null); + + const input: UpdateAvatarInput = { + driverId: 'driver-1', + mediaUrl: 'https://example.com/avatar.png', + }; + + const result = await useCase.execute(input); + + expect(result).toBeInstanceOf(Result); + expect(result.isOk()).toBe(true); + + expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1'); + expect(avatarRepo.save).toHaveBeenCalledTimes(1); + + const saved = (avatarRepo.save as unknown as Mock).mock.calls[0]![0] as Avatar; + expect(saved.driverId).toBe('driver-1'); + expect(saved.mediaUrl.value).toBe('https://example.com/avatar.png'); + expect(saved.isActive).toBe(true); + + expect(output.present).toHaveBeenCalledWith({ avatarId: 'avatar-1', driverId: 'driver-1' }); + }); + + it('deactivates current avatar before saving new avatar', async () => { + const currentAvatar = Avatar.reconstitute({ + id: 'old-avatar', + driverId: 'driver-1', + mediaUrl: 'https://example.com/old.png', + selectedAt: new Date('2020-01-01T00:00:00.000Z'), + isActive: true, + }); + + avatarRepo.findActiveByDriverId.mockResolvedValue(currentAvatar); + + const input: UpdateAvatarInput = { + driverId: 'driver-1', + mediaUrl: 'https://example.com/new.png', + }; + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(avatarRepo.save).toHaveBeenCalledTimes(2); + + const firstSaved = (avatarRepo.save as unknown as Mock).mock.calls[0]![0] as Avatar; + expect(firstSaved.id).toBe('old-avatar'); + expect(firstSaved.isActive).toBe(false); + + const secondSaved = (avatarRepo.save as unknown as Mock).mock.calls[1]![0] as Avatar; + expect(secondSaved.id).toBe('avatar-1'); + expect(secondSaved.isActive).toBe(true); + + expect(output.present).toHaveBeenCalledWith({ avatarId: 'avatar-1', driverId: 'driver-1' }); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + avatarRepo.findActiveByDriverId.mockRejectedValue(new Error('DB error')); + + const input: UpdateAvatarInput = { + driverId: 'driver-1', + mediaUrl: 'https://example.com/avatar.png', + }; + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('DB error'); + + expect(output.present).not.toHaveBeenCalled(); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/media/application/use-cases/UploadMediaUseCase.test.ts b/core/media/application/use-cases/UploadMediaUseCase.test.ts new file mode 100644 index 000000000..66eb39d3b --- /dev/null +++ b/core/media/application/use-cases/UploadMediaUseCase.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { Readable } from 'node:stream'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { + UploadMediaUseCase, + type MulterFile, + type UploadMediaErrorCode, + type UploadMediaInput, + type UploadMediaResult, +} from './UploadMediaUseCase'; +import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; +import type { MediaStoragePort } from '../ports/MediaStoragePort'; +import { Media } from '../../domain/entities/Media'; + +vi.mock('uuid', () => ({ + v4: () => 'media-1', +})); + +interface TestOutputPort extends UseCaseOutputPort { + present: Mock; + result?: UploadMediaResult; +} + +describe('UploadMediaUseCase', () => { + let mediaRepo: { save: Mock }; + let mediaStorage: { uploadMedia: Mock }; + let logger: Logger; + let output: TestOutputPort; + let useCase: UploadMediaUseCase; + + const baseFile: MulterFile = { + fieldname: 'file', + originalname: 'avatar.png', + encoding: '7bit', + mimetype: 'image/png', + size: 123, + buffer: Buffer.from('abc'), + stream: Readable.from([]), + destination: '/tmp', + filename: 'avatar.png', + path: '/tmp/avatar.png', + }; + + beforeEach(() => { + mediaRepo = { + save: vi.fn(), + }; + + mediaStorage = { + uploadMedia: vi.fn(), + }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + output = { + present: vi.fn((result: UploadMediaResult) => { + output.result = result; + }), + } as unknown as TestOutputPort; + + useCase = new UploadMediaUseCase( + mediaRepo as unknown as IMediaRepository, + mediaStorage as unknown as MediaStoragePort, + output, + logger, + ); + }); + + it('returns UPLOAD_FAILED when storage returns unsuccessful result', async () => { + mediaStorage.uploadMedia.mockResolvedValue({ + success: false, + errorMessage: 'Upload error', + }); + + const input: UploadMediaInput = { file: baseFile, uploadedBy: 'user-1' }; + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('UPLOAD_FAILED'); + expect(err.details?.message).toBe('Upload error'); + + expect(mediaRepo.save).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('creates media and presents mediaId/url on success (includes metadata)', async () => { + mediaStorage.uploadMedia.mockResolvedValue({ + success: true, + url: 'https://example.com/media.png', + filename: 'stored.png', + }); + + const input: UploadMediaInput = { + file: baseFile, + uploadedBy: 'user-1', + metadata: { foo: 'bar' }, + }; + + const result = await useCase.execute(input); + + expect(result).toBeInstanceOf(Result); + expect(result.isOk()).toBe(true); + + expect(mediaStorage.uploadMedia).toHaveBeenCalledWith(baseFile.buffer, { + filename: baseFile.originalname, + mimeType: baseFile.mimetype, + metadata: { foo: 'bar' }, + }); + + expect(mediaRepo.save).toHaveBeenCalledTimes(1); + const saved = (mediaRepo.save as unknown as Mock).mock.calls[0]![0] as Media; + expect(saved.id).toBe('media-1'); + expect(saved.url.value).toBe('https://example.com/media.png'); + expect(saved.filename).toBe('stored.png'); + expect(saved.originalName).toBe('avatar.png'); + expect(saved.mimeType).toBe('image/png'); + expect(saved.size).toBe(123); + expect(saved.type).toBe('image'); + expect(saved.uploadedBy).toBe('user-1'); + expect(saved.metadata).toEqual({ foo: 'bar' }); + + expect(output.present).toHaveBeenCalledWith({ + mediaId: 'media-1', + url: 'https://example.com/media.png', + }); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + mediaStorage.uploadMedia.mockResolvedValue({ + success: true, + url: 'https://example.com/media.png', + }); + mediaRepo.save.mockRejectedValue(new Error('DB error')); + + const input: UploadMediaInput = { file: baseFile, uploadedBy: 'user-1' }; + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('DB error'); + + expect(output.present).not.toHaveBeenCalled(); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/notifications/application/use-cases/SendNotificationUseCase.test.ts b/core/notifications/application/use-cases/SendNotificationUseCase.test.ts new file mode 100644 index 000000000..6491be876 --- /dev/null +++ b/core/notifications/application/use-cases/SendNotificationUseCase.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +import { + SendNotificationUseCase, + type SendNotificationCommand, + type SendNotificationErrorCode, + type SendNotificationResult, +} from './SendNotificationUseCase'; + +import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; +import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; +import type { NotificationGatewayRegistry } from '../ports/NotificationGateway'; +import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; + +vi.mock('uuid', () => ({ + v4: () => 'notif-1', +})); + +interface TestOutputPort extends UseCaseOutputPort { + present: Mock; + result?: SendNotificationResult; +} + +describe('SendNotificationUseCase', () => { + let notificationRepository: { create: Mock }; + let preferenceRepository: { getOrCreateDefault: Mock }; + let gatewayRegistry: { send: Mock }; + let logger: Logger; + let output: TestOutputPort; + let useCase: SendNotificationUseCase; + + beforeEach(() => { + notificationRepository = { + create: vi.fn(), + }; + + preferenceRepository = { + getOrCreateDefault: vi.fn(), + }; + + gatewayRegistry = { + send: vi.fn(), + }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + output = { + present: vi.fn((result: SendNotificationResult) => { + output.result = result; + }), + } as unknown as TestOutputPort; + + useCase = new SendNotificationUseCase( + notificationRepository as unknown as INotificationRepository, + preferenceRepository as unknown as INotificationPreferenceRepository, + gatewayRegistry as unknown as NotificationGatewayRegistry, + output, + logger, + ); + }); + + it('creates but does not deliver when type is disabled', async () => { + const preferences = { + isTypeEnabled: vi.fn().mockReturnValue(false), + getEnabledChannelsForType: vi.fn().mockReturnValue(['email'] as NotificationChannel[]), + isInQuietHours: vi.fn().mockReturnValue(false), + }; + preferenceRepository.getOrCreateDefault.mockResolvedValue(preferences); + + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'system_announcement' as NotificationType, + title: 'Hello', + body: 'World', + }; + + const result = await useCase.execute(command); + + expect(result).toBeInstanceOf(Result); + expect(result.isOk()).toBe(true); + + expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1'); + expect(preferences.isTypeEnabled).toHaveBeenCalledWith('system_announcement'); + expect(notificationRepository.create).toHaveBeenCalledTimes(1); + expect(gatewayRegistry.send).not.toHaveBeenCalled(); + + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.result?.deliveryResults).toEqual([]); + expect(output.result?.notification.channel).toBe('in_app'); + expect(output.result?.notification.status).toBe('dismissed'); + }); + + it('ensures in_app is used and sends external channels when enabled', async () => { + const preferences = { + isTypeEnabled: vi.fn().mockReturnValue(true), + getEnabledChannelsForType: vi.fn().mockReturnValue(['email'] as NotificationChannel[]), + isInQuietHours: vi.fn().mockReturnValue(false), + }; + preferenceRepository.getOrCreateDefault.mockResolvedValue(preferences); + + gatewayRegistry.send.mockResolvedValue({ + success: true, + channel: 'email' as NotificationChannel, + attemptedAt: new Date(), + externalId: 'email-1', + }); + + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'system_announcement' as NotificationType, + title: 'Hello', + body: 'World', + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + + // in_app notification must be created, email should be sent via gateway + expect(notificationRepository.create).toHaveBeenCalledTimes(1); + expect(gatewayRegistry.send).toHaveBeenCalledTimes(1); + + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.result?.notification.channel).toBe('in_app'); + expect(output.result?.deliveryResults.length).toBe(2); + + const channels = output.result!.deliveryResults.map(r => r.channel).sort(); + expect(channels).toEqual(['email', 'in_app']); + }); + + it('filters external channels during quiet hours', async () => { + const preferences = { + isTypeEnabled: vi.fn().mockReturnValue(true), + getEnabledChannelsForType: vi.fn().mockReturnValue(['email', 'discord'] as NotificationChannel[]), + isInQuietHours: vi.fn().mockReturnValue(true), + }; + preferenceRepository.getOrCreateDefault.mockResolvedValue(preferences); + + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'system_announcement' as NotificationType, + title: 'Hello', + body: 'World', + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + + expect(notificationRepository.create).toHaveBeenCalledTimes(1); + expect(gatewayRegistry.send).not.toHaveBeenCalled(); + + expect(output.result?.deliveryResults.length).toBe(1); + expect(output.result?.deliveryResults[0]?.channel).toBe('in_app'); + }); + + it('returns REPOSITORY_ERROR when preference repository throws', async () => { + preferenceRepository.getOrCreateDefault.mockRejectedValue(new Error('DB error')); + + const command: SendNotificationCommand = { + recipientId: 'driver-1', + type: 'system_announcement' as NotificationType, + title: 'Hello', + body: 'World', + }; + + const result: Result> = + await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/AwardPrizeUseCase.test.ts b/core/payments/application/use-cases/AwardPrizeUseCase.test.ts new file mode 100644 index 000000000..221bfc0e3 --- /dev/null +++ b/core/payments/application/use-cases/AwardPrizeUseCase.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { AwardPrizeUseCase, type AwardPrizeInput } from './AwardPrizeUseCase'; +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import { PrizeType, type Prize } from '../../domain/entities/Prize'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('AwardPrizeUseCase', () => { + let prizeRepository: { findById: Mock; update: Mock }; + let output: { present: Mock }; + let useCase: AwardPrizeUseCase; + + beforeEach(() => { + prizeRepository = { + findById: vi.fn(), + update: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new AwardPrizeUseCase( + prizeRepository as unknown as IPrizeRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('returns PRIZE_NOT_FOUND when prize does not exist', async () => { + prizeRepository.findById.mockResolvedValue(null); + + const input: AwardPrizeInput = { prizeId: 'prize-1', driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('PRIZE_NOT_FOUND'); + expect(prizeRepository.update).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns PRIZE_ALREADY_AWARDED when prize is already awarded', async () => { + const prize: Prize = { + id: 'prize-1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + awarded: true, + awardedTo: 'driver-x', + awardedAt: new Date(), + createdAt: new Date(), + }; + prizeRepository.findById.mockResolvedValue(prize); + + const input: AwardPrizeInput = { prizeId: 'prize-1', driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('PRIZE_ALREADY_AWARDED'); + expect(prizeRepository.update).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('awards prize and presents updated prize', async () => { + const prize: Prize = { + id: 'prize-1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + }; + + prizeRepository.findById.mockResolvedValue(prize); + prizeRepository.update.mockImplementation(async (p: Prize) => p); + + const input: AwardPrizeInput = { prizeId: 'prize-1', driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(prizeRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'prize-1', + awarded: true, + awardedTo: 'driver-1', + awardedAt: expect.any(Date), + }), + ); + + expect(output.present).toHaveBeenCalledWith({ + prize: expect.objectContaining({ + id: 'prize-1', + awarded: true, + awardedTo: 'driver-1', + awardedAt: expect.any(Date), + }), + }); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/CreatePrizeUseCase.test.ts b/core/payments/application/use-cases/CreatePrizeUseCase.test.ts new file mode 100644 index 000000000..9b7e54b0d --- /dev/null +++ b/core/payments/application/use-cases/CreatePrizeUseCase.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { CreatePrizeUseCase, type CreatePrizeInput } from './CreatePrizeUseCase'; +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import { PrizeType, type Prize } from '../../domain/entities/Prize'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('CreatePrizeUseCase', () => { + let prizeRepository: { findByPosition: Mock; create: Mock }; + let output: { present: Mock }; + let useCase: CreatePrizeUseCase; + + beforeEach(() => { + prizeRepository = { + findByPosition: vi.fn(), + create: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new CreatePrizeUseCase( + prizeRepository as unknown as IPrizeRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('returns PRIZE_ALREADY_EXISTS when prize already exists for position', async () => { + const existingPrize: Prize = { + id: 'prize-existing', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date(), + }; + + prizeRepository.findByPosition.mockResolvedValue(existingPrize); + + const input: CreatePrizeInput = { + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('PRIZE_ALREADY_EXISTS'); + expect(prizeRepository.create).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('creates prize and presents created prize', async () => { + prizeRepository.findByPosition.mockResolvedValue(null); + prizeRepository.create.mockImplementation(async (p: Prize) => p); + + const input: CreatePrizeInput = { + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + description: 'Top prize', + }; + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(prizeRepository.findByPosition).toHaveBeenCalledWith('league-1', 'season-1', 1); + + expect(prizeRepository.create).toHaveBeenCalledWith({ + id: expect.stringContaining('prize-'), + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + awarded: false, + createdAt: expect.any(Date), + description: 'Top prize', + }); + + expect(output.present).toHaveBeenCalledWith({ + prize: expect.objectContaining({ + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + awarded: false, + }), + }); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/DeletePrizeUseCase.test.ts b/core/payments/application/use-cases/DeletePrizeUseCase.test.ts new file mode 100644 index 000000000..94b579e12 --- /dev/null +++ b/core/payments/application/use-cases/DeletePrizeUseCase.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { DeletePrizeUseCase, type DeletePrizeInput } from './DeletePrizeUseCase'; +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import { PrizeType, type Prize } from '../../domain/entities/Prize'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('DeletePrizeUseCase', () => { + let prizeRepository: { findById: Mock; delete: Mock }; + let output: { present: Mock }; + let useCase: DeletePrizeUseCase; + + beforeEach(() => { + prizeRepository = { + findById: vi.fn(), + delete: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new DeletePrizeUseCase( + prizeRepository as unknown as IPrizeRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('returns PRIZE_NOT_FOUND when prize does not exist', async () => { + prizeRepository.findById.mockResolvedValue(null); + + const input: DeletePrizeInput = { prizeId: 'prize-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('PRIZE_NOT_FOUND'); + expect(prizeRepository.delete).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns CANNOT_DELETE_AWARDED_PRIZE when prize is awarded', async () => { + const prize: Prize = { + id: 'prize-1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + awarded: true, + awardedTo: 'driver-1', + awardedAt: new Date(), + createdAt: new Date(), + }; + prizeRepository.findById.mockResolvedValue(prize); + + const input: DeletePrizeInput = { prizeId: 'prize-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('CANNOT_DELETE_AWARDED_PRIZE'); + expect(prizeRepository.delete).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('deletes prize and presents success', async () => { + const prize: Prize = { + id: 'prize-1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'Winner', + amount: 100, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date(), + }; + prizeRepository.findById.mockResolvedValue(prize); + prizeRepository.delete.mockResolvedValue(undefined); + + const input: DeletePrizeInput = { prizeId: 'prize-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(prizeRepository.delete).toHaveBeenCalledWith('prize-1'); + expect(output.present).toHaveBeenCalledWith({ success: true }); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/GetPrizesUseCase.test.ts b/core/payments/application/use-cases/GetPrizesUseCase.test.ts new file mode 100644 index 000000000..9db4224ec --- /dev/null +++ b/core/payments/application/use-cases/GetPrizesUseCase.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetPrizesUseCase, type GetPrizesInput } from './GetPrizesUseCase'; +import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository'; +import { PrizeType, type Prize } from '../../domain/entities/Prize'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('GetPrizesUseCase', () => { + let prizeRepository: { + findByLeagueId: Mock; + findByLeagueIdAndSeasonId: Mock; + }; + let output: { present: Mock }; + let useCase: GetPrizesUseCase; + + beforeEach(() => { + prizeRepository = { + findByLeagueId: vi.fn(), + findByLeagueIdAndSeasonId: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new GetPrizesUseCase( + prizeRepository as unknown as IPrizeRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('retrieves and sorts prizes by leagueId when seasonId is not provided', async () => { + const prizes: Prize[] = [ + { + id: 'p2', + leagueId: 'league-1', + seasonId: 'season-1', + position: 2, + name: 'Second', + amount: 50, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date(), + }, + { + id: 'p1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'First', + amount: 100, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date(), + }, + ]; + prizeRepository.findByLeagueId.mockResolvedValue(prizes); + + const input: GetPrizesInput = { leagueId: 'league-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(prizeRepository.findByLeagueId).toHaveBeenCalledWith('league-1'); + expect(prizeRepository.findByLeagueIdAndSeasonId).not.toHaveBeenCalled(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as unknown as Mock).mock.calls[0]![0]!.prizes as Prize[]; + expect(presented.map(p => p.position)).toEqual([1, 2]); + expect(presented.map(p => p.id)).toEqual(['p1', 'p2']); + }); + + it('retrieves and sorts prizes by leagueId and seasonId when provided', async () => { + const prizes: Prize[] = [ + { + id: 'p3', + leagueId: 'league-1', + seasonId: 'season-1', + position: 3, + name: 'Third', + amount: 25, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date(), + }, + { + id: 'p1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'First', + amount: 100, + type: PrizeType.CASH, + awarded: false, + createdAt: new Date(), + }, + ]; + prizeRepository.findByLeagueIdAndSeasonId.mockResolvedValue(prizes); + + const input: GetPrizesInput = { leagueId: 'league-1', seasonId: 'season-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(prizeRepository.findByLeagueIdAndSeasonId).toHaveBeenCalledWith('league-1', 'season-1'); + expect(prizeRepository.findByLeagueId).not.toHaveBeenCalled(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as unknown as Mock).mock.calls[0]![0]!.prizes as Prize[]; + expect(presented.map(p => p.position)).toEqual([1, 3]); + expect(presented.map(p => p.id)).toEqual(['p1', 'p3']); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/GetSponsorBillingUseCase.test.ts b/core/payments/application/use-cases/GetSponsorBillingUseCase.test.ts new file mode 100644 index 000000000..5c2fc4d50 --- /dev/null +++ b/core/payments/application/use-cases/GetSponsorBillingUseCase.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetSponsorBillingUseCase, type GetSponsorBillingInput } from './GetSponsorBillingUseCase'; +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import type { Payment } from '../../domain/entities/Payment'; +import { PaymentStatus, PaymentType, PayerType } from '../../domain/entities/Payment'; +import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; +import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; +import { Money } from '@core/racing/domain/value-objects/Money'; + +describe('GetSponsorBillingUseCase', () => { + let paymentRepository: { findByFilters: Mock }; + let seasonSponsorshipRepository: { findBySponsorId: Mock }; + let useCase: GetSponsorBillingUseCase; + + beforeEach(() => { + paymentRepository = { + findByFilters: vi.fn(), + }; + + seasonSponsorshipRepository = { + findBySponsorId: vi.fn(), + }; + + useCase = new GetSponsorBillingUseCase( + paymentRepository as unknown as IPaymentRepository, + seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository, + ); + }); + + it('derives invoices and stats from payments and sponsorships', async () => { + const sponsorId = 'sponsor-1'; + + const payments: Payment[] = [ + { + id: 'pay-1', + type: PaymentType.SPONSORSHIP, + amount: 100, + platformFee: 10, + netAmount: 90, + payerId: sponsorId, + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + seasonId: 'season-1', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + completedAt: new Date('2024-01-01T00:00:00.000Z'), + }, + { + id: 'pay-2', + type: PaymentType.SPONSORSHIP, + amount: 50, + platformFee: 5, + netAmount: 45, + payerId: sponsorId, + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + seasonId: 'season-1', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-02-01T00:00:00.000Z'), + }, + ]; + + paymentRepository.findByFilters.mockResolvedValue(payments); + + const sponsorships = [ + SeasonSponsorship.create({ + id: 'ss-1', + seasonId: 'season-1', + leagueId: 'league-1', + sponsorId, + tier: 'main', + pricing: Money.create(100, 'USD'), + status: 'active', + }), + SeasonSponsorship.create({ + id: 'ss-2', + seasonId: 'season-2', + leagueId: 'league-1', + sponsorId, + tier: 'secondary', + pricing: Money.create(50, 'USD'), + status: 'pending', + }), + ]; + + seasonSponsorshipRepository.findBySponsorId.mockResolvedValue(sponsorships); + + const input: GetSponsorBillingInput = { sponsorId }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + const value = result.unwrap(); + + expect(paymentRepository.findByFilters).toHaveBeenCalledWith({ + payerId: sponsorId, + type: PaymentType.SPONSORSHIP, + }); + + expect(seasonSponsorshipRepository.findBySponsorId).toHaveBeenCalledWith(sponsorId); + + expect(value.paymentMethods).toEqual([]); + expect(value.invoices).toHaveLength(2); + + // totals: each invoice adds 19% VAT + // pay-1 total: 100 + 19 = 119 (paid) + // pay-2 total: 50 + 9.5 = 59.5 (pending) + expect(value.stats.totalSpent).toBeCloseTo(119, 5); + expect(value.stats.pendingAmount).toBeCloseTo(59.5, 5); + + expect(value.stats.activeSponsorships).toBe(1); + expect(value.stats.nextPaymentDate).not.toBeNull(); + expect(value.stats.nextPaymentAmount).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/GetWalletUseCase.test.ts b/core/payments/application/use-cases/GetWalletUseCase.test.ts new file mode 100644 index 000000000..3ad6589e9 --- /dev/null +++ b/core/payments/application/use-cases/GetWalletUseCase.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { GetWalletUseCase, type GetWalletInput } from './GetWalletUseCase'; +import type { ITransactionRepository, IWalletRepository } from '../../domain/repositories/IWalletRepository'; +import type { Transaction, Wallet } from '../../domain/entities/Wallet'; +import { TransactionType } from '../../domain/entities/Wallet'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('GetWalletUseCase', () => { + let walletRepository: { + findByLeagueId: Mock; + create: Mock; + }; + + let transactionRepository: { + findByWalletId: Mock; + }; + + let output: { + present: Mock; + }; + + let useCase: GetWalletUseCase; + + beforeEach(() => { + walletRepository = { + findByLeagueId: vi.fn(), + create: vi.fn(), + }; + + transactionRepository = { + findByWalletId: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new GetWalletUseCase( + walletRepository as unknown as IWalletRepository, + transactionRepository as unknown as ITransactionRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('returns INVALID_INPUT when leagueId is missing', async () => { + const input = { leagueId: '' } as unknown as GetWalletInput; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('INVALID_INPUT'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('presents existing wallet and transactions sorted desc by createdAt', async () => { + const input: GetWalletInput = { leagueId: 'league-1' }; + + const wallet: Wallet = { + id: 'wallet-1', + leagueId: 'league-1', + balance: 50, + totalRevenue: 100, + totalPlatformFees: 5, + totalWithdrawn: 10, + currency: 'USD', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + const older: Transaction = { + id: 'txn-older', + walletId: 'wallet-1', + type: TransactionType.DEPOSIT, + amount: 25, + description: 'Older', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + const newer: Transaction = { + id: 'txn-newer', + walletId: 'wallet-1', + type: TransactionType.WITHDRAWAL, + amount: 10, + description: 'Newer', + createdAt: new Date('2025-01-02T00:00:00.000Z'), + }; + + walletRepository.findByLeagueId.mockResolvedValue(wallet); + transactionRepository.findByWalletId.mockResolvedValue([older, newer]); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(transactionRepository.findByWalletId).toHaveBeenCalledWith('wallet-1'); + expect(output.present).toHaveBeenCalledWith({ + wallet, + transactions: [newer, older], + }); + }); + + it('creates wallet when missing, then presents wallet and transactions', async () => { + const input: GetWalletInput = { leagueId: 'league-1' }; + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + vi.spyOn(Math, 'random').mockReturnValue(0.123456789); + + try { + walletRepository.findByLeagueId.mockResolvedValue(null); + + walletRepository.create.mockImplementation(async (w: Wallet) => w); + + transactionRepository.findByWalletId.mockResolvedValue([]); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(walletRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^wallet-1735689600000-[a-z0-9]{9}$/), + leagueId: 'league-1', + balance: 0, + totalRevenue: 0, + totalPlatformFees: 0, + totalWithdrawn: 0, + currency: 'USD', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }), + ); + + const createdWalletArg = walletRepository.create.mock.calls[0]?.[0] as Wallet; + expect(transactionRepository.findByWalletId).toHaveBeenCalledWith(createdWalletArg.id); + + expect(output.present).toHaveBeenCalledWith({ + wallet: createdWalletArg, + transactions: [], + }); + } finally { + vi.useRealTimers(); + } + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/UpdateMemberPaymentUseCase.test.ts b/core/payments/application/use-cases/UpdateMemberPaymentUseCase.test.ts new file mode 100644 index 000000000..c9c7977bc --- /dev/null +++ b/core/payments/application/use-cases/UpdateMemberPaymentUseCase.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { UpdateMemberPaymentUseCase, type UpdateMemberPaymentInput } from './UpdateMemberPaymentUseCase'; +import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository'; +import { MemberPaymentStatus, type MemberPayment } from '../../domain/entities/MemberPayment'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('UpdateMemberPaymentUseCase', () => { + let membershipFeeRepository: { + findById: Mock; + }; + + let memberPaymentRepository: { + findByFeeIdAndDriverId: Mock; + create: Mock; + update: Mock; + }; + + let output: { + present: Mock; + }; + + let useCase: UpdateMemberPaymentUseCase; + + beforeEach(() => { + membershipFeeRepository = { + findById: vi.fn(), + }; + + memberPaymentRepository = { + findByFeeIdAndDriverId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new UpdateMemberPaymentUseCase( + membershipFeeRepository as unknown as IMembershipFeeRepository, + memberPaymentRepository as unknown as IMemberPaymentRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('returns MEMBERSHIP_FEE_NOT_FOUND when fee does not exist', async () => { + const input: UpdateMemberPaymentInput = { + feeId: 'fee-1', + driverId: 'driver-1', + }; + + membershipFeeRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('MEMBERSHIP_FEE_NOT_FOUND'); + expect(memberPaymentRepository.findByFeeIdAndDriverId).not.toHaveBeenCalled(); + expect(memberPaymentRepository.create).not.toHaveBeenCalled(); + expect(memberPaymentRepository.update).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('creates a new payment when missing, applies status and paidAt when PAID', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + vi.spyOn(Math, 'random').mockReturnValue(0.123456789); + + try { + const input: UpdateMemberPaymentInput = { + feeId: 'fee-1', + driverId: 'driver-1', + status: MemberPaymentStatus.PAID, + }; + + const fee = { + id: 'fee-1', + leagueId: 'league-1', + type: 'season', + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }; + + membershipFeeRepository.findById.mockResolvedValue(fee); + memberPaymentRepository.findByFeeIdAndDriverId.mockResolvedValue(null); + memberPaymentRepository.create.mockImplementation(async (p: MemberPayment) => ({ ...p })); + memberPaymentRepository.update.mockImplementation(async (p: MemberPayment) => p); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(memberPaymentRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^mp-1735689600000-[a-z0-9]{9}$/), + feeId: 'fee-1', + driverId: 'driver-1', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2025-01-01T00:00:00.000Z'), + }), + ); + + expect(memberPaymentRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: MemberPaymentStatus.PAID, + paidAt: new Date('2025-01-01T00:00:00.000Z'), + }), + ); + + const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment; + expect(output.present).toHaveBeenCalledWith({ payment: updated }); + } finally { + vi.useRealTimers(); + } + }); + + it('updates existing payment status and parses paidAt string', async () => { + const input: UpdateMemberPaymentInput = { + feeId: 'fee-1', + driverId: 'driver-1', + status: MemberPaymentStatus.PAID, + paidAt: '2025-02-01T00:00:00.000Z', + }; + + const fee = { + id: 'fee-1', + leagueId: 'league-1', + type: 'season', + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }; + + const existingPayment: MemberPayment = { + id: 'mp-1', + feeId: 'fee-1', + driverId: 'driver-1', + amount: 100, + platformFee: 10, + netAmount: 90, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2025-01-01T00:00:00.000Z'), + }; + + membershipFeeRepository.findById.mockResolvedValue(fee); + memberPaymentRepository.findByFeeIdAndDriverId.mockResolvedValue(existingPayment); + memberPaymentRepository.update.mockImplementation(async (p: MemberPayment) => p); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(memberPaymentRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'mp-1', + status: MemberPaymentStatus.PAID, + paidAt: new Date('2025-02-01T00:00:00.000Z'), + }), + ); + + const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment; + expect(output.present).toHaveBeenCalledWith({ payment: updated }); + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/UpdatePaymentStatusUseCase.test.ts b/core/payments/application/use-cases/UpdatePaymentStatusUseCase.test.ts new file mode 100644 index 000000000..ec4a7fb25 --- /dev/null +++ b/core/payments/application/use-cases/UpdatePaymentStatusUseCase.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { UpdatePaymentStatusUseCase, type UpdatePaymentStatusInput } from './UpdatePaymentStatusUseCase'; +import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository'; +import { PaymentStatus, PaymentType, PayerType, type Payment } from '../../domain/entities/Payment'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('UpdatePaymentStatusUseCase', () => { + let paymentRepository: { + findById: Mock; + update: Mock; + }; + + let output: { + present: Mock; + }; + + let useCase: UpdatePaymentStatusUseCase; + + beforeEach(() => { + paymentRepository = { + findById: vi.fn(), + update: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new UpdatePaymentStatusUseCase( + paymentRepository as unknown as IPaymentRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('returns PAYMENT_NOT_FOUND when payment does not exist', async () => { + const input: UpdatePaymentStatusInput = { + paymentId: 'payment-1', + status: PaymentStatus.COMPLETED, + }; + + paymentRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('PAYMENT_NOT_FOUND'); + expect(paymentRepository.update).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('sets completedAt when status becomes COMPLETED', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + try { + const input: UpdatePaymentStatusInput = { + paymentId: 'payment-1', + status: PaymentStatus.COMPLETED, + }; + + const existingPayment: Payment = { + id: 'payment-1', + type: PaymentType.SPONSORSHIP, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'payer-1', + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + status: PaymentStatus.PENDING, + createdAt: new Date('2024-12-31T00:00:00.000Z'), + }; + + paymentRepository.findById.mockResolvedValue(existingPayment); + paymentRepository.update.mockImplementation(async (p: Payment) => ({ ...p })); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(paymentRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'payment-1', + status: PaymentStatus.COMPLETED, + completedAt: new Date('2025-01-01T00:00:00.000Z'), + }), + ); + + const savedPayment = paymentRepository.update.mock.results[0]?.value; + await expect(savedPayment).resolves.toEqual( + expect.objectContaining({ + id: 'payment-1', + status: PaymentStatus.COMPLETED, + completedAt: new Date('2025-01-01T00:00:00.000Z'), + }), + ); + + const presentedPayment = (output.present.mock.calls[0]?.[0] as { payment: Payment }).payment; + expect(presentedPayment.status).toBe(PaymentStatus.COMPLETED); + expect(presentedPayment.completedAt).toEqual(new Date('2025-01-01T00:00:00.000Z')); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves completedAt when status is not COMPLETED', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + try { + const input: UpdatePaymentStatusInput = { + paymentId: 'payment-1', + status: PaymentStatus.FAILED, + }; + + const existingCompletedAt = new Date('2025-01-01T00:00:00.000Z'); + + const existingPayment: Payment = { + id: 'payment-1', + type: PaymentType.SPONSORSHIP, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'payer-1', + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + status: PaymentStatus.COMPLETED, + createdAt: new Date('2024-12-31T00:00:00.000Z'), + completedAt: existingCompletedAt, + }; + + paymentRepository.findById.mockResolvedValue(existingPayment); + paymentRepository.update.mockImplementation(async (p: Payment) => ({ ...p })); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(paymentRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'payment-1', + status: PaymentStatus.FAILED, + completedAt: existingCompletedAt, + }), + ); + + const presentedPayment = (output.present.mock.calls[0]?.[0] as { payment: Payment }).payment; + expect(presentedPayment.status).toBe(PaymentStatus.FAILED); + expect(presentedPayment.completedAt).toEqual(existingCompletedAt); + } finally { + vi.useRealTimers(); + } + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/UpsertMembershipFeeUseCase.test.ts b/core/payments/application/use-cases/UpsertMembershipFeeUseCase.test.ts new file mode 100644 index 000000000..f5e1a90f4 --- /dev/null +++ b/core/payments/application/use-cases/UpsertMembershipFeeUseCase.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { UpsertMembershipFeeUseCase, type UpsertMembershipFeeInput } from './UpsertMembershipFeeUseCase'; +import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository'; +import { MembershipFeeType, type MembershipFee } from '../../domain/entities/MembershipFee'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +describe('UpsertMembershipFeeUseCase', () => { + let membershipFeeRepository: { + findByLeagueId: Mock; + create: Mock; + update: Mock; + }; + + let output: { + present: Mock; + }; + + let useCase: UpsertMembershipFeeUseCase; + + beforeEach(() => { + membershipFeeRepository = { + findByLeagueId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }; + + output = { + present: vi.fn(), + }; + + useCase = new UpsertMembershipFeeUseCase( + membershipFeeRepository as unknown as IMembershipFeeRepository, + output as unknown as UseCaseOutputPort, + ); + }); + + it('creates a fee when none exists and presents it', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + vi.spyOn(Math, 'random').mockReturnValue(0.123456789); + + try { + const input: UpsertMembershipFeeInput = { + leagueId: 'league-1', + type: MembershipFeeType.SEASON, + amount: 100, + }; + + membershipFeeRepository.findByLeagueId.mockResolvedValue(null); + membershipFeeRepository.create.mockImplementation(async (fee: MembershipFee) => ({ ...fee })); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(membershipFeeRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^fee-1735689600000-[a-z0-9]{9}$/), + leagueId: 'league-1', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + }), + ); + + const createdFee = (output.present.mock.calls[0]?.[0] as { fee: MembershipFee }).fee; + expect(createdFee.enabled).toBe(true); + expect(createdFee.amount).toBe(100); + } finally { + vi.useRealTimers(); + } + }); + + it('updates an existing fee and sets enabled=false when amount is 0', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-02T00:00:00.000Z')); + + try { + const input: UpsertMembershipFeeInput = { + leagueId: 'league-1', + seasonId: 'season-2', + type: MembershipFeeType.MONTHLY, + amount: 0, + }; + + const existingFee: MembershipFee = { + id: 'fee-1', + leagueId: 'league-1', + seasonId: 'season-1', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + }; + + membershipFeeRepository.findByLeagueId.mockResolvedValue(existingFee); + membershipFeeRepository.update.mockImplementation(async (fee: MembershipFee) => ({ ...fee })); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(membershipFeeRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'fee-1', + leagueId: 'league-1', + seasonId: 'season-2', + type: MembershipFeeType.MONTHLY, + amount: 0, + enabled: false, + updatedAt: new Date('2025-01-02T00:00:00.000Z'), + }), + ); + + const updatedFee = (output.present.mock.calls[0]?.[0] as { fee: MembershipFee }).fee; + expect(updatedFee.enabled).toBe(false); + expect(updatedFee.amount).toBe(0); + expect(updatedFee.seasonId).toBe('season-2'); + expect(updatedFee.type).toBe(MembershipFeeType.MONTHLY); + } finally { + vi.useRealTimers(); + } + }); +}); \ No newline at end of file diff --git a/core/payments/application/use-cases/index.test.ts b/core/payments/application/use-cases/index.test.ts new file mode 100644 index 000000000..05563cf80 --- /dev/null +++ b/core/payments/application/use-cases/index.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import * as useCases from './index'; + +describe('payments use-cases barrel exports', () => { + it('re-exports all expected use cases', () => { + const exported = useCases as unknown as Record; + + const expectedExports = [ + 'AwardPrizeUseCase', + 'CreatePaymentUseCase', + 'CreatePrizeUseCase', + 'DeletePrizeUseCase', + 'GetMembershipFeesUseCase', + 'GetPaymentsUseCase', + 'GetPrizesUseCase', + 'GetSponsorBillingUseCase', + 'GetWalletUseCase', + 'ProcessWalletTransactionUseCase', + 'UpdateMemberPaymentUseCase', + 'UpdatePaymentStatusUseCase', + 'UpsertMembershipFeeUseCase', + ]; + + for (const name of expectedExports) { + expect(exported[name], `missing export: ${name}`).toBeDefined(); + } + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.test.ts b/core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.test.ts new file mode 100644 index 000000000..e6073cdab --- /dev/null +++ b/core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + GetSeasonSponsorshipsUseCase, + type GetSeasonSponsorshipsInput, + type GetSeasonSponsorshipsResult, + type GetSeasonSponsorshipsErrorCode, +} from './GetSeasonSponsorshipsUseCase'; +import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import { Season } from '../../domain/entities/season/Season'; +import { League } from '../../domain/entities/League'; +import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship'; +import { Money } from '../../domain/value-objects/Money'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +describe('GetSeasonSponsorshipsUseCase', () => { + let seasonSponsorshipRepository: { + findBySeasonId: Mock; + }; + let seasonRepository: { + findById: Mock; + }; + let leagueRepository: { + findById: Mock; + }; + let leagueMembershipRepository: { + getLeagueMembers: Mock; + }; + let raceRepository: { + findByLeagueId: Mock; + }; + + let output: UseCaseOutputPort & { present: Mock }; + + let useCase: GetSeasonSponsorshipsUseCase; + + beforeEach(() => { + seasonSponsorshipRepository = { + findBySeasonId: vi.fn(), + }; + seasonRepository = { + findById: vi.fn(), + }; + leagueRepository = { + findById: vi.fn(), + }; + leagueMembershipRepository = { + getLeagueMembers: vi.fn(), + }; + raceRepository = { + findByLeagueId: vi.fn(), + }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new GetSeasonSponsorshipsUseCase( + seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository, + seasonRepository as unknown as ISeasonRepository, + leagueRepository as unknown as ILeagueRepository, + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + raceRepository as unknown as IRaceRepository, + output, + ); + }); + + it('returns SEASON_NOT_FOUND when season does not exist', async () => { + const input: GetSeasonSponsorshipsInput = { seasonId: 'season-1' }; + + seasonRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetSeasonSponsorshipsErrorCode, + { message: string } + >; + expect(err.code).toBe('SEASON_NOT_FOUND'); + expect(err.details.message).toBe('Season not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns LEAGUE_NOT_FOUND when league for season does not exist', async () => { + const input: GetSeasonSponsorshipsInput = { seasonId: 'season-1' }; + + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'game-1', + name: 'Season 1', + status: 'active', + }); + + seasonRepository.findById.mockResolvedValue(season); + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetSeasonSponsorshipsErrorCode, + { message: string } + >; + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found for season'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('presents sponsorship details with computed metrics', async () => { + const input: GetSeasonSponsorshipsInput = { seasonId: 'season-1' }; + + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'game-1', + name: 'Season 1', + status: 'active', + startDate: new Date('2025-01-01T00:00:00.000Z'), + endDate: new Date('2025-02-01T00:00:00.000Z'), + }); + + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'Test', + ownerId: 'owner-1', + }); + + const sponsorship = SeasonSponsorship.create({ + id: 'sponsorship-1', + sponsorId: 'sponsor-1', + seasonId: 'season-1', + tier: 'main', + pricing: Money.create(1000, 'USD'), + activatedAt: new Date('2025-01-02T00:00:00.000Z'), + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }); + + seasonRepository.findById.mockResolvedValue(season); + leagueRepository.findById.mockResolvedValue(league); + seasonSponsorshipRepository.findBySeasonId.mockResolvedValue([sponsorship]); + + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([ + { driverId: 'driver-1' }, + { driverId: 'driver-2' }, + { driverId: 'driver-3' }, + ]); + + raceRepository.findByLeagueId.mockResolvedValue([ + { id: 'race-1', status: 'completed' }, + { id: 'race-2', status: 'completed' }, + { id: 'race-3', status: 'scheduled' }, + ]); + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0]?.[0] as GetSeasonSponsorshipsResult; + + expect(presented.seasonId).toBe('season-1'); + expect(presented.sponsorships).toHaveLength(1); + + const detail = presented.sponsorships[0]!; + expect(detail.id).toBe('sponsorship-1'); + expect(detail.leagueId).toBe('league-1'); + expect(detail.leagueName).toBe('Test League'); + expect(detail.seasonId).toBe('season-1'); + expect(detail.seasonName).toBe('Season 1'); + expect(detail.seasonStartDate).toEqual(new Date('2025-01-01T00:00:00.000Z')); + expect(detail.seasonEndDate).toEqual(new Date('2025-02-01T00:00:00.000Z')); + expect(detail.activatedAt).toEqual(new Date('2025-01-02T00:00:00.000Z')); + + expect(detail.metrics.drivers).toBe(3); + expect(detail.metrics.races).toBe(3); + expect(detail.metrics.completedRaces).toBe(2); + expect(detail.metrics.impressions).toBe(2 * 3 * 100); + + expect(detail.pricing).toEqual({ amount: 1000, currency: 'USD' }); + expect(detail.platformFee).toEqual({ amount: 100, currency: 'USD' }); + expect(detail.netAmount).toEqual({ amount: 900, currency: 'USD' }); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + const input: GetSeasonSponsorshipsInput = { seasonId: 'season-1' }; + + seasonRepository.findById.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetSeasonSponsorshipsErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSponsorUseCase.test.ts b/core/racing/application/use-cases/GetSponsorUseCase.test.ts new file mode 100644 index 000000000..2db453027 --- /dev/null +++ b/core/racing/application/use-cases/GetSponsorUseCase.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + GetSponsorUseCase, + type GetSponsorInput, + type GetSponsorResult, + type GetSponsorErrorCode, +} from './GetSponsorUseCase'; +import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; +import { Sponsor } from '../../domain/entities/sponsor/Sponsor'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +describe('GetSponsorUseCase', () => { + let sponsorRepository: { + findById: Mock; + }; + + let output: UseCaseOutputPort & { present: Mock }; + + let useCase: GetSponsorUseCase; + + beforeEach(() => { + sponsorRepository = { + findById: vi.fn(), + }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new GetSponsorUseCase( + sponsorRepository as unknown as ISponsorRepository, + output, + ); + }); + + it('presents sponsor when found', async () => { + const sponsor = Sponsor.create({ + id: 'sponsor-1', + name: 'Test Sponsor', + contactEmail: 'test@example.com', + }); + + sponsorRepository.findById.mockResolvedValue(sponsor); + + const input: GetSponsorInput = { sponsorId: 'sponsor-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledWith({ sponsor }); + }); + + it('returns SPONSOR_NOT_FOUND when sponsor does not exist', async () => { + sponsorRepository.findById.mockResolvedValue(null); + + const input: GetSponsorInput = { sponsorId: 'sponsor-404' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetSponsorErrorCode, + { message: string } + >; + + expect(err.code).toBe('SPONSOR_NOT_FOUND'); + expect(err.details.message).toBe('Sponsor not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + sponsorRepository.findById.mockRejectedValue(new Error('DB error')); + + const input: GetSponsorInput = { sponsorId: 'sponsor-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetSponsorErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/ReopenRaceUseCase.test.ts b/core/racing/application/use-cases/ReopenRaceUseCase.test.ts new file mode 100644 index 000000000..02c522f6e --- /dev/null +++ b/core/racing/application/use-cases/ReopenRaceUseCase.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + ReopenRaceUseCase, + type ReopenRaceInput, + type ReopenRaceResult, + type ReopenRaceErrorCode, +} from './ReopenRaceUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { Logger } from '@core/shared/application'; +import { Race } from '../../domain/entities/Race'; +import { SessionType } from '../../domain/value-objects/SessionType'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +describe('ReopenRaceUseCase', () => { + let raceRepository: { + findById: Mock; + update: Mock; + }; + let logger: { + debug: Mock; + warn: Mock; + info: Mock; + error: Mock; + }; + let output: UseCaseOutputPort & { present: Mock }; + + let useCase: ReopenRaceUseCase; + + beforeEach(() => { + raceRepository = { + findById: vi.fn(), + update: vi.fn(), + }; + + logger = { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new ReopenRaceUseCase( + raceRepository as unknown as IRaceRepository, + logger as unknown as Logger, + output, + ); + }); + + it('returns RACE_NOT_FOUND when race does not exist', async () => { + const input: ReopenRaceInput = { raceId: 'race-404', reopenedById: 'admin-1' }; + + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + ReopenRaceErrorCode, + { message: string } + >; + expect(err.code).toBe('RACE_NOT_FOUND'); + expect(err.details.message).toContain('race-404'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('reopens a completed race, persists, and presents the result', async () => { + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt: new Date('2025-01-01T00:00:00.000Z'), + track: 'Track 1', + car: 'Car 1', + sessionType: SessionType.main(), + status: 'completed', + }); + + raceRepository.findById.mockResolvedValue(race); + raceRepository.update.mockResolvedValue(race.reopen()); + + const input: ReopenRaceInput = { raceId: 'race-1', reopenedById: 'admin-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + + expect(raceRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'race-1', + status: 'scheduled', + }), + ); + + expect(output.present).toHaveBeenCalledWith({ + race: expect.objectContaining({ + id: 'race-1', + status: 'scheduled', + }), + }); + + expect(logger.info).toHaveBeenCalled(); + }); + + it('returns INVALID_RACE_STATE when race is already scheduled', async () => { + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt: new Date('2025-01-01T00:00:00.000Z'), + track: 'Track 1', + car: 'Car 1', + sessionType: SessionType.main(), + status: 'scheduled', + }); + + raceRepository.findById.mockResolvedValue(race); + + const input: ReopenRaceInput = { raceId: 'race-1', reopenedById: 'admin-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + ReopenRaceErrorCode, + { message: string } + >; + expect(err.code).toBe('INVALID_RACE_STATE'); + expect(err.details.message).toContain('already scheduled'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns INVALID_RACE_STATE when race is running', async () => { + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt: new Date('2025-01-01T00:00:00.000Z'), + track: 'Track 1', + car: 'Car 1', + sessionType: SessionType.main(), + status: 'running', + }); + + raceRepository.findById.mockResolvedValue(race); + + const input: ReopenRaceInput = { raceId: 'race-1', reopenedById: 'admin-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + ReopenRaceErrorCode, + { message: string } + >; + expect(err.code).toBe('INVALID_RACE_STATE'); + expect(err.details.message).toContain('running race'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws unexpected error', async () => { + raceRepository.findById.mockRejectedValue(new Error('DB error')); + + const input: ReopenRaceInput = { raceId: 'race-1', reopenedById: 'admin-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + ReopenRaceErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.test.ts b/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.test.ts new file mode 100644 index 000000000..56f860b64 --- /dev/null +++ b/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + WithdrawFromLeagueWalletUseCase, + type WithdrawFromLeagueWalletErrorCode, + type WithdrawFromLeagueWalletInput, + type WithdrawFromLeagueWalletResult, +} from './WithdrawFromLeagueWalletUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; +import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { League } from '../../domain/entities/League'; +import { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet'; +import { Money } from '../../domain/value-objects/Money'; + +describe('WithdrawFromLeagueWalletUseCase', () => { + let leagueRepository: { findById: Mock }; + let walletRepository: { findByLeagueId: Mock; update: Mock }; + let transactionRepository: { create: Mock }; + let logger: Logger & { error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; + let useCase: WithdrawFromLeagueWalletUseCase; + + beforeEach(() => { + leagueRepository = { findById: vi.fn() }; + walletRepository = { findByLeagueId: vi.fn(), update: vi.fn() }; + transactionRepository = { create: vi.fn() }; + + logger = { error: vi.fn() } as unknown as Logger & { error: Mock }; + + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { + present: Mock; + }; + + useCase = new WithdrawFromLeagueWalletUseCase( + leagueRepository as unknown as ILeagueRepository, + walletRepository as unknown as ILeagueWalletRepository, + transactionRepository as unknown as ITransactionRepository, + logger, + output, + ); + }); + + it('returns LEAGUE_NOT_FOUND when league is missing', async () => { + leagueRepository.findById.mockResolvedValue(null); + + const input: WithdrawFromLeagueWalletInput = { + leagueId: 'league-1', + requestedById: 'owner-1', + amount: 100, + currency: 'USD', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + WithdrawFromLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League with id league-1 not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns WALLET_NOT_FOUND when wallet is missing', async () => { + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'desc', + ownerId: 'owner-1', + }); + + leagueRepository.findById.mockResolvedValue(league); + walletRepository.findByLeagueId.mockResolvedValue(null); + + const input: WithdrawFromLeagueWalletInput = { + leagueId: 'league-1', + requestedById: 'owner-1', + amount: 100, + currency: 'USD', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + WithdrawFromLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('WALLET_NOT_FOUND'); + expect(err.details.message).toBe('Wallet for league league-1 not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns UNAUTHORIZED_WITHDRAWAL when requester is not owner', async () => { + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'desc', + ownerId: 'owner-1', + }); + + const wallet = LeagueWallet.create({ + id: 'wallet-1', + leagueId: 'league-1', + balance: Money.create(1000, 'USD'), + }); + + leagueRepository.findById.mockResolvedValue(league); + walletRepository.findByLeagueId.mockResolvedValue(wallet); + + const input: WithdrawFromLeagueWalletInput = { + leagueId: 'league-1', + requestedById: 'not-owner', + amount: 100, + currency: 'USD', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + WithdrawFromLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('UNAUTHORIZED_WITHDRAWAL'); + expect(err.details.message).toBe('Only the league owner can withdraw from the league wallet'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns INSUFFICIENT_FUNDS when wallet cannot withdraw amount', async () => { + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'desc', + ownerId: 'owner-1', + }); + + const wallet = LeagueWallet.create({ + id: 'wallet-1', + leagueId: 'league-1', + balance: Money.create(100, 'USD'), + }); + + leagueRepository.findById.mockResolvedValue(league); + walletRepository.findByLeagueId.mockResolvedValue(wallet); + + const input: WithdrawFromLeagueWalletInput = { + leagueId: 'league-1', + requestedById: 'owner-1', + amount: 200, + currency: 'USD', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + WithdrawFromLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('INSUFFICIENT_FUNDS'); + expect(err.details.message).toBe('Insufficient balance for withdrawal'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('creates withdrawal transaction and updates wallet on success', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'desc', + ownerId: 'owner-1', + }); + + const startingBalance = Money.create(1000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet-1', + leagueId: 'league-1', + balance: startingBalance, + }); + + leagueRepository.findById.mockResolvedValue(league); + walletRepository.findByLeagueId.mockResolvedValue(wallet); + transactionRepository.create.mockResolvedValue(undefined); + walletRepository.update.mockResolvedValue(undefined); + + const input: WithdrawFromLeagueWalletInput = { + leagueId: 'league-1', + requestedById: 'owner-1', + amount: 250, + currency: 'USD', + reason: 'Payout', + }; + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(transactionRepository.create).toHaveBeenCalledTimes(1); + const createdTx = (transactionRepository.create as Mock).mock.calls[0]![0] as { + id: { toString(): string }; + type: string; + amount: { amount: number; currency: string }; + description: string | undefined; + metadata: Record | undefined; + walletId: { toString(): string }; + }; + + const expectedTransactionId = `txn-${new Date('2025-01-01T00:00:00.000Z').getTime()}`; + + expect(createdTx.id.toString()).toBe(expectedTransactionId); + expect(createdTx.type).toBe('withdrawal'); + expect(createdTx.amount.amount).toBe(250); + expect(createdTx.amount.currency).toBe('USD'); + expect(createdTx.description).toBe('Payout'); + expect(createdTx.metadata).toEqual({ reason: 'Payout', requestedById: 'owner-1' }); + expect(createdTx.walletId.toString()).toBe(wallet.id.toString()); + + expect(walletRepository.update).toHaveBeenCalledTimes(1); + const updatedWallet = (walletRepository.update as Mock).mock.calls[0]![0] as LeagueWallet; + + expect(updatedWallet.balance.amount).toBe(750); + expect(updatedWallet.balance.currency).toBe('USD'); + expect(updatedWallet.getTransactionIds()).toContain(expectedTransactionId); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0]![0] as WithdrawFromLeagueWalletResult; + + expect(presented.leagueId).toBe('league-1'); + expect(presented.amount.amount).toBe(250); + expect(presented.amount.currency).toBe('USD'); + expect(presented.transactionId).toBe(expectedTransactionId); + expect(presented.walletBalanceAfter.amount).toBe(750); + expect(presented.walletBalanceAfter.currency).toBe('USD'); + + const createOrder = (transactionRepository.create as Mock).mock.invocationCallOrder[0]!; + const updateOrder = (walletRepository.update as Mock).mock.invocationCallOrder[0]!; + expect(createOrder).toBeLessThan(updateOrder); + + vi.useRealTimers(); + }); + + it('returns REPOSITORY_ERROR and logs when repository throws', async () => { + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'desc', + ownerId: 'owner-1', + }); + + const wallet = LeagueWallet.create({ + id: 'wallet-1', + leagueId: 'league-1', + balance: Money.create(1000, 'USD'), + }); + + leagueRepository.findById.mockResolvedValue(league); + walletRepository.findByLeagueId.mockResolvedValue(wallet); + + transactionRepository.create.mockRejectedValue(new Error('DB down')); + + const input: WithdrawFromLeagueWalletInput = { + leagueId: 'league-1', + requestedById: 'owner-1', + amount: 100, + currency: 'USD', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + WithdrawFromLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB down'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/core/social/application/use-cases/GetCurrentUserSocialUseCase.test.ts b/core/social/application/use-cases/GetCurrentUserSocialUseCase.test.ts new file mode 100644 index 000000000..ac7284045 --- /dev/null +++ b/core/social/application/use-cases/GetCurrentUserSocialUseCase.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + GetCurrentUserSocialUseCase, + type GetCurrentUserSocialApplicationError, + type GetCurrentUserSocialInput, + type GetCurrentUserSocialResult, +} from './GetCurrentUserSocialUseCase'; +import type { ISocialGraphRepository } from '../../domain/repositories/ISocialGraphRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Driver } from '@core/racing/domain/entities/Driver'; + +describe('GetCurrentUserSocialUseCase', () => { + let socialGraphRepository: ISocialGraphRepository & { getFriends: Mock }; + let logger: Logger & { debug: Mock; info: Mock; warn: Mock; error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; + let useCase: GetCurrentUserSocialUseCase; + + beforeEach(() => { + socialGraphRepository = { + getFriends: vi.fn(), + getFriendIds: vi.fn(), + getSuggestedFriends: vi.fn(), + } as unknown as ISocialGraphRepository & { getFriends: Mock }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { debug: Mock; info: Mock; warn: Mock; error: Mock }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new GetCurrentUserSocialUseCase(socialGraphRepository, logger, output); + }); + + it('presents current user social with mapped friends', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const friends = [ + Driver.create({ + id: 'friend-1', + iracingId: '123', + name: 'Friend One', + country: 'US', + }), + ]; + + socialGraphRepository.getFriends.mockResolvedValue(friends); + + const input: GetCurrentUserSocialInput = { driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0]![0] as GetCurrentUserSocialResult; + + expect(presented.currentUser).toEqual({ + driverId: 'driver-1', + displayName: '', + avatarUrl: '', + countryCode: '', + }); + + expect(presented.friends).toHaveLength(1); + expect(presented.friends[0]).toEqual({ + driverId: 'friend-1', + displayName: 'Friend One', + avatarUrl: '', + countryCode: '', + isOnline: false, + lastSeen: new Date('2025-01-01T00:00:00.000Z'), + }); + + expect(logger.warn).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('warns and presents empty friends list when no friends exist', async () => { + socialGraphRepository.getFriends.mockResolvedValue([]); + + const input: GetCurrentUserSocialInput = { driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = (output.present as Mock).mock.calls[0]![0] as GetCurrentUserSocialResult; + expect(presented.friends).toEqual([]); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect((logger.warn as Mock).mock.calls[0]![0]).toBe( + 'GetCurrentUserSocialUseCase.execute: No friends found for driverId: driver-1', + ); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + socialGraphRepository.getFriends.mockRejectedValue(new Error('DB error')); + + const input: GetCurrentUserSocialInput = { driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as GetCurrentUserSocialApplicationError; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/core/social/application/use-cases/GetUserFeedUseCase.test.ts b/core/social/application/use-cases/GetUserFeedUseCase.test.ts new file mode 100644 index 000000000..0dbcd1062 --- /dev/null +++ b/core/social/application/use-cases/GetUserFeedUseCase.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + GetUserFeedUseCase, + type GetUserFeedApplicationError, + type GetUserFeedInput, + type GetUserFeedResult, +} from './GetUserFeedUseCase'; +import type { IFeedRepository } from '../../domain/repositories/IFeedRepository'; +import type { FeedItem } from '../../domain/types/FeedItem'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; + +describe('GetUserFeedUseCase', () => { + let feedRepository: IFeedRepository & { getFeedForDriver: Mock }; + let logger: Logger & { debug: Mock; info: Mock; warn: Mock; error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; + let useCase: GetUserFeedUseCase; + + beforeEach(() => { + feedRepository = { + getFeedForDriver: vi.fn(), + getGlobalFeed: vi.fn(), + } as unknown as IFeedRepository & { getFeedForDriver: Mock }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { debug: Mock; info: Mock; warn: Mock; error: Mock }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new GetUserFeedUseCase(feedRepository, logger, output); + }); + + it('presents feed items when repository returns items', async () => { + const items: FeedItem[] = [ + { + id: 'item-1', + timestamp: new Date('2025-01-01T00:00:00.000Z'), + type: 'friend-joined-league', + actorFriendId: 'friend-1', + leagueId: 'league-1', + headline: 'Friend joined a league', + body: 'Friend joined League 1', + ctaLabel: 'View league', + ctaHref: '/leagues/league-1', + }, + { + id: 'item-2', + timestamp: new Date('2025-01-02T00:00:00.000Z'), + type: 'friend-finished-race', + actorDriverId: 'driver-2', + raceId: 'race-1', + position: 3, + headline: 'Race finished', + }, + ]; + + feedRepository.getFeedForDriver.mockResolvedValue(items); + + const input: GetUserFeedInput = { driverId: 'driver-1', limit: 10 }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(feedRepository.getFeedForDriver).toHaveBeenCalledTimes(1); + expect(feedRepository.getFeedForDriver).toHaveBeenCalledWith('driver-1', 10); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0]![0] as GetUserFeedResult; + expect(presented.items).toEqual(items); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('warns and presents empty list when no items exist', async () => { + feedRepository.getFeedForDriver.mockResolvedValue([]); + + const input: GetUserFeedInput = { driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = (output.present as Mock).mock.calls[0]![0] as GetUserFeedResult; + expect(presented.items).toEqual([]); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect((logger.warn as Mock).mock.calls[0]![0]).toBe( + 'No feed items found for driverId: driver-1', + ); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + feedRepository.getFeedForDriver.mockRejectedValue(new Error('DB error')); + + const input: GetUserFeedInput = { driverId: 'driver-1', limit: 5 }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as GetUserFeedApplicationError; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file