add tests to core
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
126
core/media/application/use-cases/SelectAvatarUseCase.test.ts
Normal file
126
core/media/application/use-cases/SelectAvatarUseCase.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
130
core/media/application/use-cases/UpdateAvatarUseCase.test.ts
Normal file
130
core/media/application/use-cases/UpdateAvatarUseCase.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
156
core/media/application/use-cases/UploadMediaUseCase.test.ts
Normal file
156
core/media/application/use-cases/UploadMediaUseCase.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
104
core/payments/application/use-cases/AwardPrizeUseCase.test.ts
Normal file
104
core/payments/application/use-cases/AwardPrizeUseCase.test.ts
Normal 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),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
102
core/payments/application/use-cases/CreatePrizeUseCase.test.ts
Normal file
102
core/payments/application/use-cases/CreatePrizeUseCase.test.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
110
core/payments/application/use-cases/GetPrizesUseCase.test.ts
Normal file
110
core/payments/application/use-cases/GetPrizesUseCase.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
142
core/payments/application/use-cases/GetWalletUseCase.test.ts
Normal file
142
core/payments/application/use-cases/GetWalletUseCase.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
28
core/payments/application/use-cases/index.test.ts
Normal file
28
core/payments/application/use-cases/index.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
88
core/racing/application/use-cases/GetSponsorUseCase.test.ts
Normal file
88
core/racing/application/use-cases/GetSponsorUseCase.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
174
core/racing/application/use-cases/ReopenRaceUseCase.test.ts
Normal file
174
core/racing/application/use-cases/ReopenRaceUseCase.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
113
core/social/application/use-cases/GetUserFeedUseCase.test.ts
Normal file
113
core/social/application/use-cases/GetUserFeedUseCase.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user