resolve todos in core

This commit is contained in:
2025-12-20 11:02:15 +01:00
parent 7bbad511e2
commit a87cf27fb9
10 changed files with 396 additions and 199 deletions

View File

@@ -0,0 +1,16 @@
export interface SponsorshipRejectionNotificationPayload {
requestId: string;
sponsorId: string;
entityType: string;
entityId: string;
tier: string;
offeredAmountCents: number;
currency: string;
rejectedAt: Date;
rejectedBy: string;
rejectionReason?: string;
}
export interface SponsorshipRejectionNotificationPort {
notifySponsorshipRequestRejected(payload: SponsorshipRejectionNotificationPayload): Promise<void>;
}

View File

@@ -69,43 +69,43 @@ describe('GetDriversLeaderboardUseCase', () => {
return Promise.resolve({ avatarUrl: 'avatar-default' });
});
const result = await useCase.execute();
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 10,
wins: 5,
podiums: 7,
isActive: true,
rank: 1,
avatarUrl: 'avatar-driver1',
},
{
id: 'driver2',
name: 'Driver Two',
rating: 2400,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 8,
wins: 3,
podiums: 4,
isActive: true,
rank: 2,
avatarUrl: 'avatar-driver2',
},
],
totalRaces: 18,
totalWins: 8,
activeCount: 2,
});
});
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'advanced',
nationality: 'US',
racesCompleted: 10,
wins: 5,
podiums: 7,
isActive: true,
rank: 1,
avatarUrl: 'avatar-driver1',
},
{
id: 'driver2',
name: 'Driver Two',
rating: 2400,
skillLevel: 'intermediate',
nationality: 'US',
racesCompleted: 8,
wins: 3,
podiums: 4,
isActive: true,
rank: 2,
avatarUrl: 'avatar-driver2',
},
],
totalRaces: 18,
totalWins: 8,
activeCount: 2,
});
});
it('should return empty result when no drivers', async () => {
const useCase = new GetDriversLeaderboardUseCase(
@@ -147,30 +147,30 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverStatsGetDriverStats.mockReturnValue(null);
mockGetDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver1' });
const result = await useCase.execute();
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 0,
wins: 0,
podiums: 0,
isActive: true,
rank: 1,
avatarUrl: 'avatar-driver1',
},
],
totalRaces: 0,
totalWins: 0,
activeCount: 1,
});
});
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'advanced',
nationality: 'US',
racesCompleted: 0,
wins: 0,
podiums: 0,
isActive: false,
rank: 1,
avatarUrl: 'avatar-driver1',
},
],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
});
});
it('should return error when repository throws', async () => {
const useCase = new GetDriversLeaderboardUseCase(

View File

@@ -4,7 +4,7 @@ import type { IDriverStatsService } from '../../domain/services/IDriverStatsServ
import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { DriversLeaderboardOutputPort, DriverLeaderboardItemOutputPort } from '../ports/output/DriversLeaderboardOutputPort';
import type { SkillLevel } from '../../domain/services/SkillLevelService';
import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -40,17 +40,20 @@ export class GetDriversLeaderboardUseCase
const driverItems: DriverLeaderboardItemOutputPort[] = drivers.map(driver => {
const ranking = rankings.find(r => r.driverId === driver.id);
const stats = this.driverStatsService.getDriverStats(driver.id);
const rating = ranking?.rating ?? 0;
const racesCompleted = stats?.totalRaces ?? 0;
const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating);
return {
id: driver.id,
name: driver.name.value,
rating: ranking?.rating ?? 0,
skillLevel: 'Pro' as SkillLevel, // TODO: map from domain
rating,
skillLevel,
nationality: driver.country.value,
racesCompleted: stats?.totalRaces ?? 0,
racesCompleted,
wins: stats?.wins ?? 0,
podiums: stats?.podiums ?? 0,
isActive: true, // TODO: determine from domain
isActive: racesCompleted > 0,
rank: ranking?.overallRank ?? 0,
avatarUrl: avatarUrls[driver.id],
};

View File

@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueWalletUseCase } from './GetLeagueWalletUseCase';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
import { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet';
import { Money } from '../../domain/value-objects/Money';
import { Transaction } from '../../domain/entities/league-wallet/Transaction';
import { TransactionId } from '../../domain/entities/league-wallet/TransactionId';
import { LeagueWalletId } from '../../domain/entities/league-wallet/LeagueWalletId';
describe('GetLeagueWalletUseCase', () => {
let leagueWalletRepository: {
findByLeagueId: Mock;
};
let transactionRepository: {
findByWalletId: Mock;
};
let useCase: GetLeagueWalletUseCase;
beforeEach(() => {
leagueWalletRepository = {
findByLeagueId: vi.fn(),
};
transactionRepository = {
findByWalletId: vi.fn(),
};
useCase = new GetLeagueWalletUseCase(
leagueWalletRepository as unknown as ILeagueWalletRepository,
transactionRepository as unknown as ITransactionRepository,
);
});
it('returns mapped wallet data when wallet exists', async () => {
const leagueId = 'league-1';
const balance = Money.create(2450, 'USD');
const wallet = LeagueWallet.create({
id: 'wallet-1',
leagueId,
balance,
});
leagueWalletRepository.findByLeagueId.mockResolvedValue(wallet);
const sponsorshipTx = Transaction.create({
id: TransactionId.create('txn-1'),
walletId: LeagueWalletId.create(wallet.id.toString()),
type: 'sponsorship_payment',
amount: Money.create(1200, 'USD'),
description: 'Main Sponsor - TechCorp',
metadata: {},
}).complete();
const membershipTx = Transaction.create({
id: TransactionId.create('txn-2'),
walletId: LeagueWalletId.create(wallet.id.toString()),
type: 'membership_payment',
amount: Money.create(1600, 'USD'),
description: 'Season Fee - 32 drivers',
metadata: {},
}).complete();
const withdrawalTx = Transaction.create({
id: TransactionId.create('txn-3'),
walletId: LeagueWalletId.create(wallet.id.toString()),
type: 'withdrawal',
amount: Money.create(430, 'USD'),
description: 'Bank Transfer - Season 1 Payout',
metadata: {},
}).complete();
const pendingPrizeTx = Transaction.create({
id: TransactionId.create('txn-4'),
walletId: LeagueWalletId.create(wallet.id.toString()),
type: 'prize_payout',
amount: Money.create(150, 'USD'),
description: 'Championship Prize Pool (reserved)',
metadata: {},
});
const refundTx = Transaction.create({
id: TransactionId.create('txn-5'),
walletId: LeagueWalletId.create(wallet.id.toString()),
type: 'refund',
amount: Money.create(100, 'USD'),
description: 'Refund for cancelled sponsorship',
metadata: {},
});
const transactions = [
sponsorshipTx,
membershipTx,
withdrawalTx,
pendingPrizeTx,
refundTx,
];
transactionRepository.findByWalletId.mockResolvedValue(transactions);
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.balance).toBe(balance.amount);
expect(viewModel.currency).toBe(balance.currency);
const expectedTotalRevenue =
sponsorshipTx.amount.amount +
membershipTx.amount.amount +
pendingPrizeTx.amount.amount;
const expectedTotalFees =
sponsorshipTx.platformFee.amount +
membershipTx.platformFee.amount +
pendingPrizeTx.platformFee.amount;
const expectedTotalWithdrawals = withdrawalTx.netAmount.amount;
const expectedPendingPayouts = pendingPrizeTx.netAmount.amount;
expect(viewModel.totalRevenue).toBe(expectedTotalRevenue);
expect(viewModel.totalFees).toBe(expectedTotalFees);
expect(viewModel.totalWithdrawals).toBe(expectedTotalWithdrawals);
expect(viewModel.pendingPayouts).toBe(expectedPendingPayouts);
expect(viewModel.transactions).toHaveLength(transactions.length);
expect(viewModel.transactions[0]!.id).toBe(transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!.id.toString());
expect(viewModel.transactions.find(t => t.type === 'sponsorship')).toBeTruthy();
expect(viewModel.transactions.find(t => t.type === 'membership')).toBeTruthy();
expect(viewModel.transactions.find(t => t.type === 'withdrawal')).toBeTruthy();
expect(viewModel.transactions.find(t => t.type === 'prize')).toBeTruthy();
});
it('returns error result when wallet is missing', async () => {
const leagueId = 'league-missing';
leagueWalletRepository.findByLeagueId.mockResolvedValue(null);
const result = await useCase.execute({ leagueId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Wallet not found',
});
});
it('returns repository error when repository throws', async () => {
const leagueId = 'league-1';
leagueWalletRepository.findByLeagueId.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ leagueId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch league wallet',
});
});
});

View File

@@ -1,13 +1,14 @@
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
import type { GetLeagueWalletOutputPort } from '../ports/output/GetLeagueWalletOutputPort';
import type { GetLeagueWalletOutputPort, WalletTransactionOutputPort } from '../ports/output/GetLeagueWalletOutputPort';
import type { TransactionType } from '../../domain/entities/league-wallet/Transaction';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetLeagueWalletUseCaseParams {
leagueId: string;
}
/**
* Use Case for retrieving league wallet information.
*/
@@ -16,84 +17,89 @@ export class GetLeagueWalletUseCase {
private readonly leagueWalletRepository: ILeagueWalletRepository,
private readonly transactionRepository: ITransactionRepository,
) {}
async execute(
params: GetLeagueWalletUseCaseParams,
): Promise<Result<GetLeagueWalletOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
// For now, return mock data to emulate previous state
// TODO: Implement full domain logic when wallet entities are properly seeded
const mockWallet: GetLeagueWalletOutputPort = {
balance: 2450.00,
currency: 'USD',
totalRevenue: 3200.00,
totalFees: 320.00,
totalWithdrawals: 430.00,
pendingPayouts: 150.00,
canWithdraw: false,
withdrawalBlockReason: 'Season 2 is still active. Withdrawals are available after season completion.',
transactions: [
{
id: 'txn-1',
type: 'sponsorship',
description: 'Main Sponsor - TechCorp',
amount: 1200.00,
fee: 120.00,
netAmount: 1080.00,
date: '2025-12-01T00:00:00.000Z',
status: 'completed',
reference: 'SP-2025-001',
},
{
id: 'txn-2',
type: 'sponsorship',
description: 'Secondary Sponsor - RaceFuel',
amount: 400.00,
fee: 40.00,
netAmount: 360.00,
date: '2025-12-01T00:00:00.000Z',
status: 'completed',
reference: 'SP-2025-002',
},
{
id: 'txn-3',
type: 'membership',
description: 'Season Fee - 32 drivers',
amount: 1600.00,
fee: 160.00,
netAmount: 1440.00,
date: '2025-11-15T00:00:00.000Z',
status: 'completed',
reference: 'MF-2025-032',
},
{
id: 'txn-4',
type: 'withdrawal',
description: 'Bank Transfer - Season 1 Payout',
amount: -430.00,
fee: 0,
netAmount: -430.00,
date: '2025-10-30T00:00:00.000Z',
status: 'completed',
reference: 'WD-2025-001',
},
{
id: 'txn-5',
type: 'prize',
description: 'Championship Prize Pool (reserved)',
amount: -150.00,
fee: 0,
netAmount: -150.00,
date: '2025-12-05T00:00:00.000Z',
status: 'pending',
reference: 'PZ-2025-001',
},
],
const wallet = await this.leagueWalletRepository.findByLeagueId(params.leagueId);
if (!wallet) {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Wallet not found' });
}
const transactions = await this.transactionRepository.findByWalletId(wallet.id.toString());
let totalRevenue = 0;
let totalFees = 0;
let totalWithdrawals = 0;
let pendingPayouts = 0;
const transactionViewModels: WalletTransactionOutputPort[] = transactions
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map(transaction => {
const amount = transaction.amount.amount;
const fee = transaction.platformFee.amount;
const netAmount = transaction.netAmount.amount;
if (
transaction.type === 'sponsorship_payment' ||
transaction.type === 'membership_payment' ||
transaction.type === 'prize_payout'
) {
totalRevenue += amount;
totalFees += fee;
}
if (transaction.type === 'withdrawal' && transaction.status === 'completed') {
totalWithdrawals += netAmount;
}
if (transaction.type === 'prize_payout' && transaction.status === 'pending') {
pendingPayouts += netAmount;
}
return {
id: transaction.id.toString(),
type: this.mapTransactionType(transaction.type),
description: transaction.description ?? '',
amount,
fee,
netAmount,
date: transaction.createdAt.toISOString(),
status: transaction.status === 'cancelled' ? 'failed' : transaction.status,
};
});
const output: GetLeagueWalletOutputPort = {
balance: wallet.balance.amount,
currency: wallet.balance.currency,
totalRevenue,
totalFees,
totalWithdrawals,
pendingPayouts,
canWithdraw: true,
transactions: transactionViewModels,
};
return Result.ok(mockWallet);
return Result.ok(output);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league wallet' });
}
}
private mapTransactionType(type: TransactionType): WalletTransactionOutputPort['type'] {
switch (type) {
case 'sponsorship_payment':
return 'sponsorship';
case 'membership_payment':
return 'membership';
case 'prize_payout':
return 'prize';
case 'withdrawal':
return 'withdrawal';
case 'refund':
return 'sponsorship';
}
}
}

