add tests to core

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

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { AwardPrizeUseCase, type AwardPrizeInput } from './AwardPrizeUseCase';
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import { PrizeType, type Prize } from '../../domain/entities/Prize';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('AwardPrizeUseCase', () => {
let prizeRepository: { findById: Mock; update: Mock };
let output: { present: Mock };
let useCase: AwardPrizeUseCase;
beforeEach(() => {
prizeRepository = {
findById: vi.fn(),
update: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new AwardPrizeUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('returns PRIZE_NOT_FOUND when prize does not exist', async () => {
prizeRepository.findById.mockResolvedValue(null);
const input: AwardPrizeInput = { prizeId: 'prize-1', driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('PRIZE_NOT_FOUND');
expect(prizeRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('returns PRIZE_ALREADY_AWARDED when prize is already awarded', async () => {
const prize: Prize = {
id: 'prize-1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
awarded: true,
awardedTo: 'driver-x',
awardedAt: new Date(),
createdAt: new Date(),
};
prizeRepository.findById.mockResolvedValue(prize);
const input: AwardPrizeInput = { prizeId: 'prize-1', driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('PRIZE_ALREADY_AWARDED');
expect(prizeRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('awards prize and presents updated prize', async () => {
const prize: Prize = {
id: 'prize-1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
awarded: false,
createdAt: new Date('2024-01-01T00:00:00.000Z'),
};
prizeRepository.findById.mockResolvedValue(prize);
prizeRepository.update.mockImplementation(async (p: Prize) => p);
const input: AwardPrizeInput = { prizeId: 'prize-1', driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(prizeRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'prize-1',
awarded: true,
awardedTo: 'driver-1',
awardedAt: expect.any(Date),
}),
);
expect(output.present).toHaveBeenCalledWith({
prize: expect.objectContaining({
id: 'prize-1',
awarded: true,
awardedTo: 'driver-1',
awardedAt: expect.any(Date),
}),
});
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { CreatePrizeUseCase, type CreatePrizeInput } from './CreatePrizeUseCase';
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import { PrizeType, type Prize } from '../../domain/entities/Prize';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CreatePrizeUseCase', () => {
let prizeRepository: { findByPosition: Mock; create: Mock };
let output: { present: Mock };
let useCase: CreatePrizeUseCase;
beforeEach(() => {
prizeRepository = {
findByPosition: vi.fn(),
create: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new CreatePrizeUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('returns PRIZE_ALREADY_EXISTS when prize already exists for position', async () => {
const existingPrize: Prize = {
id: 'prize-existing',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
awarded: false,
createdAt: new Date(),
};
prizeRepository.findByPosition.mockResolvedValue(existingPrize);
const input: CreatePrizeInput = {
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('PRIZE_ALREADY_EXISTS');
expect(prizeRepository.create).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('creates prize and presents created prize', async () => {
prizeRepository.findByPosition.mockResolvedValue(null);
prizeRepository.create.mockImplementation(async (p: Prize) => p);
const input: CreatePrizeInput = {
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
description: 'Top prize',
};
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(prizeRepository.findByPosition).toHaveBeenCalledWith('league-1', 'season-1', 1);
expect(prizeRepository.create).toHaveBeenCalledWith({
id: expect.stringContaining('prize-'),
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
awarded: false,
createdAt: expect.any(Date),
description: 'Top prize',
});
expect(output.present).toHaveBeenCalledWith({
prize: expect.objectContaining({
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
awarded: false,
}),
});
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { DeletePrizeUseCase, type DeletePrizeInput } from './DeletePrizeUseCase';
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import { PrizeType, type Prize } from '../../domain/entities/Prize';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('DeletePrizeUseCase', () => {
let prizeRepository: { findById: Mock; delete: Mock };
let output: { present: Mock };
let useCase: DeletePrizeUseCase;
beforeEach(() => {
prizeRepository = {
findById: vi.fn(),
delete: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new DeletePrizeUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('returns PRIZE_NOT_FOUND when prize does not exist', async () => {
prizeRepository.findById.mockResolvedValue(null);
const input: DeletePrizeInput = { prizeId: 'prize-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('PRIZE_NOT_FOUND');
expect(prizeRepository.delete).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('returns CANNOT_DELETE_AWARDED_PRIZE when prize is awarded', async () => {
const prize: Prize = {
id: 'prize-1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
awarded: true,
awardedTo: 'driver-1',
awardedAt: new Date(),
createdAt: new Date(),
};
prizeRepository.findById.mockResolvedValue(prize);
const input: DeletePrizeInput = { prizeId: 'prize-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('CANNOT_DELETE_AWARDED_PRIZE');
expect(prizeRepository.delete).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('deletes prize and presents success', async () => {
const prize: Prize = {
id: 'prize-1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'Winner',
amount: 100,
type: PrizeType.CASH,
awarded: false,
createdAt: new Date(),
};
prizeRepository.findById.mockResolvedValue(prize);
prizeRepository.delete.mockResolvedValue(undefined);
const input: DeletePrizeInput = { prizeId: 'prize-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(prizeRepository.delete).toHaveBeenCalledWith('prize-1');
expect(output.present).toHaveBeenCalledWith({ success: true });
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetPrizesUseCase, type GetPrizesInput } from './GetPrizesUseCase';
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
import { PrizeType, type Prize } from '../../domain/entities/Prize';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetPrizesUseCase', () => {
let prizeRepository: {
findByLeagueId: Mock;
findByLeagueIdAndSeasonId: Mock;
};
let output: { present: Mock };
let useCase: GetPrizesUseCase;
beforeEach(() => {
prizeRepository = {
findByLeagueId: vi.fn(),
findByLeagueIdAndSeasonId: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetPrizesUseCase(
prizeRepository as unknown as IPrizeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('retrieves and sorts prizes by leagueId when seasonId is not provided', async () => {
const prizes: Prize[] = [
{
id: 'p2',
leagueId: 'league-1',
seasonId: 'season-1',
position: 2,
name: 'Second',
amount: 50,
type: PrizeType.CASH,
awarded: false,
createdAt: new Date(),
},
{
id: 'p1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'First',
amount: 100,
type: PrizeType.CASH,
awarded: false,
createdAt: new Date(),
},
];
prizeRepository.findByLeagueId.mockResolvedValue(prizes);
const input: GetPrizesInput = { leagueId: 'league-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(prizeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
expect(prizeRepository.findByLeagueIdAndSeasonId).not.toHaveBeenCalled();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as unknown as Mock).mock.calls[0]![0]!.prizes as Prize[];
expect(presented.map(p => p.position)).toEqual([1, 2]);
expect(presented.map(p => p.id)).toEqual(['p1', 'p2']);
});
it('retrieves and sorts prizes by leagueId and seasonId when provided', async () => {
const prizes: Prize[] = [
{
id: 'p3',
leagueId: 'league-1',
seasonId: 'season-1',
position: 3,
name: 'Third',
amount: 25,
type: PrizeType.CASH,
awarded: false,
createdAt: new Date(),
},
{
id: 'p1',
leagueId: 'league-1',
seasonId: 'season-1',
position: 1,
name: 'First',
amount: 100,
type: PrizeType.CASH,
awarded: false,
createdAt: new Date(),
},
];
prizeRepository.findByLeagueIdAndSeasonId.mockResolvedValue(prizes);
const input: GetPrizesInput = { leagueId: 'league-1', seasonId: 'season-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(prizeRepository.findByLeagueIdAndSeasonId).toHaveBeenCalledWith('league-1', 'season-1');
expect(prizeRepository.findByLeagueId).not.toHaveBeenCalled();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as unknown as Mock).mock.calls[0]![0]!.prizes as Prize[];
expect(presented.map(p => p.position)).toEqual([1, 3]);
expect(presented.map(p => p.id)).toEqual(['p1', 'p3']);
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetSponsorBillingUseCase, type GetSponsorBillingInput } from './GetSponsorBillingUseCase';
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import type { Payment } from '../../domain/entities/Payment';
import { PaymentStatus, PaymentType, PayerType } from '../../domain/entities/Payment';
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship';
import { Money } from '@core/racing/domain/value-objects/Money';
describe('GetSponsorBillingUseCase', () => {
let paymentRepository: { findByFilters: Mock };
let seasonSponsorshipRepository: { findBySponsorId: Mock };
let useCase: GetSponsorBillingUseCase;
beforeEach(() => {
paymentRepository = {
findByFilters: vi.fn(),
};
seasonSponsorshipRepository = {
findBySponsorId: vi.fn(),
};
useCase = new GetSponsorBillingUseCase(
paymentRepository as unknown as IPaymentRepository,
seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository,
);
});
it('derives invoices and stats from payments and sponsorships', async () => {
const sponsorId = 'sponsor-1';
const payments: Payment[] = [
{
id: 'pay-1',
type: PaymentType.SPONSORSHIP,
amount: 100,
platformFee: 10,
netAmount: 90,
payerId: sponsorId,
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
seasonId: 'season-1',
status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-01-01T00:00:00.000Z'),
completedAt: new Date('2024-01-01T00:00:00.000Z'),
},
{
id: 'pay-2',
type: PaymentType.SPONSORSHIP,
amount: 50,
platformFee: 5,
netAmount: 45,
payerId: sponsorId,
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
seasonId: 'season-1',
status: PaymentStatus.PENDING,
createdAt: new Date('2024-02-01T00:00:00.000Z'),
},
];
paymentRepository.findByFilters.mockResolvedValue(payments);
const sponsorships = [
SeasonSponsorship.create({
id: 'ss-1',
seasonId: 'season-1',
leagueId: 'league-1',
sponsorId,
tier: 'main',
pricing: Money.create(100, 'USD'),
status: 'active',
}),
SeasonSponsorship.create({
id: 'ss-2',
seasonId: 'season-2',
leagueId: 'league-1',
sponsorId,
tier: 'secondary',
pricing: Money.create(50, 'USD'),
status: 'pending',
}),
];
seasonSponsorshipRepository.findBySponsorId.mockResolvedValue(sponsorships);
const input: GetSponsorBillingInput = { sponsorId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const value = result.unwrap();
expect(paymentRepository.findByFilters).toHaveBeenCalledWith({
payerId: sponsorId,
type: PaymentType.SPONSORSHIP,
});
expect(seasonSponsorshipRepository.findBySponsorId).toHaveBeenCalledWith(sponsorId);
expect(value.paymentMethods).toEqual([]);
expect(value.invoices).toHaveLength(2);
// totals: each invoice adds 19% VAT
// pay-1 total: 100 + 19 = 119 (paid)
// pay-2 total: 50 + 9.5 = 59.5 (pending)
expect(value.stats.totalSpent).toBeCloseTo(119, 5);
expect(value.stats.pendingAmount).toBeCloseTo(59.5, 5);
expect(value.stats.activeSponsorships).toBe(1);
expect(value.stats.nextPaymentDate).not.toBeNull();
expect(value.stats.nextPaymentAmount).not.toBeNull();
});
});

View File

@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { GetWalletUseCase, type GetWalletInput } from './GetWalletUseCase';
import type { ITransactionRepository, IWalletRepository } from '../../domain/repositories/IWalletRepository';
import type { Transaction, Wallet } from '../../domain/entities/Wallet';
import { TransactionType } from '../../domain/entities/Wallet';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetWalletUseCase', () => {
let walletRepository: {
findByLeagueId: Mock;
create: Mock;
};
let transactionRepository: {
findByWalletId: Mock;
};
let output: {
present: Mock;
};
let useCase: GetWalletUseCase;
beforeEach(() => {
walletRepository = {
findByLeagueId: vi.fn(),
create: vi.fn(),
};
transactionRepository = {
findByWalletId: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetWalletUseCase(
walletRepository as unknown as IWalletRepository,
transactionRepository as unknown as ITransactionRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('returns INVALID_INPUT when leagueId is missing', async () => {
const input = { leagueId: '' } as unknown as GetWalletInput;
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
expect(output.present).not.toHaveBeenCalled();
});
it('presents existing wallet and transactions sorted desc by createdAt', async () => {
const input: GetWalletInput = { leagueId: 'league-1' };
const wallet: Wallet = {
id: 'wallet-1',
leagueId: 'league-1',
balance: 50,
totalRevenue: 100,
totalPlatformFees: 5,
totalWithdrawn: 10,
currency: 'USD',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
};
const older: Transaction = {
id: 'txn-older',
walletId: 'wallet-1',
type: TransactionType.DEPOSIT,
amount: 25,
description: 'Older',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
};
const newer: Transaction = {
id: 'txn-newer',
walletId: 'wallet-1',
type: TransactionType.WITHDRAWAL,
amount: 10,
description: 'Newer',
createdAt: new Date('2025-01-02T00:00:00.000Z'),
};
walletRepository.findByLeagueId.mockResolvedValue(wallet);
transactionRepository.findByWalletId.mockResolvedValue([older, newer]);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith('wallet-1');
expect(output.present).toHaveBeenCalledWith({
wallet,
transactions: [newer, older],
});
});
it('creates wallet when missing, then presents wallet and transactions', async () => {
const input: GetWalletInput = { leagueId: 'league-1' };
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
vi.spyOn(Math, 'random').mockReturnValue(0.123456789);
try {
walletRepository.findByLeagueId.mockResolvedValue(null);
walletRepository.create.mockImplementation(async (w: Wallet) => w);
transactionRepository.findByWalletId.mockResolvedValue([]);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(walletRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^wallet-1735689600000-[a-z0-9]{9}$/),
leagueId: 'league-1',
balance: 0,
totalRevenue: 0,
totalPlatformFees: 0,
totalWithdrawn: 0,
currency: 'USD',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
}),
);
const createdWalletArg = walletRepository.create.mock.calls[0]?.[0] as Wallet;
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith(createdWalletArg.id);
expect(output.present).toHaveBeenCalledWith({
wallet: createdWalletArg,
transactions: [],
});
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { UpdateMemberPaymentUseCase, type UpdateMemberPaymentInput } from './UpdateMemberPaymentUseCase';
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository';
import { MemberPaymentStatus, type MemberPayment } from '../../domain/entities/MemberPayment';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('UpdateMemberPaymentUseCase', () => {
let membershipFeeRepository: {
findById: Mock;
};
let memberPaymentRepository: {
findByFeeIdAndDriverId: Mock;
create: Mock;
update: Mock;
};
let output: {
present: Mock;
};
let useCase: UpdateMemberPaymentUseCase;
beforeEach(() => {
membershipFeeRepository = {
findById: vi.fn(),
};
memberPaymentRepository = {
findByFeeIdAndDriverId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new UpdateMemberPaymentUseCase(
membershipFeeRepository as unknown as IMembershipFeeRepository,
memberPaymentRepository as unknown as IMemberPaymentRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('returns MEMBERSHIP_FEE_NOT_FOUND when fee does not exist', async () => {
const input: UpdateMemberPaymentInput = {
feeId: 'fee-1',
driverId: 'driver-1',
};
membershipFeeRepository.findById.mockResolvedValue(null);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('MEMBERSHIP_FEE_NOT_FOUND');
expect(memberPaymentRepository.findByFeeIdAndDriverId).not.toHaveBeenCalled();
expect(memberPaymentRepository.create).not.toHaveBeenCalled();
expect(memberPaymentRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('creates a new payment when missing, applies status and paidAt when PAID', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
vi.spyOn(Math, 'random').mockReturnValue(0.123456789);
try {
const input: UpdateMemberPaymentInput = {
feeId: 'fee-1',
driverId: 'driver-1',
status: MemberPaymentStatus.PAID,
};
const fee = {
id: 'fee-1',
leagueId: 'league-1',
type: 'season',
amount: 100,
enabled: true,
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
};
membershipFeeRepository.findById.mockResolvedValue(fee);
memberPaymentRepository.findByFeeIdAndDriverId.mockResolvedValue(null);
memberPaymentRepository.create.mockImplementation(async (p: MemberPayment) => ({ ...p }));
memberPaymentRepository.update.mockImplementation(async (p: MemberPayment) => p);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(memberPaymentRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^mp-1735689600000-[a-z0-9]{9}$/),
feeId: 'fee-1',
driverId: 'driver-1',
amount: 100,
platformFee: 10,
netAmount: 90,
status: MemberPaymentStatus.PENDING,
dueDate: new Date('2025-01-01T00:00:00.000Z'),
}),
);
expect(memberPaymentRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
status: MemberPaymentStatus.PAID,
paidAt: new Date('2025-01-01T00:00:00.000Z'),
}),
);
const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment;
expect(output.present).toHaveBeenCalledWith({ payment: updated });
} finally {
vi.useRealTimers();
}
});
it('updates existing payment status and parses paidAt string', async () => {
const input: UpdateMemberPaymentInput = {
feeId: 'fee-1',
driverId: 'driver-1',
status: MemberPaymentStatus.PAID,
paidAt: '2025-02-01T00:00:00.000Z',
};
const fee = {
id: 'fee-1',
leagueId: 'league-1',
type: 'season',
amount: 100,
enabled: true,
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-02T00:00:00.000Z'),
};
const existingPayment: MemberPayment = {
id: 'mp-1',
feeId: 'fee-1',
driverId: 'driver-1',
amount: 100,
platformFee: 10,
netAmount: 90,
status: MemberPaymentStatus.PENDING,
dueDate: new Date('2025-01-01T00:00:00.000Z'),
};
membershipFeeRepository.findById.mockResolvedValue(fee);
memberPaymentRepository.findByFeeIdAndDriverId.mockResolvedValue(existingPayment);
memberPaymentRepository.update.mockImplementation(async (p: MemberPayment) => p);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(memberPaymentRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'mp-1',
status: MemberPaymentStatus.PAID,
paidAt: new Date('2025-02-01T00:00:00.000Z'),
}),
);
const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment;
expect(output.present).toHaveBeenCalledWith({ payment: updated });
});
});

View File

@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { UpdatePaymentStatusUseCase, type UpdatePaymentStatusInput } from './UpdatePaymentStatusUseCase';
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
import { PaymentStatus, PaymentType, PayerType, type Payment } from '../../domain/entities/Payment';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('UpdatePaymentStatusUseCase', () => {
let paymentRepository: {
findById: Mock;
update: Mock;
};
let output: {
present: Mock;
};
let useCase: UpdatePaymentStatusUseCase;
beforeEach(() => {
paymentRepository = {
findById: vi.fn(),
update: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new UpdatePaymentStatusUseCase(
paymentRepository as unknown as IPaymentRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('returns PAYMENT_NOT_FOUND when payment does not exist', async () => {
const input: UpdatePaymentStatusInput = {
paymentId: 'payment-1',
status: PaymentStatus.COMPLETED,
};
paymentRepository.findById.mockResolvedValue(null);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('PAYMENT_NOT_FOUND');
expect(paymentRepository.update).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('sets completedAt when status becomes COMPLETED', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
try {
const input: UpdatePaymentStatusInput = {
paymentId: 'payment-1',
status: PaymentStatus.COMPLETED,
};
const existingPayment: Payment = {
id: 'payment-1',
type: PaymentType.SPONSORSHIP,
amount: 100,
platformFee: 5,
netAmount: 95,
payerId: 'payer-1',
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
status: PaymentStatus.PENDING,
createdAt: new Date('2024-12-31T00:00:00.000Z'),
};
paymentRepository.findById.mockResolvedValue(existingPayment);
paymentRepository.update.mockImplementation(async (p: Payment) => ({ ...p }));
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(paymentRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'payment-1',
status: PaymentStatus.COMPLETED,
completedAt: new Date('2025-01-01T00:00:00.000Z'),
}),
);
const savedPayment = paymentRepository.update.mock.results[0]?.value;
await expect(savedPayment).resolves.toEqual(
expect.objectContaining({
id: 'payment-1',
status: PaymentStatus.COMPLETED,
completedAt: new Date('2025-01-01T00:00:00.000Z'),
}),
);
const presentedPayment = (output.present.mock.calls[0]?.[0] as { payment: Payment }).payment;
expect(presentedPayment.status).toBe(PaymentStatus.COMPLETED);
expect(presentedPayment.completedAt).toEqual(new Date('2025-01-01T00:00:00.000Z'));
} finally {
vi.useRealTimers();
}
});
it('preserves completedAt when status is not COMPLETED', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
try {
const input: UpdatePaymentStatusInput = {
paymentId: 'payment-1',
status: PaymentStatus.FAILED,
};
const existingCompletedAt = new Date('2025-01-01T00:00:00.000Z');
const existingPayment: Payment = {
id: 'payment-1',
type: PaymentType.SPONSORSHIP,
amount: 100,
platformFee: 5,
netAmount: 95,
payerId: 'payer-1',
payerType: PayerType.SPONSOR,
leagueId: 'league-1',
status: PaymentStatus.COMPLETED,
createdAt: new Date('2024-12-31T00:00:00.000Z'),
completedAt: existingCompletedAt,
};
paymentRepository.findById.mockResolvedValue(existingPayment);
paymentRepository.update.mockImplementation(async (p: Payment) => ({ ...p }));
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(paymentRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'payment-1',
status: PaymentStatus.FAILED,
completedAt: existingCompletedAt,
}),
);
const presentedPayment = (output.present.mock.calls[0]?.[0] as { payment: Payment }).payment;
expect(presentedPayment.status).toBe(PaymentStatus.FAILED);
expect(presentedPayment.completedAt).toEqual(existingCompletedAt);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { UpsertMembershipFeeUseCase, type UpsertMembershipFeeInput } from './UpsertMembershipFeeUseCase';
import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository';
import { MembershipFeeType, type MembershipFee } from '../../domain/entities/MembershipFee';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('UpsertMembershipFeeUseCase', () => {
let membershipFeeRepository: {
findByLeagueId: Mock;
create: Mock;
update: Mock;
};
let output: {
present: Mock;
};
let useCase: UpsertMembershipFeeUseCase;
beforeEach(() => {
membershipFeeRepository = {
findByLeagueId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new UpsertMembershipFeeUseCase(
membershipFeeRepository as unknown as IMembershipFeeRepository,
output as unknown as UseCaseOutputPort<unknown>,
);
});
it('creates a fee when none exists and presents it', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
vi.spyOn(Math, 'random').mockReturnValue(0.123456789);
try {
const input: UpsertMembershipFeeInput = {
leagueId: 'league-1',
type: MembershipFeeType.SEASON,
amount: 100,
};
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
membershipFeeRepository.create.mockImplementation(async (fee: MembershipFee) => ({ ...fee }));
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^fee-1735689600000-[a-z0-9]{9}$/),
leagueId: 'league-1',
type: MembershipFeeType.SEASON,
amount: 100,
enabled: true,
createdAt: new Date('2025-01-01T00:00:00.000Z'),
updatedAt: new Date('2025-01-01T00:00:00.000Z'),
}),
);
const createdFee = (output.present.mock.calls[0]?.[0] as { fee: MembershipFee }).fee;
expect(createdFee.enabled).toBe(true);
expect(createdFee.amount).toBe(100);
} finally {
vi.useRealTimers();
}
});
it('updates an existing fee and sets enabled=false when amount is 0', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-02T00:00:00.000Z'));
try {
const input: UpsertMembershipFeeInput = {
leagueId: 'league-1',
seasonId: 'season-2',
type: MembershipFeeType.MONTHLY,
amount: 0,
};
const existingFee: MembershipFee = {
id: 'fee-1',
leagueId: 'league-1',
seasonId: 'season-1',
type: MembershipFeeType.SEASON,
amount: 100,
enabled: true,
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
};
membershipFeeRepository.findByLeagueId.mockResolvedValue(existingFee);
membershipFeeRepository.update.mockImplementation(async (fee: MembershipFee) => ({ ...fee }));
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(membershipFeeRepository.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'fee-1',
leagueId: 'league-1',
seasonId: 'season-2',
type: MembershipFeeType.MONTHLY,
amount: 0,
enabled: false,
updatedAt: new Date('2025-01-02T00:00:00.000Z'),
}),
);
const updatedFee = (output.present.mock.calls[0]?.[0] as { fee: MembershipFee }).fee;
expect(updatedFee.enabled).toBe(false);
expect(updatedFee.amount).toBe(0);
expect(updatedFee.seasonId).toBe('season-2');
expect(updatedFee.type).toBe(MembershipFeeType.MONTHLY);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import * as useCases from './index';
describe('payments use-cases barrel exports', () => {
it('re-exports all expected use cases', () => {
const exported = useCases as unknown as Record<string, unknown>;
const expectedExports = [
'AwardPrizeUseCase',
'CreatePaymentUseCase',
'CreatePrizeUseCase',
'DeletePrizeUseCase',
'GetMembershipFeesUseCase',
'GetPaymentsUseCase',
'GetPrizesUseCase',
'GetSponsorBillingUseCase',
'GetWalletUseCase',
'ProcessWalletTransactionUseCase',
'UpdateMemberPaymentUseCase',
'UpdatePaymentStatusUseCase',
'UpsertMembershipFeeUseCase',
];
for (const name of expectedExports) {
expect(exported[name], `missing export: ${name}`).toBeDefined();
}
});
});