diff --git a/apps/website/components/leagues/LeagueHeader.tsx b/apps/website/components/leagues/LeagueHeader.tsx
index 9a1583ac7..ee3ba7e66 100644
--- a/apps/website/components/leagues/LeagueHeader.tsx
+++ b/apps/website/components/leagues/LeagueHeader.tsx
@@ -1,14 +1,13 @@
'use client';
-import React, { useEffect, useMemo, useState } from 'react';
-import Link from 'next/link';
-import Image from 'next/image';
import MembershipStatus from '@/components/leagues/MembershipStatus';
-import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
+import Image from 'next/image';
+import { useEffect, useState } from 'react';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
-import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
+
+// TODO EntityMapper is legacy. Must use ´useServices` hook.
// Main sponsor info for "by XYZ" display
interface MainSponsorInfo {
@@ -31,7 +30,6 @@ export default function LeagueHeader({
leagueName,
description,
ownerId,
- ownerName,
mainSponsor,
}: LeagueHeaderProps) {
const imageService = getImageService();
@@ -60,45 +58,6 @@ export default function LeagueHeader({
};
}, [ownerId]);
- const ownerSummary = useMemo(() => {
- if (!ownerDriver) {
- return null;
- }
-
- const stats = getDriverStats(ownerDriver.id);
- const allRankings = getAllDriverRankings();
-
- let rating: number | null = stats?.rating ?? null;
- let rank: number | null = null;
-
- if (stats) {
- if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
- rank = stats.overallRank;
- } else {
- const indexInGlobal = allRankings.findIndex(
- (stat) => stat.driverId === stats.driverId,
- );
- if (indexInGlobal !== -1) {
- rank = indexInGlobal + 1;
- }
- }
-
- if (rating === null) {
- const globalEntry = allRankings.find(
- (stat) => stat.driverId === stats.driverId,
- );
- if (globalEntry) {
- rating = globalEntry.rating;
- }
- }
- }
-
- return {
- driver: ownerDriver,
- rating,
- rank,
- };
- }, [ownerDriver]);
return (
diff --git a/apps/website/components/leagues/LeagueMembers.tsx b/apps/website/components/leagues/LeagueMembers.tsx
index dff80acb7..7f76b8a89 100644
--- a/apps/website/components/leagues/LeagueMembers.tsx
+++ b/apps/website/components/leagues/LeagueMembers.tsx
@@ -11,6 +11,8 @@ import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
import { useCallback, useEffect, useState } from 'react';
+// TODO EntityMapper is legacy. Must use ´useServices` hook.
+
interface LeagueMembersProps {
leagueId: string;
onRemoveMember?: (driverId: string) => void;
diff --git a/apps/website/components/profile/UserPill.tsx b/apps/website/components/profile/UserPill.tsx
index 7ffe49772..ad12e1dea 100644
--- a/apps/website/components/profile/UserPill.tsx
+++ b/apps/website/components/profile/UserPill.tsx
@@ -11,6 +11,8 @@ import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { EntityMappers } from '@core/racing/application/mappers/EntityMappers';
+// TODO EntityMapper is legacy. Must use ´useServices` hook.
+
// Hook to detect sponsor mode
function useSponsorMode(): boolean {
const [isSponsor, setIsSponsor] = useState(false);
@@ -81,20 +83,12 @@ function SponsorSummaryPill({
}
export default function UserPill() {
- const { session, login } = useAuth();
+ const { session } = useAuth();
const [driver, setDriver] = useState(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const isSponsorMode = useSponsorMode();
const shouldReduceMotion = useReducedMotion();
- const user = session?.user as
- | {
- id: string;
- displayName?: string;
- primaryDriverId?: string | null;
- avatarUrl?: string | null;
- }
- | undefined;
const primaryDriverId = useEffectiveDriverId();
diff --git a/core/racing/application/ports/output/SponsorshipRejectionNotificationPort.ts b/core/racing/application/ports/output/SponsorshipRejectionNotificationPort.ts
new file mode 100644
index 000000000..2c2c2d7ff
--- /dev/null
+++ b/core/racing/application/ports/output/SponsorshipRejectionNotificationPort.ts
@@ -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;
+}
diff --git a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts
index 316fec8d1..25bca3124 100644
--- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts
+++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts
@@ -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(
diff --git a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts
index 1544197aa..be429b30d 100644
--- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts
+++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts
@@ -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],
};
diff --git a/core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts b/core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts
new file mode 100644
index 000000000..ea682646b
--- /dev/null
+++ b/core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts
@@ -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',
+ });
+ });
+});
diff --git a/core/racing/application/use-cases/GetLeagueWalletUseCase.ts b/core/racing/application/use-cases/GetLeagueWalletUseCase.ts
index 40cdc0640..d54c462cb 100644
--- a/core/racing/application/use-cases/GetLeagueWalletUseCase.ts
+++ b/core/racing/application/use-cases/GetLeagueWalletUseCase.ts
@@ -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>> {
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';
+ }
+ }
}
\ No newline at end of file
diff --git a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts
index 71a614c97..fb3b34515 100644
--- a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts
+++ b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts
@@ -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,
+ });
});
});
\ No newline at end of file
diff --git a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts
index c34cbd398..1220ede60 100644
--- a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts
+++ b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts
@@ -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>> {
@@ -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,