From a87cf27fb961b1d02a21b2143210be0e9bc63438 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 20 Dec 2025 11:02:15 +0100 Subject: [PATCH] resolve todos in core --- .../components/leagues/LeagueHeader.tsx | 49 +----- .../components/leagues/LeagueMembers.tsx | 2 + apps/website/components/profile/UserPill.tsx | 12 +- .../SponsorshipRejectionNotificationPort.ts | 16 ++ .../GetDriversLeaderboardUseCase.test.ts | 118 ++++++------- .../use-cases/GetDriversLeaderboardUseCase.ts | 13 +- .../use-cases/GetLeagueWalletUseCase.test.ts | 163 ++++++++++++++++++ .../use-cases/GetLeagueWalletUseCase.ts | 154 +++++++++-------- .../RejectSponsorshipRequestUseCase.test.ts | 53 +++++- .../RejectSponsorshipRequestUseCase.ts | 15 +- 10 files changed, 396 insertions(+), 199 deletions(-) create mode 100644 core/racing/application/ports/output/SponsorshipRejectionNotificationPort.ts create mode 100644 core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts 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,