add tests to core

This commit is contained in:
2025-12-23 18:30:18 +01:00
parent 4318b380d9
commit 14d390b831
22 changed files with 2912 additions and 4 deletions

View File

@@ -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', () => {

View File

@@ -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<RequestAvatarGenerationResult> {
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<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
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<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
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<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
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();
});
});

View File

@@ -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<SelectAvatarResult> {
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<SelectAvatarErrorCode, { message: string }>;
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<typeof AvatarGenerationRequest.create>[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<SelectAvatarErrorCode, { message: string }>;
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<typeof AvatarGenerationRequest.create>[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<SelectAvatarErrorCode, { message: string }>;
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();
});
});

View File

@@ -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<UpdateAvatarResult> {
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<void, ApplicationErrorCode<UpdateAvatarErrorCode, { message: string }>> =
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();
});
});

View File

@@ -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<UploadMediaResult> {
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<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
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<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
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();
});
});

View File

@@ -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<SendNotificationResult> {
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<void, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>> =
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();
});
});

View File

@@ -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<unknown>,
);
});
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),
}),
});
});
});

View File

@@ -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<unknown>,
);
});
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,
}),
});
});
});

View File

@@ -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<unknown>,
);
});
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 });
});
});

View File

@@ -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<unknown>,
);
});
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']);
});
});

View File

@@ -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();
});
});

View File

@@ -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<unknown>,
);
});
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();
}
});
});

View File

@@ -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<unknown>,
);
});
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 });
});
});

View File

@@ -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<unknown>,
);
});
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();
}
});
});

View File

@@ -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<unknown>,
);
});
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();
}
});
});

View File

@@ -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<string, unknown>;
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();
}
});
});

View File

@@ -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<GetSeasonSponsorshipsResult> & { 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<GetSeasonSponsorshipsResult> & { 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();
});
});

View File

@@ -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<GetSponsorResult> & { present: Mock };
let useCase: GetSponsorUseCase;
beforeEach(() => {
sponsorRepository = {
findById: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetSponsorResult> & { 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();
});
});

View File

@@ -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<ReopenRaceResult> & { 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<ReopenRaceResult> & { 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();
});
});

View File

@@ -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<WithdrawFromLeagueWalletResult> & { 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<WithdrawFromLeagueWalletResult> & {
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<string, unknown> | 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);
});
});

View File

@@ -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<GetCurrentUserSocialResult> & { 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<GetCurrentUserSocialResult> & { 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);
});
});

View File

@@ -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<GetUserFeedResult> & { 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<GetUserFeedResult> & { 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);
});
});