refactor use cases

This commit is contained in:
2026-01-08 15:34:51 +01:00
parent d984ab24a8
commit 52e9a2f6a7
362 changed files with 5192 additions and 8409 deletions

View File

@@ -2,11 +2,9 @@ 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(() => {
@@ -15,13 +13,8 @@ describe('AwardPrizeUseCase', () => {
update: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new AwardPrizeUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
@@ -34,7 +27,6 @@ describe('AwardPrizeUseCase', () => {
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 () => {
@@ -59,10 +51,9 @@ describe('AwardPrizeUseCase', () => {
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 () => {
it('awards prize and returns updated prize', async () => {
const prize: Prize = {
id: 'prize-1',
leagueId: 'league-1',
@@ -92,13 +83,14 @@ describe('AwardPrizeUseCase', () => {
}),
);
expect(output.present).toHaveBeenCalledWith({
prize: expect.objectContaining({
const value = result.value;
expect(value.prize).toEqual(
expect.objectContaining({
id: 'prize-1',
awarded: true,
awardedTo: 'driver-1',
awardedAt: expect.any(Date),
}),
});
);
});
});

View File

@@ -7,7 +7,6 @@
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type { Prize } from '../../domain/entities/Prize';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -23,14 +22,13 @@ export interface AwardPrizeResult {
export type AwardPrizeErrorCode = 'PRIZE_NOT_FOUND' | 'PRIZE_ALREADY_AWARDED';
export class AwardPrizeUseCase
implements UseCase<AwardPrizeInput, void, AwardPrizeErrorCode>
implements UseCase<AwardPrizeInput, AwardPrizeResult, AwardPrizeErrorCode>
{
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<AwardPrizeResult>,
) {}
async execute(input: AwardPrizeInput): Promise<Result<void, ApplicationErrorCode<AwardPrizeErrorCode>>> {
async execute(input: AwardPrizeInput): Promise<Result<AwardPrizeResult, ApplicationErrorCode<AwardPrizeErrorCode>>> {
const { prizeId, driverId } = input;
const prize = await this.prizeRepository.findById(prizeId);
@@ -48,8 +46,6 @@ export class AwardPrizeUseCase
const updatedPrize = await this.prizeRepository.update(prize);
this.output.present({ prize: updatedPrize });
return Result.ok(undefined);
return Result.ok({ prize: updatedPrize });
}
}

View File

@@ -1,16 +1,12 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { CreatePaymentUseCase, type CreatePaymentInput } from './CreatePaymentUseCase';
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import { PaymentType, PayerType } from '../../domain/entities/Payment';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { PaymentType, PayerType, PaymentStatus } from '../../domain/entities/Payment';
describe('CreatePaymentUseCase', () => {
let paymentRepository: {
create: Mock;
};
let output: {
present: Mock;
};
let useCase: CreatePaymentUseCase;
beforeEach(() => {
@@ -18,17 +14,12 @@ describe('CreatePaymentUseCase', () => {
create: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new CreatePaymentUseCase(
paymentRepository as unknown as IPaymentRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('creates a payment and presents the result', async () => {
it('creates a payment and returns result', async () => {
const input: CreatePaymentInput = {
type: PaymentType.SPONSORSHIP,
amount: 100,
@@ -39,7 +30,7 @@ describe('CreatePaymentUseCase', () => {
};
const createdPayment = {
id: 'payment-123',
id: 'payment-1',
type: PaymentType.SPONSORSHIP,
amount: 100,
platformFee: 5,
@@ -48,9 +39,8 @@ describe('CreatePaymentUseCase', () => {
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
seasonId: 'season-1',
status: 'pending',
status: PaymentStatus.PENDING,
createdAt: new Date(),
completedAt: undefined,
};
paymentRepository.create.mockResolvedValue(createdPayment);
@@ -58,19 +48,10 @@ describe('CreatePaymentUseCase', () => {
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(paymentRepository.create).toHaveBeenCalledWith({
id: expect.stringContaining('payment-'),
type: 'sponsorship',
amount: 100,
platformFee: 5,
netAmount: 95,
payerId: 'payer-1',
payerType: 'sponsor',
leagueId: 'league-1',
seasonId: 'season-1',
status: 'pending',
createdAt: expect.any(Date),
});
expect(output.present).toHaveBeenCalledWith({ payment: createdPayment });
expect(paymentRepository.create).toHaveBeenCalled();
if (result.isOk()) {
expect(result.value.payment).toEqual(createdPayment);
}
});
});

View File

@@ -8,7 +8,6 @@ import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepos
import type { Payment, PaymentType, PayerType } from '../../domain/entities/Payment';
import { PaymentStatus } from '../../domain/entities/Payment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -28,14 +27,13 @@ export interface CreatePaymentResult {
export type CreatePaymentErrorCode = never;
export class CreatePaymentUseCase
implements UseCase<CreatePaymentInput, void, CreatePaymentErrorCode>
implements UseCase<CreatePaymentInput, CreatePaymentResult, CreatePaymentErrorCode>
{
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly output: UseCaseOutputPort<CreatePaymentResult>,
) {}
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
async execute(input: CreatePaymentInput): Promise<Result<CreatePaymentResult, ApplicationErrorCode<CreatePaymentErrorCode>>> {
const { type, amount, payerId, payerType, leagueId, seasonId } = input;
// Calculate platform fee (assume 5% for now)
@@ -59,8 +57,6 @@ export class CreatePaymentUseCase
const createdPayment = await this.paymentRepository.create(payment);
this.output.present({ payment: createdPayment });
return Result.ok(undefined);
return Result.ok({ payment: createdPayment });
}
}

View File

@@ -2,11 +2,9 @@ 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(() => {
@@ -15,13 +13,8 @@ describe('CreatePrizeUseCase', () => {
create: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new CreatePrizeUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
@@ -54,10 +47,9 @@ describe('CreatePrizeUseCase', () => {
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 () => {
it('creates prize and returns created prize', async () => {
prizeRepository.findByPosition.mockResolvedValue(null);
prizeRepository.create.mockImplementation(async (p: Prize) => p);
@@ -90,13 +82,14 @@ describe('CreatePrizeUseCase', () => {
description: 'Top prize',
});
expect(output.present).toHaveBeenCalledWith({
prize: expect.objectContaining({
const value = result.value;
expect(value.prize).toEqual(
expect.objectContaining({
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
awarded: false,
}),
});
);
});
});

View File

@@ -7,7 +7,6 @@
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type { PrizeType, Prize } from '../../domain/entities/Prize';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -28,14 +27,13 @@ export interface CreatePrizeResult {
export type CreatePrizeErrorCode = 'PRIZE_ALREADY_EXISTS';
export class CreatePrizeUseCase
implements UseCase<CreatePrizeInput, void, CreatePrizeErrorCode>
implements UseCase<CreatePrizeInput, CreatePrizeResult, CreatePrizeErrorCode>
{
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<CreatePrizeResult>,
) {}
async execute(input: CreatePrizeInput): Promise<Result<void, ApplicationErrorCode<CreatePrizeErrorCode>>> {
async execute(input: CreatePrizeInput): Promise<Result<CreatePrizeResult, ApplicationErrorCode<CreatePrizeErrorCode>>> {
const { leagueId, seasonId, position, name, amount, type, description } = input;
const existingPrize = await this.prizeRepository.findByPosition(leagueId, seasonId, position);
@@ -59,8 +57,6 @@ export class CreatePrizeUseCase
const createdPrize = await this.prizeRepository.create(prize);
this.output.present({ prize: createdPrize });
return Result.ok(undefined);
return Result.ok({ prize: createdPrize });
}
}

View File

@@ -2,11 +2,9 @@ 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(() => {
@@ -15,13 +13,8 @@ describe('DeletePrizeUseCase', () => {
delete: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new DeletePrizeUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
@@ -34,7 +27,6 @@ describe('DeletePrizeUseCase', () => {
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 () => {
@@ -59,10 +51,9 @@ describe('DeletePrizeUseCase', () => {
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 () => {
it('deletes prize and returns success', async () => {
const prize: Prize = {
id: 'prize-1',
leagueId: 'league-1',
@@ -82,6 +73,7 @@ describe('DeletePrizeUseCase', () => {
expect(result.isOk()).toBe(true);
expect(prizeRepository.delete).toHaveBeenCalledWith('prize-1');
expect(output.present).toHaveBeenCalledWith({ success: true });
const value = result.value;
expect(value.success).toBe(true);
});
});

View File

@@ -6,7 +6,6 @@
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -21,14 +20,13 @@ export interface DeletePrizeResult {
export type DeletePrizeErrorCode = 'PRIZE_NOT_FOUND' | 'CANNOT_DELETE_AWARDED_PRIZE';
export class DeletePrizeUseCase
implements UseCase<DeletePrizeInput, void, DeletePrizeErrorCode>
implements UseCase<DeletePrizeInput, DeletePrizeResult, DeletePrizeErrorCode>
{
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<DeletePrizeResult>,
) {}
async execute(input: DeletePrizeInput): Promise<Result<void, ApplicationErrorCode<DeletePrizeErrorCode>>> {
async execute(input: DeletePrizeInput): Promise<Result<DeletePrizeResult, ApplicationErrorCode<DeletePrizeErrorCode>>> {
const { prizeId } = input;
const prize = await this.prizeRepository.findById(prizeId);
@@ -42,8 +40,6 @@ export class DeletePrizeUseCase
await this.prizeRepository.delete(prizeId);
this.output.present({ success: true });
return Result.ok(undefined);
return Result.ok({ success: true });
}
}

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetMembershipFeesUseCase, type GetMembershipFeesInput } from './GetMembershipFeesUseCase';
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetMembershipFeesUseCase', () => {
let membershipFeeRepository: {
@@ -10,81 +9,49 @@ describe('GetMembershipFeesUseCase', () => {
let memberPaymentRepository: {
findByLeagueIdAndDriverId: Mock;
};
let output: {
present: Mock;
};
let useCase: GetMembershipFeesUseCase;
beforeEach(() => {
membershipFeeRepository = {
findByLeagueId: vi.fn(),
};
memberPaymentRepository = {
findByLeagueIdAndDriverId: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetMembershipFeesUseCase(
membershipFeeRepository as unknown as IMembershipFeeRepository,
memberPaymentRepository as unknown as IMemberPaymentRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('returns error when leagueId is missing', async () => {
const input = { leagueId: '' } as GetMembershipFeesInput;
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
});
it('returns null fee and empty payments when no fee exists', async () => {
const input: GetMembershipFeesInput = { leagueId: 'league-1' };
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
expect(memberPaymentRepository.findByLeagueIdAndDriverId).not.toHaveBeenCalled();
expect(output.present).toHaveBeenCalledWith({
fee: null,
payments: [],
});
});
it('maps fee and payments when fee and driverId are provided', async () => {
const input: GetMembershipFeesInput = { leagueId: 'league-1', driverId: 'driver-1' };
it('retrieves membership fees and returns result', async () => {
const input: GetMembershipFeesInput = {
leagueId: 'league-1',
driverId: 'driver-1',
};
const fee = {
id: 'fee-1',
leagueId: 'league-1',
seasonId: 'season-1',
type: 'season',
amount: 100,
type: 'monthly',
amount: 50,
enabled: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
createdAt: new Date(),
updatedAt: new Date(),
};
const payments = [
{
id: 'pay-1',
id: 'payment-1',
feeId: 'fee-1',
driverId: 'driver-1',
amount: 100,
amount: 50,
platformFee: 5,
netAmount: 95,
netAmount: 45,
status: 'paid',
dueDate: new Date('2024-02-01'),
paidAt: new Date('2024-01-15'),
dueDate: new Date(),
paidAt: new Date(),
},
];
@@ -95,11 +62,23 @@ describe('GetMembershipFeesUseCase', () => {
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
expect(memberPaymentRepository.findByLeagueIdAndDriverId).toHaveBeenCalledWith('league-1', 'driver-1', membershipFeeRepository as unknown as IMembershipFeeRepository);
expect(output.present).toHaveBeenCalledWith({
fee,
payments,
});
expect(memberPaymentRepository.findByLeagueIdAndDriverId).toHaveBeenCalledWith('league-1', 'driver-1', membershipFeeRepository);
if (result.isOk()) {
expect(result.value).toEqual({ fee, payments });
}
});
});
it('returns error when leagueId is missing', async () => {
const input: GetMembershipFeesInput = {
leagueId: '',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
if (result.isErr()) {
expect(result.error.code).toBe('INVALID_INPUT');
}
});
});

View File

@@ -8,7 +8,6 @@ import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../d
import type { MembershipFee } from '../../domain/entities/MembershipFee';
import type { MemberPayment } from '../../domain/entities/MemberPayment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -25,15 +24,14 @@ export interface GetMembershipFeesResult {
}
export class GetMembershipFeesUseCase
implements UseCase<GetMembershipFeesInput, void, GetMembershipFeesErrorCode>
implements UseCase<GetMembershipFeesInput, GetMembershipFeesResult, GetMembershipFeesErrorCode>
{
constructor(
private readonly membershipFeeRepository: IMembershipFeeRepository,
private readonly memberPaymentRepository: IMemberPaymentRepository,
private readonly output: UseCaseOutputPort<GetMembershipFeesResult>,
) {}
async execute(input: GetMembershipFeesInput): Promise<Result<void, ApplicationErrorCode<GetMembershipFeesErrorCode>>> {
async execute(input: GetMembershipFeesInput): Promise<Result<GetMembershipFeesResult, ApplicationErrorCode<GetMembershipFeesErrorCode>>> {
const { leagueId, driverId } = input;
if (!leagueId) {
@@ -47,8 +45,6 @@ export class GetMembershipFeesUseCase
payments = await this.memberPaymentRepository.findByLeagueIdAndDriverId(leagueId, driverId, this.membershipFeeRepository);
}
this.output.present({ fee, payments });
return Result.ok(undefined);
return Result.ok({ fee, payments });
}
}

View File

@@ -2,15 +2,11 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetPaymentsUseCase, type GetPaymentsInput } from './GetPaymentsUseCase';
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import { PaymentType, PayerType } from '../../domain/entities/Payment';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetPaymentsUseCase', () => {
let paymentRepository: {
findByFilters: Mock;
};
let output: {
present: Mock;
};
let useCase: GetPaymentsUseCase;
beforeEach(() => {
@@ -18,17 +14,12 @@ describe('GetPaymentsUseCase', () => {
findByFilters: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetPaymentsUseCase(
paymentRepository as unknown as IPaymentRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('retrieves payments and presents the result', async () => {
it('retrieves payments and returns result', async () => {
const input: GetPaymentsInput = {
leagueId: 'league-1',
payerId: 'payer-1',
@@ -62,6 +53,9 @@ describe('GetPaymentsUseCase', () => {
payerId: 'payer-1',
type: PaymentType.SPONSORSHIP,
});
expect(output.present).toHaveBeenCalledWith({ payments });
if (result.isOk()) {
expect(result.value).toEqual({ payments });
}
});
});

View File

@@ -7,7 +7,6 @@
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import type { Payment, PaymentType } from '../../domain/entities/Payment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -24,14 +23,13 @@ export interface GetPaymentsResult {
export type GetPaymentsErrorCode = never;
export class GetPaymentsUseCase
implements UseCase<GetPaymentsInput, void, GetPaymentsErrorCode>
implements UseCase<GetPaymentsInput, GetPaymentsResult, GetPaymentsErrorCode>
{
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly output: UseCaseOutputPort<GetPaymentsResult>,
) {}
async execute(input: GetPaymentsInput): Promise<Result<void, ApplicationErrorCode<GetPaymentsErrorCode>>> {
async execute(input: GetPaymentsInput): Promise<Result<GetPaymentsResult, ApplicationErrorCode<GetPaymentsErrorCode>>> {
const { leagueId, payerId, type } = input;
const filters: { leagueId?: string; payerId?: string; type?: PaymentType } = {};
@@ -41,8 +39,6 @@ export class GetPaymentsUseCase
const payments = await this.paymentRepository.findByFilters(filters);
this.output.present({ payments });
return Result.ok(undefined);
return Result.ok({ payments });
}
}

View File

@@ -2,14 +2,12 @@ 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(() => {
@@ -18,13 +16,8 @@ describe('GetPrizesUseCase', () => {
findByLeagueIdAndSeasonId: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetPrizesUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
@@ -62,10 +55,9 @@ describe('GetPrizesUseCase', () => {
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']);
const value = result.value;
expect(value.prizes.map(p => p.position)).toEqual([1, 2]);
expect(value.prizes.map(p => p.id)).toEqual(['p1', 'p2']);
});
it('retrieves and sorts prizes by leagueId and seasonId when provided', async () => {
@@ -102,9 +94,8 @@ describe('GetPrizesUseCase', () => {
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']);
const value = result.value;
expect(value.prizes.map(p => p.position)).toEqual([1, 3]);
expect(value.prizes.map(p => p.id)).toEqual(['p1', 'p3']);
});
});

View File

@@ -7,7 +7,6 @@
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import type { Prize } from '../../domain/entities/Prize';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
export interface GetPrizesInput {
@@ -20,14 +19,13 @@ export interface GetPrizesResult {
}
export class GetPrizesUseCase
implements UseCase<GetPrizesInput, void, never>
implements UseCase<GetPrizesInput, GetPrizesResult, never>
{
constructor(
private readonly prizeRepository: IPrizeRepository,
private readonly output: UseCaseOutputPort<GetPrizesResult>,
) {}
async execute(input: GetPrizesInput): Promise<Result<void, never>> {
async execute(input: GetPrizesInput): Promise<Result<GetPrizesResult, never>> {
const { leagueId, seasonId } = input;
let prizes;
@@ -39,8 +37,6 @@ export class GetPrizesUseCase
prizes.sort((a, b) => a.position - b.position);
this.output.present({ prizes });
return Result.ok(undefined);
return Result.ok({ prizes });
}
}

View File

@@ -3,7 +3,6 @@ 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: {
@@ -15,10 +14,6 @@ describe('GetWalletUseCase', () => {
findByWalletId: Mock;
};
let output: {
present: Mock;
};
let useCase: GetWalletUseCase;
beforeEach(() => {
@@ -31,14 +26,9 @@ describe('GetWalletUseCase', () => {
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>,
);
});
@@ -49,10 +39,9 @@ describe('GetWalletUseCase', () => {
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 () => {
it('returns wallet and transactions sorted desc by createdAt', async () => {
const input: GetWalletInput = { leagueId: 'league-1' };
const wallet: Wallet = {
@@ -90,14 +79,15 @@ describe('GetWalletUseCase', () => {
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith('wallet-1');
expect(output.present).toHaveBeenCalledWith({
const value = result.value;
expect(value).toEqual({
wallet,
transactions: [newer, older],
});
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith('wallet-1');
});
it('creates wallet when missing, then presents wallet and transactions', async () => {
it('creates wallet when missing, then returns wallet and transactions', async () => {
const input: GetWalletInput = { leagueId: 'league-1' };
vi.useFakeTimers();
@@ -131,7 +121,8 @@ describe('GetWalletUseCase', () => {
const createdWalletArg = walletRepository.create.mock.calls[0]?.[0] as Wallet;
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith(createdWalletArg.id);
expect(output.present).toHaveBeenCalledWith({
const value = result.value;
expect(value).toEqual({
wallet: createdWalletArg,
transactions: [],
});

View File

@@ -7,7 +7,6 @@
import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository';
import type { Wallet, Transaction } from '../../domain/entities/Wallet';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -23,15 +22,14 @@ export interface GetWalletResult {
}
export class GetWalletUseCase
implements UseCase<GetWalletInput, void, GetWalletErrorCode>
implements UseCase<GetWalletInput, GetWalletResult, GetWalletErrorCode>
{
constructor(
private readonly walletRepository: IWalletRepository,
private readonly transactionRepository: ITransactionRepository,
private readonly output: UseCaseOutputPort<GetWalletResult>,
) {}
async execute(input: GetWalletInput): Promise<Result<void, ApplicationErrorCode<GetWalletErrorCode>>> {
async execute(input: GetWalletInput): Promise<Result<GetWalletResult, ApplicationErrorCode<GetWalletErrorCode>>> {
const { leagueId } = input;
if (!leagueId) {
@@ -58,8 +56,6 @@ export class GetWalletUseCase
const transactions = await this.transactionRepository.findByWalletId(wallet.id);
transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
this.output.present({ wallet, transactions });
return Result.ok(undefined);
return Result.ok({ wallet, transactions });
}
}

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import { ProcessWalletTransactionUseCase, type ProcessWalletTransactionInput } from './ProcessWalletTransactionUseCase';
import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository';
import { TransactionType, ReferenceType } from '../../domain/entities/Wallet';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ProcessWalletTransactionUseCase', () => {
let walletRepository: {
@@ -13,9 +12,6 @@ describe('ProcessWalletTransactionUseCase', () => {
let transactionRepository: {
create: Mock;
};
let output: {
present: Mock;
};
let useCase: ProcessWalletTransactionUseCase;
beforeEach(() => {
@@ -29,18 +25,13 @@ describe('ProcessWalletTransactionUseCase', () => {
create: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new ProcessWalletTransactionUseCase(
walletRepository as unknown as IWalletRepository,
transactionRepository as unknown as ITransactionRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('processes a deposit transaction and presents the result', async () => {
it('processes a deposit transaction and returns the result', async () => {
const input: ProcessWalletTransactionInput = {
leagueId: 'league-1',
type: TransactionType.DEPOSIT,
@@ -79,10 +70,9 @@ describe('ProcessWalletTransactionUseCase', () => {
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({
wallet: { ...wallet, balance: 150, totalRevenue: 150 },
transaction,
});
const value = result.value;
expect(value.wallet).toEqual({ ...wallet, balance: 150, totalRevenue: 150 });
expect(value.transaction).toEqual(transaction);
});
it('returns error for insufficient balance on withdrawal', async () => {

View File

@@ -8,7 +8,6 @@ import type { IWalletRepository, ITransactionRepository } from '../../domain/rep
import type { Wallet, Transaction } from '../../domain/entities/Wallet';
import { TransactionType, ReferenceType } from '../../domain/entities/Wallet';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -29,15 +28,14 @@ export interface ProcessWalletTransactionResult {
export type ProcessWalletTransactionErrorCode = 'MISSING_REQUIRED_FIELDS' | 'INVALID_TYPE' | 'INSUFFICIENT_BALANCE';
export class ProcessWalletTransactionUseCase
implements UseCase<ProcessWalletTransactionInput, void, ProcessWalletTransactionErrorCode>
implements UseCase<ProcessWalletTransactionInput, ProcessWalletTransactionResult, ProcessWalletTransactionErrorCode>
{
constructor(
private readonly walletRepository: IWalletRepository,
private readonly transactionRepository: ITransactionRepository,
private readonly output: UseCaseOutputPort<ProcessWalletTransactionResult>,
) {}
async execute(input: ProcessWalletTransactionInput): Promise<Result<void, ApplicationErrorCode<ProcessWalletTransactionErrorCode>>> {
async execute(input: ProcessWalletTransactionInput): Promise<Result<ProcessWalletTransactionResult, ApplicationErrorCode<ProcessWalletTransactionErrorCode>>> {
const { leagueId, type, amount, description, referenceId, referenceType } = input;
if (!leagueId || !type || amount === undefined || !description) {
@@ -95,8 +93,6 @@ export class ProcessWalletTransactionUseCase
const updatedWallet = await this.walletRepository.update(wallet);
this.output.present({ wallet: updatedWallet, transaction: createdTransaction });
return Result.ok(undefined);
return Result.ok({ wallet: updatedWallet, transaction: createdTransaction });
}
}

View File

@@ -2,7 +2,6 @@ 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: {
@@ -15,10 +14,6 @@ describe('UpdateMemberPaymentUseCase', () => {
update: Mock;
};
let output: {
present: Mock;
};
let useCase: UpdateMemberPaymentUseCase;
beforeEach(() => {
@@ -32,14 +27,9 @@ describe('UpdateMemberPaymentUseCase', () => {
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>,
);
});
@@ -58,7 +48,6 @@ describe('UpdateMemberPaymentUseCase', () => {
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 () => {
@@ -112,8 +101,9 @@ describe('UpdateMemberPaymentUseCase', () => {
}),
);
const value = result.value;
const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment;
expect(output.present).toHaveBeenCalledWith({ payment: updated });
expect(value.payment).toEqual(updated);
} finally {
vi.useRealTimers();
}
@@ -164,7 +154,8 @@ describe('UpdateMemberPaymentUseCase', () => {
}),
);
const value = result.value;
const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment;
expect(output.present).toHaveBeenCalledWith({ payment: updated });
expect(value.payment).toEqual(updated);
});
});

View File

@@ -8,7 +8,6 @@ import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../d
import type { MemberPayment } from '../../domain/entities/MemberPayment';
import { MemberPaymentStatus } from '../../domain/entities/MemberPayment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -28,15 +27,14 @@ export interface UpdateMemberPaymentResult {
export type UpdateMemberPaymentErrorCode = 'MEMBERSHIP_FEE_NOT_FOUND';
export class UpdateMemberPaymentUseCase
implements UseCase<UpdateMemberPaymentInput, void, UpdateMemberPaymentErrorCode>
implements UseCase<UpdateMemberPaymentInput, UpdateMemberPaymentResult, UpdateMemberPaymentErrorCode>
{
constructor(
private readonly membershipFeeRepository: IMembershipFeeRepository,
private readonly memberPaymentRepository: IMemberPaymentRepository,
private readonly output: UseCaseOutputPort<UpdateMemberPaymentResult>,
) {}
async execute(input: UpdateMemberPaymentInput): Promise<Result<void, ApplicationErrorCode<UpdateMemberPaymentErrorCode>>> {
async execute(input: UpdateMemberPaymentInput): Promise<Result<UpdateMemberPaymentResult, ApplicationErrorCode<UpdateMemberPaymentErrorCode>>> {
const { feeId, driverId, status, paidAt } = input;
const fee = await this.membershipFeeRepository.findById(feeId);
@@ -73,8 +71,6 @@ export class UpdateMemberPaymentUseCase
const updatedPayment = await this.memberPaymentRepository.update(payment);
this.output.present({ payment: updatedPayment });
return Result.ok(undefined);
return Result.ok({ payment: updatedPayment });
}
}

View File

@@ -1,19 +1,13 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { describe, it, expect, 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';
import { PaymentStatus, PaymentType, PayerType } from '../../domain/entities/Payment';
describe('UpdatePaymentStatusUseCase', () => {
let paymentRepository: {
findById: Mock;
update: Mock;
};
let output: {
present: Mock;
};
let useCase: UpdatePaymentStatusUseCase;
beforeEach(() => {
@@ -22,133 +16,63 @@ describe('UpdatePaymentStatusUseCase', () => {
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 () => {
it('updates payment status and returns result', async () => {
const input: UpdatePaymentStatusInput = {
paymentId: 'payment-1',
status: PaymentStatus.COMPLETED,
};
const existingPayment = {
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(),
};
const updatedPayment = {
...existingPayment,
status: PaymentStatus.COMPLETED,
completedAt: new Date(),
};
paymentRepository.findById.mockResolvedValue(existingPayment);
paymentRepository.update.mockResolvedValue(updatedPayment);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(paymentRepository.findById).toHaveBeenCalledWith('payment-1');
expect(paymentRepository.update).toHaveBeenCalled();
if (result.isOk()) {
expect(result.value.payment).toEqual(updatedPayment);
}
});
it('returns error when payment not found', async () => {
const input: UpdatePaymentStatusInput = {
paymentId: 'non-existent',
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();
if (result.isErr()) {
expect(result.error.code).toBe('PAYMENT_NOT_FOUND');
}
});
});

View File

@@ -8,7 +8,6 @@ import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepos
import type { Payment } from '../../domain/entities/Payment';
import { PaymentStatus } from '../../domain/entities/Payment';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -24,14 +23,13 @@ export interface UpdatePaymentStatusResult {
}
export class UpdatePaymentStatusUseCase
implements UseCase<UpdatePaymentStatusInput, void, UpdatePaymentStatusErrorCode>
implements UseCase<UpdatePaymentStatusInput, UpdatePaymentStatusResult, UpdatePaymentStatusErrorCode>
{
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly output: UseCaseOutputPort<UpdatePaymentStatusResult>,
) {}
async execute(input: UpdatePaymentStatusInput): Promise<Result<void, ApplicationErrorCode<UpdatePaymentStatusErrorCode>>> {
async execute(input: UpdatePaymentStatusInput): Promise<Result<UpdatePaymentStatusResult, ApplicationErrorCode<UpdatePaymentStatusErrorCode>>> {
const { paymentId, status } = input;
const existingPayment = await this.paymentRepository.findById(paymentId);
@@ -47,8 +45,6 @@ export class UpdatePaymentStatusUseCase
const savedPayment = await this.paymentRepository.update(updatedPayment as Payment);
this.output.present({ payment: savedPayment });
return Result.ok(undefined);
return Result.ok({ payment: savedPayment });
}
}

View File

@@ -1,127 +1,98 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { describe, it, expect, 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';
import { MembershipFeeType } from '../../domain/entities/MembershipFee';
describe('UpsertMembershipFeeUseCase', () => {
let membershipFeeRepository: {
findByLeagueId: Mock;
create: Mock;
update: Mock;
create: Mock;
};
let output: {
present: Mock;
};
let useCase: UpsertMembershipFeeUseCase;
beforeEach(() => {
membershipFeeRepository = {
findByLeagueId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
output = {
present: vi.fn(),
create: 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);
it('updates existing membership fee and returns result', async () => {
const input: UpsertMembershipFeeInput = {
leagueId: 'league-1',
seasonId: 'season-1',
type: MembershipFeeType.MONTHLY,
amount: 50,
};
try {
const input: UpsertMembershipFeeInput = {
leagueId: 'league-1',
type: MembershipFeeType.SEASON,
amount: 100,
};
const existingFee = {
id: 'fee-1',
leagueId: 'league-1',
type: MembershipFeeType.YEARLY,
amount: 100,
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
membershipFeeRepository.create.mockImplementation(async (fee: MembershipFee) => ({ ...fee }));
const updatedFee = {
...existingFee,
type: MembershipFeeType.MONTHLY,
amount: 50,
seasonId: 'season-1',
enabled: true,
updatedAt: new Date(),
};
const result = await useCase.execute(input);
membershipFeeRepository.findByLeagueId.mockResolvedValue(existingFee);
membershipFeeRepository.update.mockResolvedValue(updatedFee);
expect(result.isOk()).toBe(true);
const result = await useCase.execute(input);
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();
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
expect(membershipFeeRepository.update).toHaveBeenCalled();
if (result.isOk()) {
expect(result.value.fee).toEqual(updatedFee);
}
});
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'));
it('creates new membership fee and returns result', async () => {
const input: UpsertMembershipFeeInput = {
leagueId: 'league-1',
type: MembershipFeeType.MONTHLY,
amount: 50,
};
try {
const input: UpsertMembershipFeeInput = {
leagueId: 'league-1',
seasonId: 'season-2',
type: MembershipFeeType.MONTHLY,
amount: 0,
};
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
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'),
};
const createdFee = {
id: 'fee-new',
leagueId: 'league-1',
type: MembershipFeeType.MONTHLY,
amount: 50,
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
membershipFeeRepository.findByLeagueId.mockResolvedValue(existingFee);
membershipFeeRepository.update.mockImplementation(async (fee: MembershipFee) => ({ ...fee }));
membershipFeeRepository.create.mockResolvedValue(createdFee);
const result = await useCase.execute(input);
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();
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
expect(membershipFeeRepository.create).toHaveBeenCalled();
if (result.isOk()) {
expect(result.value.fee).toEqual(createdFee);
}
});
});

View File

@@ -7,7 +7,6 @@
import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository';
import type { MembershipFeeType, MembershipFee } from '../../domain/entities/MembershipFee';
import type { UseCase } from '@core/shared/application/UseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
export interface UpsertMembershipFeeInput {
@@ -24,14 +23,13 @@ export interface UpsertMembershipFeeResult {
export type UpsertMembershipFeeErrorCode = never;
export class UpsertMembershipFeeUseCase
implements UseCase<UpsertMembershipFeeInput, void, UpsertMembershipFeeErrorCode>
implements UseCase<UpsertMembershipFeeInput, UpsertMembershipFeeResult, UpsertMembershipFeeErrorCode>
{
constructor(
private readonly membershipFeeRepository: IMembershipFeeRepository,
private readonly output: UseCaseOutputPort<UpsertMembershipFeeResult>,
) {}
async execute(input: UpsertMembershipFeeInput): Promise<Result<void, never>> {
async execute(input: UpsertMembershipFeeInput): Promise<Result<UpsertMembershipFeeResult, never>> {
const { leagueId, seasonId, type, amount } = input;
let existingFee = await this.membershipFeeRepository.findByLeagueId(leagueId);
@@ -59,8 +57,6 @@ export class UpsertMembershipFeeUseCase
fee = await this.membershipFeeRepository.create(newFee);
}
this.output.present({ fee });
return Result.ok(undefined);
return Result.ok({ fee });
}
}