View File

@@ -5,11 +5,14 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
describe('RejectSponsorshipRequestUseCase', () => {
let useCase: RejectSponsorshipRequestUseCase;
let sponsorshipRequestRepo: { findById: Mock; update: Mock };
let notificationPort: { notifySponsorshipRequestRejected: Mock };
beforeEach(() => {
sponsorshipRequestRepo = { findById: vi.fn(), update: vi.fn() };
notificationPort = { notifySponsorshipRequestRejected: vi.fn() };
useCase = new RejectSponsorshipRequestUseCase(
sponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
notificationPort as any,
);
});
@@ -48,14 +51,20 @@ describe('RejectSponsorshipRequestUseCase', () => {
});
});
it('should reject the request successfully', async () => {
it('should reject the request successfully and notify sponsor with reason', async () => {
const respondedAt = new Date('2023-01-01T00:00:00Z');
const mockRequest = {
id: 'request-1',
sponsorId: 'sponsor-1',
entityType: 'season',
entityId: 'season-1',
tier: 'main',
offeredAmount: { amount: 1000, currency: 'USD' },
status: 'pending',
isPending: vi.fn().mockReturnValue(true),
reject: vi.fn().mockReturnValue({
id: 'request-1',
respondedAt: new Date('2023-01-01T00:00:00Z'),
respondedAt,
rejectionReason: 'Not interested',
}),
};
@@ -72,20 +81,39 @@ describe('RejectSponsorshipRequestUseCase', () => {
expect(result.unwrap()).toEqual({
requestId: 'request-1',
status: 'rejected',
rejectedAt: new Date('2023-01-01T00:00:00Z'),
rejectedAt: respondedAt,
reason: 'Not interested',
});
expect(sponsorshipRequestRepo.update).toHaveBeenCalledWith(mockRequest.reject());
expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledTimes(1);
expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledWith({
requestId: 'request-1',
sponsorId: 'sponsor-1',
entityType: 'season',
entityId: 'season-1',
tier: 'main',
offeredAmountCents: 1000,
currency: 'USD',
rejectedAt: respondedAt,
rejectedBy: 'driver-1',
rejectionReason: 'Not interested',
});
});
it('should reject the request successfully without reason', async () => {
it('should reject the request successfully and notify sponsor without reason', async () => {
const respondedAt = new Date('2023-01-01T00:00:00Z');
const mockRequest = {
id: 'request-1',
sponsorId: 'sponsor-1',
entityType: 'season',
entityId: 'season-1',
tier: 'main',
offeredAmount: { amount: 1000, currency: 'USD' },
status: 'pending',
isPending: vi.fn().mockReturnValue(true),
reject: vi.fn().mockReturnValue({
id: 'request-1',
respondedAt: new Date('2023-01-01T00:00:00Z'),
respondedAt,
rejectionReason: undefined,
}),
};
@@ -101,8 +129,21 @@ describe('RejectSponsorshipRequestUseCase', () => {
expect(result.unwrap()).toEqual({
requestId: 'request-1',
status: 'rejected',
rejectedAt: new Date('2023-01-01T00:00:00Z'),
rejectedAt: respondedAt,
});
expect(sponsorshipRequestRepo.update).toHaveBeenCalledWith(mockRequest.reject());
expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledTimes(1);
expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledWith({
requestId: 'request-1',
sponsorId: 'sponsor-1',
entityType: 'season',
entityId: 'season-1',
tier: 'main',
offeredAmountCents: 1000,
currency: 'USD',
rejectedAt: respondedAt,
rejectedBy: 'driver-1',
rejectionReason: undefined,
});
});
});

View File

@@ -7,6 +7,7 @@
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { SponsorshipRejectionNotificationPort } from '../ports/output/SponsorshipRejectionNotificationPort';
export interface RejectSponsorshipRequestDTO {
requestId: string;
@@ -24,6 +25,7 @@ export interface RejectSponsorshipRequestResultDTO {
export class RejectSponsorshipRequestUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorshipRejectionNotificationPort: SponsorshipRejectionNotificationPort,
) {}
async execute(dto: RejectSponsorshipRequestDTO): Promise<Result<RejectSponsorshipRequestResultDTO, ApplicationErrorCode<'SPONSORSHIP_REQUEST_NOT_FOUND' | 'SPONSORSHIP_REQUEST_NOT_PENDING'>>> {
@@ -41,7 +43,18 @@ export class RejectSponsorshipRequestUseCase {
const rejectedRequest = request.reject(dto.respondedBy, dto.reason);
await this.sponsorshipRequestRepo.update(rejectedRequest);
// TODO: In a real implementation, notify the sponsor
await this.sponsorshipRejectionNotificationPort.notifySponsorshipRequestRejected({
requestId: request.id,
sponsorId: request.sponsorId,
entityType: request.entityType,
entityId: request.entityId,
tier: request.tier,
offeredAmountCents: request.offeredAmount.amount,
currency: request.offeredAmount.currency,
rejectedAt: rejectedRequest.respondedAt!,
rejectedBy: dto.respondedBy,
rejectionReason: rejectedRequest.rejectionReason,
});
return Result.ok({
requestId: rejectedRequest.id,