From 92be9d2e1b32adef26b0cb751a776805e6be6e48 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 20 Dec 2025 12:55:07 +0100 Subject: [PATCH] resolve todos in website --- .eslintrc.json | 12 + .../GetAnalyticsMetricsUseCase.test.ts | 77 ++++ .../use-cases/GetDashboardDataUseCase.test.ts | 32 ++ .../use-cases/GetDashboardDataUseCase.ts | 2 +- .../use-cases/GetEntityAnalyticsQuery.test.ts | 96 +++++ .../use-cases/GetEntityAnalyticsQuery.ts | 3 +- .../use-cases/RecordEngagementUseCase.test.ts | 75 ++++ .../use-cases/RecordPageViewUseCase.test.ts | 74 ++++ core/analytics/index.ts | 5 +- .../GetCurrentUserSessionUseCase.test.ts | 54 +++ .../use-cases/GetUserUseCase.test.ts | 48 +++ .../HandleAuthCallbackUseCase.test.ts | 65 +++ .../use-cases/LoginUseCase.test.ts | 81 ++++ .../use-cases/LoginWithEmailUseCase.test.ts | 108 +++++ .../use-cases/LogoutUseCase.test.ts | 28 ++ .../use-cases/SignupUseCase.test.ts | 71 ++++ .../use-cases/SignupWithEmailUseCase.test.ts | 121 ++++++ .../use-cases/StartAuthUseCase.test.ts | 35 ++ .../CreateAchievementUseCase.test.ts | 53 +++ core/identity/index.ts | 5 +- .../GetLeagueStandingsUseCaseImpl.test.ts | 77 ++++ .../use-cases/DeleteMediaUseCase.test.ts | 97 +++++ .../use-cases/GetAvatarUseCase.test.ts | 93 +++++ .../use-cases/GetMediaUseCase.test.ts | 102 +++++ .../RequestAvatarGenerationUseCase.test.ts | 126 ++++++ .../use-cases/SelectAvatarUseCase.test.ts | 76 ++++ .../GetUnreadNotificationsUseCase.test.ts | 64 +++ .../GetUnreadNotificationsUseCase.ts | 3 +- .../MarkNotificationReadUseCase.test.ts | 83 ++++ .../use-cases/MarkNotificationReadUseCase.ts | 3 +- .../NotificationPreferencesUseCases.test.ts | 165 ++++++++ .../NotificationPreferencesUseCases.ts | 3 +- core/notifications/infrastructure/index.ts | 4 +- .../GetMembershipFeesUseCase.test.ts | 133 +++++++ .../services/SeasonApplicationService.ts | 374 ++++++++++++++++++ .../use-cases/ApplyForSponsorshipUseCase.ts | 3 +- .../use-cases/ApplyPenaltyUseCase.ts | 3 +- .../use-cases/CancelRaceUseCase.ts | 3 +- .../CloseRaceEventStewardingUseCase.ts | 3 +- ...CreateLeagueWithSeasonAndScoringUseCase.ts | 3 +- .../use-cases/CreateSponsorUseCase.ts | 3 +- .../use-cases/CreateTeamUseCase.ts | 3 +- .../use-cases/GetTeamJoinRequestsUseCase.ts | 3 +- .../use-cases/GetTeamMembersUseCase.ts | 3 +- .../use-cases/GetTeamsLeaderboardUseCase.ts | 3 +- .../use-cases/GetTotalDriversUseCase.ts | 3 +- .../use-cases/GetTotalLeaguesUseCase.ts | 3 +- .../use-cases/GetTotalRacesUseCase.ts | 3 +- .../use-cases/JoinLeagueUseCase.ts | 3 +- .../use-cases/QuickPenaltyUseCase.ts | 3 +- .../RejectLeagueJoinRequestUseCase.test.ts | 38 +- .../use-cases/ReopenRaceUseCase.ts | 3 +- .../racing/domain/types/ChampionshipConfig.ts | 8 +- core/racing/index.ts | 6 +- .../use-cases/GetCurrentUserSocialUseCase.ts | 3 +- .../use-cases/GetUserFeedUseCase.ts | 3 +- 56 files changed, 2476 insertions(+), 78 deletions(-) create mode 100644 core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts create mode 100644 core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts create mode 100644 core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts create mode 100644 core/analytics/application/use-cases/RecordEngagementUseCase.test.ts create mode 100644 core/analytics/application/use-cases/RecordPageViewUseCase.test.ts create mode 100644 core/identity/application/use-cases/GetCurrentUserSessionUseCase.test.ts create mode 100644 core/identity/application/use-cases/GetUserUseCase.test.ts create mode 100644 core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts create mode 100644 core/identity/application/use-cases/LoginUseCase.test.ts create mode 100644 core/identity/application/use-cases/LoginWithEmailUseCase.test.ts create mode 100644 core/identity/application/use-cases/LogoutUseCase.test.ts create mode 100644 core/identity/application/use-cases/SignupUseCase.test.ts create mode 100644 core/identity/application/use-cases/SignupWithEmailUseCase.test.ts create mode 100644 core/identity/application/use-cases/StartAuthUseCase.test.ts create mode 100644 core/identity/application/use-cases/achievement/CreateAchievementUseCase.test.ts create mode 100644 core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.test.ts create mode 100644 core/media/application/use-cases/DeleteMediaUseCase.test.ts create mode 100644 core/media/application/use-cases/GetAvatarUseCase.test.ts create mode 100644 core/media/application/use-cases/GetMediaUseCase.test.ts create mode 100644 core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts create mode 100644 core/media/application/use-cases/SelectAvatarUseCase.test.ts create mode 100644 core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts create mode 100644 core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts create mode 100644 core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts create mode 100644 core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts create mode 100644 core/racing/application/services/SeasonApplicationService.ts diff --git a/.eslintrc.json b/.eslintrc.json index dd58c23e3..510787568 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -210,6 +210,18 @@ } ] } + }, + { + "files": ["core/**/*.ts"], + "rules": { + "no-restricted-syntax": [ + "error", + { + "selector": "ExportDefaultDeclaration", + "message": "Default exports are forbidden. Use named exports instead." + } + ] + } } ] } \ No newline at end of file diff --git a/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts new file mode 100644 index 000000000..9e6f44e64 --- /dev/null +++ b/core/analytics/application/use-cases/GetAnalyticsMetricsUseCase.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput } from './GetAnalyticsMetricsUseCase'; +import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; +import type { Logger } from '@core/shared/application'; + +describe('GetAnalyticsMetricsUseCase', () => { + let pageViewRepository: { + save: Mock; + findById: Mock; + findByEntityId: Mock; + findBySessionId: Mock; + countByEntityId: Mock; + getUniqueVisitorsCount: Mock; + getAverageSessionDuration: Mock; + getBounceRate: Mock; + }; + let logger: Logger; + let useCase: GetAnalyticsMetricsUseCase; + + beforeEach(() => { + pageViewRepository = { + save: vi.fn(), + findById: vi.fn(), + findByEntityId: vi.fn(), + findBySessionId: vi.fn(), + countByEntityId: vi.fn(), + getUniqueVisitorsCount: vi.fn(), + getAverageSessionDuration: vi.fn(), + getBounceRate: vi.fn(), + } as unknown as IPageViewRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new GetAnalyticsMetricsUseCase( + pageViewRepository as unknown as IPageViewRepository, + logger, + ); + }); + + it('returns default metrics and logs retrieval when no input is provided', async () => { + const result = await useCase.execute(); + + expect(result).toEqual({ + pageViews: 0, + uniqueVisitors: 0, + averageSessionDuration: 0, + bounceRate: 0, + }); + + expect((logger.info as unknown as Mock)).toHaveBeenCalled(); + }); + + it('uses provided date range and logs error when execute throws', async () => { + const input: GetAnalyticsMetricsInput = { + startDate: new Date('2024-01-01'), + endDate: new Date('2024-01-31'), + }; + + const erroringUseCase = new GetAnalyticsMetricsUseCase( + pageViewRepository as unknown as IPageViewRepository, + logger, + ); + + // Simulate an error by temporarily spying on logger.info to throw + (logger.info as unknown as Mock).mockImplementation(() => { + throw new Error('Logging failed'); + }); + + await expect(erroringUseCase.execute(input)).rejects.toThrow('Logging failed'); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); diff --git a/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts b/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts new file mode 100644 index 000000000..ad58572ab --- /dev/null +++ b/core/analytics/application/use-cases/GetDashboardDataUseCase.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from 'vitest'; +import { GetDashboardDataUseCase } from './GetDashboardDataUseCase'; +import type { Logger } from '@core/shared/application'; + +describe('GetDashboardDataUseCase', () => { + let logger: Logger; + let useCase: GetDashboardDataUseCase; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new GetDashboardDataUseCase(logger); + }); + + it('returns placeholder dashboard metrics and logs retrieval', async () => { + const result = await useCase.execute(); + + expect(result).toEqual({ + totalUsers: 0, + activeUsers: 0, + totalRaces: 0, + totalLeagues: 0, + }); + + expect((logger.info as unknown as ReturnType)).toHaveBeenCalled(); + }); +}); diff --git a/core/analytics/application/use-cases/GetDashboardDataUseCase.ts b/core/analytics/application/use-cases/GetDashboardDataUseCase.ts index 38b7cde87..b3fe6d0c3 100644 --- a/core/analytics/application/use-cases/GetDashboardDataUseCase.ts +++ b/core/analytics/application/use-cases/GetDashboardDataUseCase.ts @@ -14,7 +14,7 @@ export class GetDashboardDataUseCase { private readonly logger: Logger, ) {} - async execute(_input: GetDashboardDataInput = {}): Promise { + async execute(): Promise { try { // Placeholder implementation - would need repositories from identity and racing domains const totalUsers = 0; diff --git a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts new file mode 100644 index 000000000..ecfc011f8 --- /dev/null +++ b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetEntityAnalyticsQuery, type GetEntityAnalyticsInput } from './GetEntityAnalyticsQuery'; +import type { IPageViewRepository } from '../repositories/IPageViewRepository'; +import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; +import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository'; +import type { Logger } from '@core/shared/application'; +import type { EntityType } from '../../domain/types/PageView'; + +describe('GetEntityAnalyticsQuery', () => { + let pageViewRepository: { + countByEntityId: Mock; + countUniqueVisitors: Mock; + }; + let engagementRepository: { + getSponsorClicksForEntity: Mock; + }; + let snapshotRepository: IAnalyticsSnapshotRepository; + let logger: Logger; + let useCase: GetEntityAnalyticsQuery; + + beforeEach(() => { + pageViewRepository = { + countByEntityId: vi.fn(), + countUniqueVisitors: vi.fn(), + } as unknown as IPageViewRepository as any; + + engagementRepository = { + getSponsorClicksForEntity: vi.fn(), + } as unknown as IEngagementRepository as any; + + snapshotRepository = {} as IAnalyticsSnapshotRepository; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new GetEntityAnalyticsQuery( + pageViewRepository as unknown as IPageViewRepository, + engagementRepository as unknown as IEngagementRepository, + snapshotRepository, + logger, + ); + }); + + it('aggregates entity analytics and returns summary and trends', async () => { + const input: GetEntityAnalyticsInput = { + entityType: 'league' as EntityType, + entityId: 'league-1', + period: 'weekly', + }; + + pageViewRepository.countByEntityId + .mockResolvedValueOnce(100) // current period total page views + .mockResolvedValueOnce(150); // previous period full page views + + pageViewRepository.countUniqueVisitors + .mockResolvedValueOnce(40) // current period uniques + .mockResolvedValueOnce(60); // previous period full uniques + + engagementRepository.getSponsorClicksForEntity + .mockResolvedValueOnce(10) // current clicks + .mockResolvedValueOnce(5); // for engagement score + + const result = await useCase.execute(input); + + expect(result.entityId).toBe(input.entityId); + expect(result.entityType).toBe(input.entityType); + + expect(result.summary.totalPageViews).toBe(100); + expect(result.summary.uniqueVisitors).toBe(40); + expect(result.summary.sponsorClicks).toBe(10); + expect(typeof result.summary.engagementScore).toBe('number'); + expect(result.summary.exposureValue).toBeGreaterThan(0); + + expect(result.trends.pageViewsChange).toBeDefined(); + expect(result.trends.uniqueVisitorsChange).toBeDefined(); + + expect(result.period.start).toBeInstanceOf(Date); + expect(result.period.end).toBeInstanceOf(Date); + }); + + it('propagates repository errors', async () => { + const input: GetEntityAnalyticsInput = { + entityType: 'league' as EntityType, + entityId: 'league-1', + }; + + pageViewRepository.countByEntityId.mockRejectedValue(new Error('DB error')); + + await expect(useCase.execute(input)).rejects.toThrow('DB error'); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); diff --git a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts index b631986cc..f74f1599a 100644 --- a/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts +++ b/core/analytics/application/use-cases/GetEntityAnalyticsQuery.ts @@ -5,8 +5,7 @@ * Returns metrics formatted for display to sponsors and admins. */ -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { IPageViewRepository } from '../repositories/IPageViewRepository'; import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository'; diff --git a/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts b/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts new file mode 100644 index 000000000..d52cd808e --- /dev/null +++ b/core/analytics/application/use-cases/RecordEngagementUseCase.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { RecordEngagementUseCase, type RecordEngagementInput } from './RecordEngagementUseCase'; +import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository'; +import { EngagementEvent } from '../../domain/entities/EngagementEvent'; +import type { Logger } from '@core/shared/application'; +import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent'; + +describe('RecordEngagementUseCase', () => { + let engagementRepository: { + save: Mock; + }; + let logger: Logger; + let useCase: RecordEngagementUseCase; + + beforeEach(() => { + engagementRepository = { + save: vi.fn(), + } as unknown as IEngagementRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new RecordEngagementUseCase( + engagementRepository as unknown as IEngagementRepository, + logger, + ); + }); + + it('creates and saves an EngagementEvent and returns its id and weight', async () => { + const input: RecordEngagementInput = { + action: 'view' as EngagementAction, + entityType: 'league' as EngagementEntityType, + entityId: 'league-1', + actorId: 'driver-1', + actorType: 'driver', + sessionId: 'session-1', + metadata: { foo: 'bar' }, + }; + + engagementRepository.save.mockResolvedValue(undefined); + + const result = await useCase.execute(input); + + expect(engagementRepository.save).toHaveBeenCalledTimes(1); + const saved = (engagementRepository.save as unknown as Mock).mock.calls[0][0] as EngagementEvent; + + expect(saved).toBeInstanceOf(EngagementEvent); + expect(saved.id).toBeDefined(); + expect(saved.entityId).toBe(input.entityId); + expect(saved.entityType).toBe(input.entityType); + expect(result.eventId).toBe(saved.id); + expect(typeof result.engagementWeight).toBe('number'); + expect((logger.info as unknown as Mock)).toHaveBeenCalled(); + }); + + it('logs and rethrows when repository save fails', async () => { + const input: RecordEngagementInput = { + action: 'view' as EngagementAction, + entityType: 'league' as EngagementEntityType, + entityId: 'league-1', + actorType: 'anonymous', + sessionId: 'session-1', + }; + + const error = new Error('DB error'); + engagementRepository.save.mockRejectedValue(error); + + await expect(useCase.execute(input)).rejects.toThrow('DB error'); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); diff --git a/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts new file mode 100644 index 000000000..82e1be216 --- /dev/null +++ b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { RecordPageViewUseCase, type RecordPageViewInput } from './RecordPageViewUseCase'; +import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; +import { PageView } from '../../domain/entities/PageView'; +import type { Logger } from '@core/shared/application'; +import type { EntityType, VisitorType } from '../../domain/types/PageView'; + +describe('RecordPageViewUseCase', () => { + let pageViewRepository: { + save: Mock; + }; + let logger: Logger; + let useCase: RecordPageViewUseCase; + + beforeEach(() => { + pageViewRepository = { + save: vi.fn(), + } as unknown as IPageViewRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new RecordPageViewUseCase( + pageViewRepository as unknown as IPageViewRepository, + logger, + ); + }); + + it('creates and saves a PageView and returns its id', async () => { + const input: RecordPageViewInput = { + entityType: 'league' as EntityType, + entityId: 'league-1', + visitorId: 'visitor-1', + visitorType: 'anonymous' as VisitorType, + sessionId: 'session-1', + referrer: 'https://example.com', + userAgent: 'jest', + country: 'US', + }; + + pageViewRepository.save.mockResolvedValue(undefined); + + const result = await useCase.execute(input); + + expect(pageViewRepository.save).toHaveBeenCalledTimes(1); + const saved = (pageViewRepository.save as unknown as Mock).mock.calls[0][0] as PageView; + + expect(saved).toBeInstanceOf(PageView); + expect(saved.id).toBeDefined(); + expect(saved.entityId).toBe(input.entityId); + expect(saved.entityType).toBe(input.entityType); + expect(result.pageViewId).toBe(saved.id); + expect((logger.info as unknown as Mock)).toHaveBeenCalled(); + }); + + it('logs and rethrows when repository save fails', async () => { + const input: RecordPageViewInput = { + entityType: 'league' as EntityType, + entityId: 'league-1', + visitorType: 'anonymous' as VisitorType, + sessionId: 'session-1', + }; + + const error = new Error('DB error'); + pageViewRepository.save.mockRejectedValue(error); + + await expect(useCase.execute(input)).rejects.toThrow('DB error'); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); diff --git a/core/analytics/index.ts b/core/analytics/index.ts index b2d329903..95408355c 100644 --- a/core/analytics/index.ts +++ b/core/analytics/index.ts @@ -20,7 +20,4 @@ export * from './application/use-cases/RecordPageViewUseCase'; export * from './application/use-cases/RecordEngagementUseCase'; export * from './application/use-cases/GetEntityAnalyticsQuery'; -// Infrastructure (moved to adapters) -export type { IPageViewRepository } from './application/repositories/IPageViewRepository'; -export type { IEngagementRepository } from './domain/repositories/IEngagementRepository'; -export type { IAnalyticsSnapshotRepository } from './domain/repositories/IAnalyticsSnapshotRepository'; \ No newline at end of file +// Infrastructure (moved to adapters) \ No newline at end of file diff --git a/core/identity/application/use-cases/GetCurrentUserSessionUseCase.test.ts b/core/identity/application/use-cases/GetCurrentUserSessionUseCase.test.ts new file mode 100644 index 000000000..0e78d8ce6 --- /dev/null +++ b/core/identity/application/use-cases/GetCurrentUserSessionUseCase.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetCurrentUserSessionUseCase } from './GetCurrentUserSessionUseCase'; +import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; + +describe('GetCurrentUserSessionUseCase', () => { + let sessionPort: { + getCurrentSession: Mock; + createSession: Mock; + clearSession: Mock; + }; + + let useCase: GetCurrentUserSessionUseCase; + + beforeEach(() => { + sessionPort = { + getCurrentSession: vi.fn(), + createSession: vi.fn(), + clearSession: vi.fn(), + }; + + useCase = new GetCurrentUserSessionUseCase(sessionPort as unknown as IdentitySessionPort); + }); + + it('returns the current auth session when one exists', async () => { + const session: AuthSessionDTO = { + user: { + id: 'user-1', + email: 'test@example.com', + displayName: 'Test User', + primaryDriverId: 'driver-1', + }, + issuedAt: Date.now(), + expiresAt: Date.now() + 1000, + token: 'token-123', + }; + + sessionPort.getCurrentSession.mockResolvedValue(session); + + const result = await useCase.execute(); + + expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1); + expect(result).toEqual(session); + }); + + it('returns null when there is no active session', async () => { + sessionPort.getCurrentSession.mockResolvedValue(null); + + const result = await useCase.execute(); + + expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); +}); diff --git a/core/identity/application/use-cases/GetUserUseCase.test.ts b/core/identity/application/use-cases/GetUserUseCase.test.ts new file mode 100644 index 000000000..09c9a6358 --- /dev/null +++ b/core/identity/application/use-cases/GetUserUseCase.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetUserUseCase } from './GetUserUseCase'; +import { User } from '../../domain/entities/User'; +import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; + +describe('GetUserUseCase', () => { + let userRepository: { + findById: Mock; + }; + + let useCase: GetUserUseCase; + + beforeEach(() => { + userRepository = { + findById: vi.fn(), + }; + + useCase = new GetUserUseCase(userRepository as unknown as IUserRepository); + }); + + it('returns a User when the user exists', async () => { + const storedUser: StoredUser = { + id: 'user-1', + email: 'test@example.com', + displayName: 'Test User', + passwordHash: 'hash', + salt: 'salt', + primaryDriverId: 'driver-1', + createdAt: new Date(), + }; + + userRepository.findById.mockResolvedValue(storedUser); + + const result = await useCase.execute('user-1'); + + expect(userRepository.findById).toHaveBeenCalledWith('user-1'); + expect(result).toBeInstanceOf(User); + expect(result.getId().value).toBe('user-1'); + expect(result.getDisplayName()).toBe('Test User'); + }); + + it('throws when the user does not exist', async () => { + userRepository.findById.mockResolvedValue(null); + + await expect(useCase.execute('missing-user')).rejects.toThrow('User not found'); + expect(userRepository.findById).toHaveBeenCalledWith('missing-user'); + }); +}); diff --git a/core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts b/core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts new file mode 100644 index 000000000..3481328a4 --- /dev/null +++ b/core/identity/application/use-cases/HandleAuthCallbackUseCase.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { HandleAuthCallbackUseCase } from './HandleAuthCallbackUseCase'; +import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; +import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import type { AuthCallbackCommandDTO } from '../dto/AuthCallbackCommandDTO'; +import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; +import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; + +describe('HandleAuthCallbackUseCase', () => { + let provider: { + completeAuth: Mock; + }; + let sessionPort: { + createSession: Mock; + getCurrentSession: Mock; + clearSession: Mock; + }; + let useCase: HandleAuthCallbackUseCase; + + beforeEach(() => { + provider = { + completeAuth: vi.fn(), + }; + sessionPort = { + createSession: vi.fn(), + getCurrentSession: vi.fn(), + clearSession: vi.fn(), + }; + + useCase = new HandleAuthCallbackUseCase( + provider as unknown as IdentityProviderPort, + sessionPort as unknown as IdentitySessionPort, + ); + }); + + it('completes auth and creates a session', async () => { + const command: AuthCallbackCommandDTO = { + code: 'auth-code', + state: 'state-123', + redirectUri: 'https://app/callback', + }; + + const user: AuthenticatedUserDTO = { + id: 'user-1', + email: 'test@example.com', + displayName: 'Test User', + }; + + const session: AuthSessionDTO = { + user, + issuedAt: Date.now(), + expiresAt: Date.now() + 1000, + token: 'session-token', + }; + + provider.completeAuth.mockResolvedValue(user); + sessionPort.createSession.mockResolvedValue(session); + + const result = await useCase.execute(command); + + expect(provider.completeAuth).toHaveBeenCalledWith(command); + expect(sessionPort.createSession).toHaveBeenCalledWith(user); + expect(result).toEqual(session); + }); +}); diff --git a/core/identity/application/use-cases/LoginUseCase.test.ts b/core/identity/application/use-cases/LoginUseCase.test.ts new file mode 100644 index 000000000..32ec47e7f --- /dev/null +++ b/core/identity/application/use-cases/LoginUseCase.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { LoginUseCase } from './LoginUseCase'; +import { EmailAddress } from '../../domain/value-objects/EmailAddress'; +import type { IAuthRepository } from '../../domain/repositories/IAuthRepository'; +import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; +import { User } from '../../domain/entities/User'; + +describe('LoginUseCase', () => { + let authRepo: { + findByEmail: Mock; + }; + let passwordService: { + verify: Mock; + }; + let useCase: LoginUseCase; + + beforeEach(() => { + authRepo = { + findByEmail: vi.fn(), + }; + passwordService = { + verify: vi.fn(), + }; + useCase = new LoginUseCase( + authRepo as unknown as IAuthRepository, + passwordService as unknown as IPasswordHashingService, + ); + }); + + it('returns the user when credentials are valid', async () => { + const email = 'test@example.com'; + const password = 'password123'; + const emailVO = EmailAddress.create(email); + + const user = User.create({ + id: { value: 'user-1' } as any, + displayName: 'Test User', + email: emailVO.value, + }); + + (user as any).getPasswordHash = () => ({ value: 'stored-hash' }); + + authRepo.findByEmail.mockResolvedValue(user); + passwordService.verify.mockResolvedValue(true); + + const result = await useCase.execute(email, password); + + expect(authRepo.findByEmail).toHaveBeenCalledWith(emailVO); + expect(passwordService.verify).toHaveBeenCalledWith(password, 'stored-hash'); + expect(result).toBe(user); + }); + + it('throws when user is not found', async () => { + const email = 'missing@example.com'; + + authRepo.findByEmail.mockResolvedValue(null); + + await expect(useCase.execute(email, 'password')).rejects.toThrow('Invalid credentials'); + }); + + it('throws when password is invalid', async () => { + const email = 'test@example.com'; + const password = 'wrong-password'; + const emailVO = EmailAddress.create(email); + + const user = User.create({ + id: { value: 'user-1' } as any, + displayName: 'Test User', + email: emailVO.value, + }); + + (user as any).getPasswordHash = () => ({ value: 'stored-hash' }); + + authRepo.findByEmail.mockResolvedValue(user); + passwordService.verify.mockResolvedValue(false); + + await expect(useCase.execute(email, password)).rejects.toThrow('Invalid credentials'); + expect(authRepo.findByEmail).toHaveBeenCalled(); + expect(passwordService.verify).toHaveBeenCalled(); + }); +}); diff --git a/core/identity/application/use-cases/LoginWithEmailUseCase.test.ts b/core/identity/application/use-cases/LoginWithEmailUseCase.test.ts new file mode 100644 index 000000000..f48bac921 --- /dev/null +++ b/core/identity/application/use-cases/LoginWithEmailUseCase.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { LoginWithEmailUseCase, type LoginCommandDTO } from './LoginWithEmailUseCase'; +import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; +import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; + +describe('LoginWithEmailUseCase', () => { + let userRepository: { + findByEmail: Mock; + }; + let sessionPort: { + createSession: Mock; + getCurrentSession: Mock; + clearSession: Mock; + }; + let useCase: LoginWithEmailUseCase; + + beforeEach(() => { + userRepository = { + findByEmail: vi.fn(), + }; + sessionPort = { + createSession: vi.fn(), + getCurrentSession: vi.fn(), + clearSession: vi.fn(), + }; + useCase = new LoginWithEmailUseCase( + userRepository as unknown as IUserRepository, + sessionPort as unknown as IdentitySessionPort, + ); + }); + + it('creates a session for valid credentials', async () => { + const command: LoginCommandDTO = { + email: 'Test@Example.com', + password: 'password123', + }; + + const storedUser: StoredUser = { + id: 'user-1', + email: 'test@example.com', + displayName: 'Test User', + passwordHash: 'hashed-password', + salt: 'salt', + createdAt: new Date(), + }; + + const session: AuthSessionDTO = { + user: { + id: storedUser.id, + email: storedUser.email, + displayName: storedUser.displayName, + }, + issuedAt: Date.now(), + expiresAt: Date.now() + 1000, + token: 'token-123', + }; + + userRepository.findByEmail.mockResolvedValue(storedUser); + sessionPort.createSession.mockResolvedValue(session); + + const result = await useCase.execute(command); + + expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com'); + expect(sessionPort.createSession).toHaveBeenCalledWith({ + id: storedUser.id, + email: storedUser.email, + displayName: storedUser.displayName, + }); + expect(result).toEqual(session); + }); + + it('throws when email or password is missing', async () => { + await expect(useCase.execute({ email: '', password: 'x' })).rejects.toThrow('Email and password are required'); + await expect(useCase.execute({ email: 'a@example.com', password: '' })).rejects.toThrow('Email and password are required'); + }); + + it('throws when user does not exist', async () => { + const command: LoginCommandDTO = { + email: 'missing@example.com', + password: 'password', + }; + + userRepository.findByEmail.mockResolvedValue(null); + + await expect(useCase.execute(command)).rejects.toThrow('Invalid email or password'); + }); + + it('throws when password is invalid', async () => { + const command: LoginCommandDTO = { + email: 'test@example.com', + password: 'wrong', + }; + + const storedUser: StoredUser = { + id: 'user-1', + email: 'test@example.com', + displayName: 'Test User', + passwordHash: 'different-hash', + salt: 'salt', + createdAt: new Date(), + }; + + userRepository.findByEmail.mockResolvedValue(storedUser); + + await expect(useCase.execute(command)).rejects.toThrow('Invalid email or password'); + }); +}); diff --git a/core/identity/application/use-cases/LogoutUseCase.test.ts b/core/identity/application/use-cases/LogoutUseCase.test.ts new file mode 100644 index 000000000..4c45be675 --- /dev/null +++ b/core/identity/application/use-cases/LogoutUseCase.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { LogoutUseCase } from './LogoutUseCase'; +import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; + +describe('LogoutUseCase', () => { + let sessionPort: { + clearSession: Mock; + getCurrentSession: Mock; + createSession: Mock; + }; + let useCase: LogoutUseCase; + + beforeEach(() => { + sessionPort = { + clearSession: vi.fn(), + getCurrentSession: vi.fn(), + createSession: vi.fn(), + }; + + useCase = new LogoutUseCase(sessionPort as unknown as IdentitySessionPort); + }); + + it('clears the current session', async () => { + await useCase.execute(); + + expect(sessionPort.clearSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/core/identity/application/use-cases/SignupUseCase.test.ts b/core/identity/application/use-cases/SignupUseCase.test.ts new file mode 100644 index 000000000..5a365d02f --- /dev/null +++ b/core/identity/application/use-cases/SignupUseCase.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { SignupUseCase } from './SignupUseCase'; +import { EmailAddress } from '../../domain/value-objects/EmailAddress'; +import { UserId } from '../../domain/value-objects/UserId'; +import { User } from '../../domain/entities/User'; +import type { IAuthRepository } from '../../domain/repositories/IAuthRepository'; +import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; + +vi.mock('../../domain/value-objects/PasswordHash', () => ({ + PasswordHash: { + fromHash: (hash: string) => ({ value: hash }), + }, +})); + +describe('SignupUseCase', () => { + let authRepo: { + findByEmail: Mock; + save: Mock; + }; + let passwordService: { + hash: Mock; + }; + let useCase: SignupUseCase; + + beforeEach(() => { + authRepo = { + findByEmail: vi.fn(), + save: vi.fn(), + }; + passwordService = { + hash: vi.fn(), + }; + + useCase = new SignupUseCase( + authRepo as unknown as IAuthRepository, + passwordService as unknown as IPasswordHashingService, + ); + }); + + it('creates and saves a new user when email is free', async () => { + const email = 'new@example.com'; + const password = 'password123'; + const displayName = 'New User'; + + authRepo.findByEmail.mockResolvedValue(null); + passwordService.hash.mockResolvedValue('hashed-password'); + + const result = await useCase.execute(email, password, displayName); + + expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(email)); + expect(passwordService.hash).toHaveBeenCalledWith(password); + expect(authRepo.save).toHaveBeenCalled(); + + expect(result).toBeInstanceOf(User); + expect(result.getDisplayName()).toBe(displayName); + }); + + it('throws when user already exists', async () => { + const email = 'existing@example.com'; + + const existingUser = User.create({ + id: UserId.create(), + displayName: 'Existing User', + email, + }); + + authRepo.findByEmail.mockResolvedValue(existingUser); + + await expect(useCase.execute(email, 'password', 'Existing User')).rejects.toThrow('User already exists'); + }); +}); diff --git a/core/identity/application/use-cases/SignupWithEmailUseCase.test.ts b/core/identity/application/use-cases/SignupWithEmailUseCase.test.ts new file mode 100644 index 000000000..9e1bb52a8 --- /dev/null +++ b/core/identity/application/use-cases/SignupWithEmailUseCase.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { SignupWithEmailUseCase, type SignupCommandDTO } from './SignupWithEmailUseCase'; +import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; +import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; +import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; + +describe('SignupWithEmailUseCase', () => { + let userRepository: { + findByEmail: Mock; + create: Mock; + }; + let sessionPort: { + createSession: Mock; + getCurrentSession: Mock; + clearSession: Mock; + }; + let useCase: SignupWithEmailUseCase; + + beforeEach(() => { + userRepository = { + findByEmail: vi.fn(), + create: vi.fn(), + }; + sessionPort = { + createSession: vi.fn(), + getCurrentSession: vi.fn(), + clearSession: vi.fn(), + }; + useCase = new SignupWithEmailUseCase( + userRepository as unknown as IUserRepository, + sessionPort as unknown as IdentitySessionPort, + ); + }); + + it('creates a new user and session for valid input', async () => { + const command: SignupCommandDTO = { + email: 'new@example.com', + password: 'password123', + displayName: 'New User', + }; + + userRepository.findByEmail.mockResolvedValue(null); + + const session: AuthSessionDTO = { + user: { + id: 'user-1', + email: command.email.toLowerCase(), + displayName: command.displayName, + }, + issuedAt: Date.now(), + expiresAt: Date.now() + 1000, + token: 'session-token', + }; + + sessionPort.createSession.mockResolvedValue(session); + + const result = await useCase.execute(command); + + expect(userRepository.findByEmail).toHaveBeenCalledWith(command.email); + expect(userRepository.create).toHaveBeenCalled(); + expect(sessionPort.createSession).toHaveBeenCalledWith({ + id: expect.any(String), + email: command.email.toLowerCase(), + displayName: command.displayName, + }); + + expect(result.session).toEqual(session); + expect(result.isNewUser).toBe(true); + }); + + it('throws when email format is invalid', async () => { + const command: SignupCommandDTO = { + email: 'invalid-email', + password: 'password123', + displayName: 'User', + }; + + await expect(useCase.execute(command)).rejects.toThrow('Invalid email format'); + }); + + it('throws when password is too short', async () => { + const command: SignupCommandDTO = { + email: 'valid@example.com', + password: 'short', + displayName: 'User', + }; + + await expect(useCase.execute(command)).rejects.toThrow('Password must be at least 8 characters'); + }); + + it('throws when display name is too short', async () => { + const command: SignupCommandDTO = { + email: 'valid@example.com', + password: 'password123', + displayName: ' ', + }; + + await expect(useCase.execute(command)).rejects.toThrow('Display name must be at least 2 characters'); + }); + + it('throws when email already exists', async () => { + const command: SignupCommandDTO = { + email: 'existing@example.com', + password: 'password123', + displayName: 'Existing User', + }; + + const existingUser: StoredUser = { + id: 'user-1', + email: command.email, + displayName: command.displayName, + passwordHash: 'hash', + salt: 'salt', + createdAt: new Date(), + }; + + userRepository.findByEmail.mockResolvedValue(existingUser); + + await expect(useCase.execute(command)).rejects.toThrow('An account with this email already exists'); + }); +}); diff --git a/core/identity/application/use-cases/StartAuthUseCase.test.ts b/core/identity/application/use-cases/StartAuthUseCase.test.ts new file mode 100644 index 000000000..43d36e377 --- /dev/null +++ b/core/identity/application/use-cases/StartAuthUseCase.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { StartAuthUseCase } from './StartAuthUseCase'; +import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; +import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO'; + +describe('StartAuthUseCase', () => { + let provider: { + startAuth: Mock; + }; + let useCase: StartAuthUseCase; + + beforeEach(() => { + provider = { + startAuth: vi.fn(), + }; + + useCase = new StartAuthUseCase(provider as unknown as IdentityProviderPort); + }); + + it('delegates to the identity provider to start auth', async () => { + const command: StartAuthCommandDTO = { + redirectUri: 'https://app/callback', + provider: 'demo', + }; + + const expected = { redirectUrl: 'https://auth/redirect', state: 'state-123' }; + + provider.startAuth.mockResolvedValue(expected); + + const result = await useCase.execute(command); + + expect(provider.startAuth).toHaveBeenCalledWith(command); + expect(result).toEqual(expected); + }); +}); diff --git a/core/identity/application/use-cases/achievement/CreateAchievementUseCase.test.ts b/core/identity/application/use-cases/achievement/CreateAchievementUseCase.test.ts new file mode 100644 index 000000000..e184d9c61 --- /dev/null +++ b/core/identity/application/use-cases/achievement/CreateAchievementUseCase.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { CreateAchievementUseCase, type IAchievementRepository } from './CreateAchievementUseCase'; +import { Achievement } from '@core/identity/domain/entities/Achievement'; + +describe('CreateAchievementUseCase', () => { + let achievementRepository: { + save: Mock; + findById: Mock; + }; + let useCase: CreateAchievementUseCase; + + beforeEach(() => { + achievementRepository = { + save: vi.fn(), + findById: vi.fn(), + }; + + useCase = new CreateAchievementUseCase(achievementRepository as unknown as IAchievementRepository); + }); + + it('creates an achievement and persists it', async () => { + const props = { + id: 'achv-1', + name: 'First Win', + description: 'Awarded for winning your first race', + category: 'driver' as const, + rarity: 'common' as const, + iconUrl: 'https://example.com/icon.png', + points: 50, + requirements: [ + { + type: 'wins', + value: 1, + operator: '>=', + }, + ], + isSecret: false, + }; + + achievementRepository.save.mockResolvedValue(undefined); + + const result = await useCase.execute(props); + + expect(result).toBeInstanceOf(Achievement); + expect(result.id).toBe(props.id); + expect(result.name).toBe(props.name); + expect(result.description).toBe(props.description); + expect(result.category).toBe(props.category); + expect(result.points).toBe(props.points); + expect(result.requirements).toHaveLength(1); + expect(achievementRepository.save).toHaveBeenCalledWith(result); + }); +}); diff --git a/core/identity/index.ts b/core/identity/index.ts index 51adaec8c..e20076b2a 100644 --- a/core/identity/index.ts +++ b/core/identity/index.ts @@ -11,9 +11,6 @@ export * from './domain/repositories/ISponsorAccountRepository'; export * from './domain/repositories/IUserRatingRepository'; export * from './domain/repositories/IAchievementRepository'; -export * from './infrastructure/repositories/InMemoryUserRatingRepository'; -export * from './infrastructure/repositories/InMemoryAchievementRepository'; - export * from './application/dto/AuthenticatedUserDTO'; export * from './application/dto/AuthSessionDTO'; export * from './application/dto/AuthCallbackCommandDTO'; @@ -24,4 +21,4 @@ export * from './application/dto/IracingAuthStateDTO'; export * from './application/use-cases/StartAuthUseCase'; export * from './application/use-cases/HandleAuthCallbackUseCase'; export * from './application/use-cases/GetCurrentUserSessionUseCase'; -export * from './application/use-cases/LogoutUseCase'; \ No newline at end of file +export * from './application/use-cases/LogoutUseCase'; diff --git a/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.test.ts b/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.test.ts new file mode 100644 index 000000000..b252c5e7e --- /dev/null +++ b/core/league/application/use-cases/GetLeagueStandingsUseCaseImpl.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetLeagueStandingsUseCaseImpl } from './GetLeagueStandingsUseCaseImpl'; +import type { ILeagueStandingsRepository, RawStanding } from '../ports/ILeagueStandingsRepository'; + +describe('GetLeagueStandingsUseCaseImpl', () => { + let repository: { + getLeagueStandings: Mock; + }; + let useCase: GetLeagueStandingsUseCaseImpl; + + beforeEach(() => { + repository = { + getLeagueStandings: vi.fn(), + } as unknown as ILeagueStandingsRepository as any; + + useCase = new GetLeagueStandingsUseCaseImpl(repository as unknown as ILeagueStandingsRepository); + }); + + it('maps raw standings from repository to view model', async () => { + const leagueId = 'league-1'; + const rawStandings: RawStanding[] = [ + { + id: 's1', + leagueId, + seasonId: 'season-1', + driverId: 'driver-1', + position: 1, + points: 100, + wins: 3, + podiums: 5, + racesCompleted: 10, + }, + { + id: 's2', + leagueId, + seasonId: null, + driverId: 'driver-2', + position: 2, + points: 80, + wins: 1, + podiums: null, + racesCompleted: 10, + }, + ]; + + repository.getLeagueStandings.mockResolvedValue(rawStandings); + + const result = await useCase.execute(leagueId); + + expect(repository.getLeagueStandings).toHaveBeenCalledWith(leagueId); + expect(result.leagueId).toBe(leagueId); + expect(result.standings).toEqual([ + { + id: 's1', + leagueId, + seasonId: 'season-1', + driverId: 'driver-1', + position: 1, + points: 100, + wins: 3, + podiums: 5, + racesCompleted: 10, + }, + { + id: 's2', + leagueId, + seasonId: '', + driverId: 'driver-2', + position: 2, + points: 80, + wins: 1, + podiums: 0, + racesCompleted: 10, + }, + ]); + }); +}); diff --git a/core/media/application/use-cases/DeleteMediaUseCase.test.ts b/core/media/application/use-cases/DeleteMediaUseCase.test.ts new file mode 100644 index 000000000..d94684449 --- /dev/null +++ b/core/media/application/use-cases/DeleteMediaUseCase.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { DeleteMediaUseCase } from './DeleteMediaUseCase'; +import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; +import type { MediaStoragePort } from '../ports/MediaStoragePort'; +import type { IDeleteMediaPresenter } from '../presenters/IDeleteMediaPresenter'; +import type { Logger } from '@core/shared/application'; +import { Media } from '../../domain/entities/Media'; +import { MediaUrl } from '../../domain/value-objects/MediaUrl'; + +describe('DeleteMediaUseCase', () => { + let mediaRepo: { + findById: Mock; + delete: Mock; + }; + let mediaStorage: { + deleteMedia: Mock; + }; + let logger: Logger; + let presenter: IDeleteMediaPresenter & { result?: any }; + let useCase: DeleteMediaUseCase; + + beforeEach(() => { + mediaRepo = { + findById: vi.fn(), + delete: vi.fn(), + } as unknown as IMediaRepository as any; + + mediaStorage = { + deleteMedia: vi.fn(), + } as unknown as MediaStoragePort as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + presenter = { + present: vi.fn((result) => { + (presenter as any).result = result; + }), + } as unknown as IDeleteMediaPresenter & { result?: any }; + + useCase = new DeleteMediaUseCase( + mediaRepo as unknown as IMediaRepository, + mediaStorage as unknown as MediaStoragePort, + logger, + ); + }); + + it('returns error result when media is not found', async () => { + mediaRepo.findById.mockResolvedValue(null); + + await useCase.execute({ mediaId: 'missing' }, presenter); + + expect(mediaRepo.findById).toHaveBeenCalledWith('missing'); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Media not found', + }); + }); + + it('deletes media from storage and repository on success', async () => { + const media = Media.create({ + id: 'media-1', + filename: 'file.png', + originalName: 'file.png', + mimeType: 'image/png', + size: 123, + url: MediaUrl.create('https://example.com/file.png'), + type: 'image', + uploadedBy: 'user-1', + }); + + mediaRepo.findById.mockResolvedValue(media); + + await useCase.execute({ mediaId: 'media-1' }, presenter); + + expect(mediaRepo.findById).toHaveBeenCalledWith('media-1'); + expect(mediaStorage.deleteMedia).toHaveBeenCalledWith(media.url.value); + expect(mediaRepo.delete).toHaveBeenCalledWith('media-1'); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ success: true }); + }); + + it('handles errors and presents failure result', async () => { + mediaRepo.findById.mockRejectedValue(new Error('DB error')); + + await useCase.execute({ mediaId: 'media-1' }, presenter); + + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Internal error occurred while deleting media', + }); + }); +}); diff --git a/core/media/application/use-cases/GetAvatarUseCase.test.ts b/core/media/application/use-cases/GetAvatarUseCase.test.ts new file mode 100644 index 000000000..cca8c914b --- /dev/null +++ b/core/media/application/use-cases/GetAvatarUseCase.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetAvatarUseCase } from './GetAvatarUseCase'; +import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository'; +import type { IGetAvatarPresenter } from '../presenters/IGetAvatarPresenter'; +import type { Logger } from '@core/shared/application'; +import { Avatar } from '../../domain/entities/Avatar'; +import { MediaUrl } from '../../domain/value-objects/MediaUrl'; + +interface TestPresenter extends IGetAvatarPresenter { + result?: any; +} + +describe('GetAvatarUseCase', () => { + let avatarRepo: { + findActiveByDriverId: Mock; + save: Mock; + }; + let logger: Logger; + let presenter: TestPresenter; + let useCase: GetAvatarUseCase; + + beforeEach(() => { + avatarRepo = { + findActiveByDriverId: vi.fn(), + save: vi.fn(), + } as unknown as IAvatarRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + presenter = { + present: vi.fn((result) => { + presenter.result = result; + }), + } as unknown as TestPresenter; + + useCase = new GetAvatarUseCase( + avatarRepo as unknown as IAvatarRepository, + logger, + ); + }); + + it('presents error when no avatar exists for driver', async () => { + avatarRepo.findActiveByDriverId.mockResolvedValue(null); + + await useCase.execute({ driverId: 'driver-1' }, presenter); + + expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1'); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Avatar not found', + }); + }); + + it('presents avatar details when avatar exists', async () => { + const avatar = Avatar.create({ + id: 'avatar-1', + driverId: 'driver-1', + mediaUrl: MediaUrl.create('https://example.com/avatar.png'), + }); + + avatarRepo.findActiveByDriverId.mockResolvedValue(avatar); + + await useCase.execute({ driverId: 'driver-1' }, presenter); + + expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1'); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: true, + avatar: { + id: avatar.id, + driverId: avatar.driverId, + mediaUrl: avatar.mediaUrl.value, + selectedAt: avatar.selectedAt, + }, + }); + }); + + it('handles errors by logging and presenting failure', async () => { + avatarRepo.findActiveByDriverId.mockRejectedValue(new Error('DB error')); + + await useCase.execute({ driverId: 'driver-1' }, presenter); + + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Internal error occurred while retrieving avatar', + }); + }); +}); diff --git a/core/media/application/use-cases/GetMediaUseCase.test.ts b/core/media/application/use-cases/GetMediaUseCase.test.ts new file mode 100644 index 000000000..6bfc858ed --- /dev/null +++ b/core/media/application/use-cases/GetMediaUseCase.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetMediaUseCase } from './GetMediaUseCase'; +import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; +import type { IGetMediaPresenter } from '../presenters/IGetMediaPresenter'; +import type { Logger } from '@core/shared/application'; +import { Media } from '../../domain/entities/Media'; +import { MediaUrl } from '../../domain/value-objects/MediaUrl'; + +interface TestPresenter extends IGetMediaPresenter { + result?: any; +} + +describe('GetMediaUseCase', () => { + let mediaRepo: { + findById: Mock; + }; + let logger: Logger; + let presenter: TestPresenter; + let useCase: GetMediaUseCase; + + beforeEach(() => { + mediaRepo = { + findById: vi.fn(), + } as unknown as IMediaRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + presenter = { + present: vi.fn((result) => { + presenter.result = result; + }), + } as unknown as TestPresenter; + + useCase = new GetMediaUseCase( + mediaRepo as unknown as IMediaRepository, + logger, + ); + }); + + it('presents error when media is not found', async () => { + mediaRepo.findById.mockResolvedValue(null); + + await useCase.execute({ mediaId: 'missing' }, presenter); + + expect(mediaRepo.findById).toHaveBeenCalledWith('missing'); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Media not found', + }); + }); + + it('presents media details when media exists', async () => { + const media = Media.create({ + id: 'media-1', + filename: 'file.png', + originalName: 'file.png', + mimeType: 'image/png', + size: 123, + url: MediaUrl.create('https://example.com/file.png'), + type: 'image', + uploadedBy: 'user-1', + }); + + mediaRepo.findById.mockResolvedValue(media); + + await useCase.execute({ mediaId: 'media-1' }, presenter); + + expect(mediaRepo.findById).toHaveBeenCalledWith('media-1'); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: true, + media: { + id: media.id, + filename: media.filename, + originalName: media.originalName, + mimeType: media.mimeType, + size: media.size, + url: media.url.value, + type: media.type, + uploadedBy: media.uploadedBy, + uploadedAt: media.uploadedAt, + metadata: media.metadata, + }, + }); + }); + + it('handles errors by logging and presenting failure', async () => { + mediaRepo.findById.mockRejectedValue(new Error('DB error')); + + await useCase.execute({ mediaId: 'media-1' }, presenter); + + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Internal error occurred while retrieving media', + }); + }); +}); diff --git a/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts b/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts new file mode 100644 index 000000000..193a9ed43 --- /dev/null +++ b/core/media/application/use-cases/RequestAvatarGenerationUseCase.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { RequestAvatarGenerationUseCase } from './RequestAvatarGenerationUseCase'; +import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; +import type { FaceValidationPort } from '../ports/FaceValidationPort'; +import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort'; +import type { Logger } from '@core/shared/application'; +import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; +import type { RequestAvatarGenerationInput } from './RequestAvatarGenerationUseCase'; + +describe('RequestAvatarGenerationUseCase', () => { + let avatarRepo: { + save: Mock; + findById: Mock; + }; + let faceValidation: { + validateFacePhoto: Mock; + }; + let avatarGeneration: { + generateAvatars: Mock; + }; + let logger: Logger; + let useCase: RequestAvatarGenerationUseCase; + + beforeEach(() => { + avatarRepo = { + save: vi.fn(), + findById: vi.fn(), + } as unknown as IAvatarGenerationRepository as any; + + faceValidation = { + validateFacePhoto: vi.fn(), + } as unknown as FaceValidationPort as any; + + avatarGeneration = { + generateAvatars: vi.fn(), + } as unknown as AvatarGenerationPort as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new RequestAvatarGenerationUseCase( + avatarRepo as unknown as IAvatarGenerationRepository, + faceValidation as unknown as FaceValidationPort, + avatarGeneration as unknown as AvatarGenerationPort, + logger, + ); + }); + + const createPresenter = () => { + const presenter: { present: Mock; result?: any } = { + present: vi.fn((result) => { + presenter.result = result; + }), + result: undefined, + }; + return presenter; + }; + + it('fails when face validation fails', async () => { + const input: RequestAvatarGenerationInput = { + userId: 'user-1', + facePhotoData: 'photo-data', + suitColor: 'red', + style: 'realistic', + }; + + const presenter = createPresenter(); + + faceValidation.validateFacePhoto.mockResolvedValue({ + isValid: false, + hasFace: false, + faceCount: 0, + errorMessage: 'No face detected', + }); + + await useCase.execute(input, presenter as any); + + expect((presenter.present as Mock)).toHaveBeenCalledWith({ + requestId: expect.any(String), + status: 'failed', + errorMessage: 'No face detected', + }); + }); + + it('completes request and returns avatar URLs on success', async () => { + const input: RequestAvatarGenerationInput = { + userId: 'user-1', + facePhotoData: 'photo-data', + suitColor: 'red', + style: 'realistic', + }; + + const presenter = createPresenter(); + + faceValidation.validateFacePhoto.mockResolvedValue({ + isValid: true, + hasFace: true, + faceCount: 1, + }); + + avatarGeneration.generateAvatars.mockResolvedValue({ + success: true, + avatars: [ + { url: 'https://example.com/avatar1.png' }, + { url: 'https://example.com/avatar2.png' }, + ], + }); + + await useCase.execute(input, presenter as any); + + expect(faceValidation.validateFacePhoto).toHaveBeenCalled(); + expect(avatarGeneration.generateAvatars).toHaveBeenCalled(); + expect((presenter.present as Mock)).toHaveBeenCalledWith({ + requestId: expect.any(String), + status: 'completed', + avatarUrls: [ + 'https://example.com/avatar1.png', + 'https://example.com/avatar2.png', + ], + }); + }); +}); diff --git a/core/media/application/use-cases/SelectAvatarUseCase.test.ts b/core/media/application/use-cases/SelectAvatarUseCase.test.ts new file mode 100644 index 000000000..59dc1e72b --- /dev/null +++ b/core/media/application/use-cases/SelectAvatarUseCase.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { SelectAvatarUseCase } from './SelectAvatarUseCase'; +import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; +import type { ISelectAvatarPresenter } from '../presenters/ISelectAvatarPresenter'; +import type { Logger } from '@core/shared/application'; +import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; + +interface TestPresenter extends ISelectAvatarPresenter { + result?: any; +} + +describe('SelectAvatarUseCase', () => { + let avatarRepo: { + findById: Mock; + save: Mock; + }; + let logger: Logger; + let presenter: TestPresenter; + let useCase: SelectAvatarUseCase; + + beforeEach(() => { + avatarRepo = { + findById: vi.fn(), + save: vi.fn(), + } as unknown as IAvatarGenerationRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + presenter = { + present: vi.fn((result) => { + presenter.result = result; + }), + } as unknown as TestPresenter; + + useCase = new SelectAvatarUseCase( + avatarRepo as unknown as IAvatarGenerationRepository, + logger, + ); + }); + + it('returns error when request is not found', async () => { + avatarRepo.findById.mockResolvedValue(null); + + await useCase.execute({ requestId: 'req-1', selectedIndex: 0 }, presenter); + + expect(avatarRepo.findById).toHaveBeenCalledWith('req-1'); + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Avatar generation request not found', + }); + }); + + it('returns error when request is not completed', async () => { + const request = AvatarGenerationRequest.create({ + id: 'req-1', + userId: 'user-1', + facePhotoUrl: 'photo', + suitColor: 'red', + style: 'realistic', + }); + + avatarRepo.findById.mockResolvedValue(request); + + await useCase.execute({ requestId: 'req-1', selectedIndex: 0 }, presenter); + + expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ + success: false, + errorMessage: 'Avatar generation is not completed yet', + }); + }); +}); diff --git a/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts new file mode 100644 index 000000000..0f4497e09 --- /dev/null +++ b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetUnreadNotificationsUseCase } from './GetUnreadNotificationsUseCase'; +import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; +import type { Logger } from '@core/shared/application'; +import { Notification } from '../../domain/entities/Notification'; + +interface NotificationRepositoryMock { + findUnreadByRecipientId: Mock; +} + +describe('GetUnreadNotificationsUseCase', () => { + let notificationRepository: NotificationRepositoryMock; + let logger: Logger; + let useCase: GetUnreadNotificationsUseCase; + + beforeEach(() => { + notificationRepository = { + findUnreadByRecipientId: vi.fn(), + } as unknown as INotificationRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new GetUnreadNotificationsUseCase( + notificationRepository as unknown as INotificationRepository, + logger, + ); + }); + + it('returns unread notifications and total count', async () => { + const recipientId = 'driver-1'; + const notifications: Notification[] = [ + Notification.create({ + id: 'n1', + recipientId, + type: 'info', + title: 'Test', + body: 'Body', + channel: 'in_app', + }), + ]; + + notificationRepository.findUnreadByRecipientId.mockResolvedValue(notifications); + + const result = await useCase.execute(recipientId); + + expect(notificationRepository.findUnreadByRecipientId).toHaveBeenCalledWith(recipientId); + expect(result.notifications).toEqual(notifications); + expect(result.totalCount).toBe(1); + }); + + it('handles repository errors by logging and rethrowing', async () => { + const recipientId = 'driver-1'; + const error = new Error('DB error'); + notificationRepository.findUnreadByRecipientId.mockRejectedValue(error); + + await expect(useCase.execute(recipientId)).rejects.toThrow('DB error'); + expect((logger.error as unknown as Mock)).toHaveBeenCalled(); + }); +}); diff --git a/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts index f7463e1df..b7ca1c1b5 100644 --- a/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts +++ b/core/notifications/application/use-cases/GetUnreadNotificationsUseCase.ts @@ -4,8 +4,7 @@ * Retrieves unread notifications for a recipient. */ -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { Notification } from '../../domain/entities/Notification'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; diff --git a/core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts b/core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts new file mode 100644 index 000000000..850349d73 --- /dev/null +++ b/core/notifications/application/use-cases/MarkNotificationReadUseCase.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { MarkNotificationReadUseCase } from './MarkNotificationReadUseCase'; +import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; +import type { Logger } from '@core/shared/application'; +import { Notification } from '../../domain/entities/Notification'; +import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; + +interface NotificationRepositoryMock { + findById: Mock; + update: Mock; + markAllAsReadByRecipientId: Mock; +} + +describe('MarkNotificationReadUseCase', () => { + let notificationRepository: NotificationRepositoryMock; + let logger: Logger; + let useCase: MarkNotificationReadUseCase; + + beforeEach(() => { + notificationRepository = { + findById: vi.fn(), + update: vi.fn(), + markAllAsReadByRecipientId: vi.fn(), + } as unknown as INotificationRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + useCase = new MarkNotificationReadUseCase( + notificationRepository as unknown as INotificationRepository, + logger, + ); + }); + + it('throws when notification is not found', async () => { + notificationRepository.findById.mockResolvedValue(null); + + await expect( + useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }), + ).rejects.toThrow(NotificationDomainError); + + expect((logger.warn as unknown as Mock)).toHaveBeenCalled(); + }); + + it('throws when recipientId does not match', async () => { + const notification = Notification.create({ + id: 'n1', + recipientId: 'driver-2', + type: 'info', + title: 'Test', + body: 'Body', + channel: 'in_app', + }); + + notificationRepository.findById.mockResolvedValue(notification); + + await expect( + useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }), + ).rejects.toThrow(NotificationDomainError); + }); + + it('marks notification as read when unread', async () => { + const notification = Notification.create({ + id: 'n1', + recipientId: 'driver-1', + type: 'info', + title: 'Test', + body: 'Body', + channel: 'in_app', + }); + + notificationRepository.findById.mockResolvedValue(notification); + + await useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }); + + expect(notificationRepository.update).toHaveBeenCalled(); + expect((logger.info as unknown as Mock)).toHaveBeenCalled(); + }); +}); diff --git a/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts b/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts index 551ac5321..d9824fe0c 100644 --- a/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts +++ b/core/notifications/application/use-cases/MarkNotificationReadUseCase.ts @@ -4,10 +4,9 @@ * Marks a notification as read. */ -import type { AsyncUseCase } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; -import type { Logger } from '@core/shared/application'; export interface MarkNotificationReadCommand { notificationId: string; diff --git a/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts b/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts new file mode 100644 index 000000000..96749a59a --- /dev/null +++ b/core/notifications/application/use-cases/NotificationPreferencesUseCases.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { + GetNotificationPreferencesQuery, + UpdateChannelPreferenceUseCase, + UpdateTypePreferenceUseCase, + UpdateQuietHoursUseCase, + SetDigestModeUseCase, +} from './NotificationPreferencesUseCases'; +import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; +import type { NotificationPreference , ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; +import type { Logger } from '@core/shared/application'; +import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; +import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; + +describe('NotificationPreferencesUseCases', () => { + let preferenceRepository: { + getOrCreateDefault: Mock; + save: Mock; + }; + let logger: Logger; + + beforeEach(() => { + preferenceRepository = { + getOrCreateDefault: vi.fn(), + save: vi.fn(), + } as unknown as INotificationPreferenceRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + }); + + it('GetNotificationPreferencesQuery returns preferences from repository', async () => { + const preference = { + id: 'pref-1', + } as unknown as NotificationPreference; + + preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + + const useCase = new GetNotificationPreferencesQuery( + preferenceRepository as unknown as INotificationPreferenceRepository, + logger, + ); + + const result = await useCase.execute('driver-1'); + + expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1'); + expect(result).toBe(preference); + }); + + it('UpdateChannelPreferenceUseCase updates channel preference', async () => { + const preference = { + updateChannel: vi.fn().mockReturnThis(), + } as unknown as NotificationPreference; + + preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + + const useCase = new UpdateChannelPreferenceUseCase( + preferenceRepository as unknown as INotificationPreferenceRepository, + logger, + ); + + await useCase.execute({ + driverId: 'driver-1', + channel: 'email' as NotificationChannel, + preference: 'enabled' as ChannelPreference, + }); + + expect(preference.updateChannel).toHaveBeenCalled(); + expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + }); + + it('UpdateTypePreferenceUseCase updates type preference', async () => { + const preference = { + updateTypePreference: vi.fn().mockReturnThis(), + } as unknown as NotificationPreference; + + preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + + const useCase = new UpdateTypePreferenceUseCase( + preferenceRepository as unknown as INotificationPreferenceRepository, + logger, + ); + + await useCase.execute({ + driverId: 'driver-1', + type: 'info' as NotificationType, + preference: 'enabled' as TypePreference, + }); + + expect(preference.updateTypePreference).toHaveBeenCalled(); + expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + }); + + it('UpdateQuietHoursUseCase validates hours and updates preferences', async () => { + const preference = { + updateQuietHours: vi.fn().mockReturnThis(), + } as unknown as NotificationPreference; + + preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + + const useCase = new UpdateQuietHoursUseCase( + preferenceRepository as unknown as INotificationPreferenceRepository, + logger, + ); + + await useCase.execute({ + driverId: 'driver-1', + startHour: 22, + endHour: 7, + }); + + expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7); + expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + }); + + it('UpdateQuietHoursUseCase throws on invalid hours', async () => { + const useCase = new UpdateQuietHoursUseCase( + preferenceRepository as unknown as INotificationPreferenceRepository, + logger, + ); + + await expect( + useCase.execute({ driverId: 'd1', startHour: -1, endHour: 10 }), + ).rejects.toThrow(NotificationDomainError); + + await expect( + useCase.execute({ driverId: 'd1', startHour: 10, endHour: 24 }), + ).rejects.toThrow(NotificationDomainError); + }); + + it('SetDigestModeUseCase sets digest mode with valid frequency', async () => { + const preference = { + setDigestMode: vi.fn().mockReturnThis(), + } as unknown as NotificationPreference; + + preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); + + const useCase = new SetDigestModeUseCase( + preferenceRepository as unknown as INotificationPreferenceRepository, + ); + + await useCase.execute({ + driverId: 'driver-1', + enabled: true, + frequencyHours: 4, + }); + + expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4); + expect(preferenceRepository.save).toHaveBeenCalledWith(preference); + }); + + it('SetDigestModeUseCase throws on invalid frequency', async () => { + const useCase = new SetDigestModeUseCase( + preferenceRepository as unknown as INotificationPreferenceRepository, + ); + + await expect( + useCase.execute({ driverId: 'driver-1', enabled: true, frequencyHours: 0 }), + ).rejects.toThrow(NotificationDomainError); + }); +}); diff --git a/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts b/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts index da912ddc1..870772c79 100644 --- a/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts +++ b/core/notifications/application/use-cases/NotificationPreferencesUseCases.ts @@ -4,8 +4,7 @@ * Manages user notification preferences. */ -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { NotificationPreference } from '../../domain/entities/NotificationPreference'; import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; diff --git a/core/notifications/infrastructure/index.ts b/core/notifications/infrastructure/index.ts index d361f923d..be9669439 100644 --- a/core/notifications/infrastructure/index.ts +++ b/core/notifications/infrastructure/index.ts @@ -7,5 +7,5 @@ export { InMemoryNotificationRepository } from './repositories/InMemoryNotificat export { InMemoryNotificationPreferenceRepository } from './repositories/InMemoryNotificationPreferenceRepository'; // Adapters -export { InAppNotificationAdapter } from './/InAppNotificationAdapter'; -export { NotificationGatewayRegistry } from './/NotificationGatewayRegistry'; \ No newline at end of file +export { InAppNotificationAdapter } from "./InAppNotificationAdapter"; +export { NotificationGatewayRegistry } from "./NotificationGatewayRegistry"; \ No newline at end of file diff --git a/core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts b/core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts new file mode 100644 index 000000000..fb8a954fc --- /dev/null +++ b/core/payments/application/use-cases/GetMembershipFeesUseCase.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { GetMembershipFeesUseCase, type GetMembershipFeesInput } from './GetMembershipFeesUseCase'; +import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository'; +import type { IGetMembershipFeesPresenter, GetMembershipFeesResultDTO, GetMembershipFeesViewModel } from '../presenters/IGetMembershipFeesPresenter'; + +interface TestPresenter extends IGetMembershipFeesPresenter { + reset: Mock; + present: Mock; + lastDto?: GetMembershipFeesResultDTO; + viewModel?: GetMembershipFeesViewModel; +} + +describe('GetMembershipFeesUseCase', () => { + let membershipFeeRepository: { + findByLeagueId: Mock; + }; + let memberPaymentRepository: { + findByLeagueIdAndDriverId: Mock; + }; + let presenter: TestPresenter; + let useCase: GetMembershipFeesUseCase; + + beforeEach(() => { + membershipFeeRepository = { + findByLeagueId: vi.fn(), + } as unknown as IMembershipFeeRepository as any; + + memberPaymentRepository = { + findByLeagueIdAndDriverId: vi.fn(), + } as unknown as IMemberPaymentRepository as any; + + presenter = { + reset: vi.fn(), + present: vi.fn((dto: GetMembershipFeesResultDTO) => { + presenter.lastDto = dto; + }), + toViewModel: vi.fn((dto: GetMembershipFeesResultDTO) => ({ + fee: dto.fee, + payments: dto.payments, + })), + } as unknown as TestPresenter; + + useCase = new GetMembershipFeesUseCase( + membershipFeeRepository as unknown as IMembershipFeeRepository, + memberPaymentRepository as unknown as IMemberPaymentRepository, + ); + }); + + it('throws when leagueId is missing', async () => { + const input = { leagueId: '' } as GetMembershipFeesInput; + + await expect(useCase.execute(input, presenter)).rejects.toThrow('leagueId is required'); + expect(presenter.reset).toHaveBeenCalled(); + }); + + it('returns null fee and empty payments when no fee exists', async () => { + const input: GetMembershipFeesInput = { leagueId: 'league-1' }; + + membershipFeeRepository.findByLeagueId.mockResolvedValue(null); + + await useCase.execute(input, presenter); + + expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1'); + expect(memberPaymentRepository.findByLeagueIdAndDriverId).not.toHaveBeenCalled(); + expect(presenter.present).toHaveBeenCalledWith({ + fee: null, + payments: [], + }); + }); + + it('maps fee and payments when fee and driverId are provided', async () => { + const input: GetMembershipFeesInput = { leagueId: 'league-1', driverId: 'driver-1' }; + + const fee = { + id: 'fee-1', + leagueId: 'league-1', + seasonId: 'season-1', + type: 'season', + amount: 100, + enabled: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + }; + + const payments = [ + { + id: 'pay-1', + feeId: 'fee-1', + driverId: 'driver-1', + amount: 100, + platformFee: 5, + netAmount: 95, + status: 'paid', + dueDate: new Date('2024-02-01'), + paidAt: new Date('2024-01-15'), + }, + ]; + + membershipFeeRepository.findByLeagueId.mockResolvedValue(fee); + memberPaymentRepository.findByLeagueIdAndDriverId.mockResolvedValue(payments); + + await useCase.execute(input, presenter); + + expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1'); + expect(memberPaymentRepository.findByLeagueIdAndDriverId).toHaveBeenCalledWith('league-1', 'driver-1', membershipFeeRepository as unknown as IMembershipFeeRepository); + + expect(presenter.present).toHaveBeenCalledWith({ + fee: { + id: fee.id, + leagueId: fee.leagueId, + seasonId: fee.seasonId, + type: fee.type, + amount: fee.amount, + enabled: fee.enabled, + createdAt: fee.createdAt, + updatedAt: fee.updatedAt, + }, + payments: [ + { + id: 'pay-1', + feeId: 'fee-1', + driverId: 'driver-1', + amount: 100, + platformFee: 5, + netAmount: 95, + status: 'paid', + dueDate: payments[0].dueDate, + paidAt: payments[0].paidAt, + }, + ], + }); + }); +}); diff --git a/core/racing/application/services/SeasonApplicationService.ts b/core/racing/application/services/SeasonApplicationService.ts new file mode 100644 index 000000000..cc303e9e6 --- /dev/null +++ b/core/racing/application/services/SeasonApplicationService.ts @@ -0,0 +1,374 @@ +import { Season } from '../../domain/entities/Season'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO'; +import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule'; +import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig'; +import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy'; +import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig'; +import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay'; +import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone'; +import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy'; +import { WeekdaySet } from '../../domain/value-objects/WeekdaySet'; +import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern'; +import type { Weekday } from '../../domain/types/Weekday'; +import { v4 as uuidv4 } from 'uuid'; + +export interface CreateSeasonForLeagueCommand { + leagueId: string; + name: string; + gameId: string; + sourceSeasonId?: string; + config?: LeagueConfigFormModel; +} + +export interface CreateSeasonForLeagueResultDTO { + seasonId: string; +} + +export interface SeasonSummaryDTO { + seasonId: string; + leagueId: string; + name: string; + status: import('../../domain/entities/Season').SeasonStatus; + startDate?: Date; + endDate?: Date; + isPrimary: boolean; +} + +export interface ListSeasonsForLeagueQuery { + leagueId: string; +} + +export interface ListSeasonsForLeagueResultDTO { + items: SeasonSummaryDTO[]; +} + +export interface GetSeasonDetailsQuery { + leagueId: string; + seasonId: string; +} + +export interface SeasonDetailsDTO { + seasonId: string; + leagueId: string; + gameId: string; + name: string; + status: import('../../domain/entities/Season').SeasonStatus; + startDate?: Date; + endDate?: Date; + maxDrivers?: number; + schedule?: { + startDate: Date; + plannedRounds: number; + }; + scoring?: { + scoringPresetId: string; + customScoringEnabled: boolean; + }; + dropPolicy?: { + strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy; + n?: number; + }; + stewarding?: { + decisionMode: import('../../domain/entities/League').StewardingDecisionMode; + requiredVotes?: number; + requireDefense: boolean; + defenseTimeLimit: number; + voteTimeLimit: number; + protestDeadlineHours: number; + stewardingClosesHours: number; + notifyAccusedOnProtest: boolean; + notifyOnVoteRequired: boolean; + }; +} + +export type SeasonLifecycleTransition = 'activate' | 'complete' | 'archive' | 'cancel'; + +export interface ManageSeasonLifecycleCommand { + leagueId: string; + seasonId: string; + transition: SeasonLifecycleTransition; +} + +export interface ManageSeasonLifecycleResultDTO { + seasonId: string; + status: import('../../domain/entities/Season').SeasonStatus; + startDate?: Date; + endDate?: Date; +} + +export class SeasonApplicationService { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly seasonRepository: ISeasonRepository, + ) {} + + async createSeasonForLeague(command: CreateSeasonForLeagueCommand): Promise { + const league = await this.leagueRepository.findById(command.leagueId); + if (!league) { + throw new Error(`League not found: ${command.leagueId}`); + } + + let baseSeasonProps: { + schedule?: SeasonSchedule; + scoringConfig?: SeasonScoringConfig; + dropPolicy?: SeasonDropPolicy; + stewardingConfig?: SeasonStewardingConfig; + maxDrivers?: number; + } = {}; + + if (command.sourceSeasonId) { + const source = await this.seasonRepository.findById(command.sourceSeasonId); + if (!source) { + throw new Error(`Source Season not found: ${command.sourceSeasonId}`); + } + baseSeasonProps = { + ...(source.schedule !== undefined ? { schedule: source.schedule } : {}), + ...(source.scoringConfig !== undefined ? { scoringConfig: source.scoringConfig } : {}), + ...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}), + ...(source.stewardingConfig !== undefined ? { stewardingConfig: source.stewardingConfig } : {}), + ...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}), + }; + } else if (command.config) { + baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config); + } + + const seasonId = uuidv4(); + + const season = Season.create({ + id: seasonId, + leagueId: league.id, + gameId: command.gameId, + name: command.name, + year: new Date().getFullYear(), + status: 'planned', + ...(baseSeasonProps?.schedule ? { schedule: baseSeasonProps.schedule } : {}), + ...(baseSeasonProps?.scoringConfig ? { scoringConfig: baseSeasonProps.scoringConfig } : {}), + ...(baseSeasonProps?.dropPolicy ? { dropPolicy: baseSeasonProps.dropPolicy } : {}), + ...(baseSeasonProps?.stewardingConfig ? { stewardingConfig: baseSeasonProps.stewardingConfig } : {}), + ...(baseSeasonProps?.maxDrivers !== undefined ? { maxDrivers: baseSeasonProps.maxDrivers } : {}), + }); + + await this.seasonRepository.add(season); + + return { seasonId }; + } + + async listSeasonsForLeague(query: ListSeasonsForLeagueQuery): Promise { + const league = await this.leagueRepository.findById(query.leagueId); + if (!league) { + throw new Error(`League not found: ${query.leagueId}`); + } + + const seasons = await this.seasonRepository.listByLeague(league.id); + const items: SeasonSummaryDTO[] = seasons.map((s) => ({ + seasonId: s.id, + leagueId: s.leagueId, + name: s.name, + status: s.status, + ...(s.startDate !== undefined ? { startDate: s.startDate } : {}), + ...(s.endDate !== undefined ? { endDate: s.endDate } : {}), + isPrimary: false, + })); + + return { items }; + } + + async getSeasonDetails(query: GetSeasonDetailsQuery): Promise { + const league = await this.leagueRepository.findById(query.leagueId); + if (!league) { + throw new Error(`League not found: ${query.leagueId}`); + } + + const season = await this.seasonRepository.findById(query.seasonId); + if (!season || season.leagueId !== league.id) { + throw new Error(`Season ${query.seasonId} does not belong to league ${league.id}`); + } + + return { + seasonId: season.id, + leagueId: season.leagueId, + gameId: season.gameId, + name: season.name, + status: season.status, + ...(season.startDate !== undefined ? { startDate: season.startDate } : {}), + ...(season.endDate !== undefined ? { endDate: season.endDate } : {}), + ...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}), + ...(season.schedule + ? { + schedule: { + startDate: season.schedule.startDate, + plannedRounds: season.schedule.plannedRounds, + }, + } + : {}), + ...(season.scoringConfig + ? { + scoring: { + scoringPresetId: season.scoringConfig.scoringPresetId, + customScoringEnabled: season.scoringConfig.customScoringEnabled ?? false, + }, + } + : {}), + ...(season.dropPolicy + ? { + dropPolicy: { + strategy: season.dropPolicy.strategy, + ...(season.dropPolicy.n !== undefined ? { n: season.dropPolicy.n } : {}), + }, + } + : {}), + ...(season.stewardingConfig + ? { + stewarding: { + decisionMode: season.stewardingConfig.decisionMode, + ...(season.stewardingConfig.requiredVotes !== undefined + ? { requiredVotes: season.stewardingConfig.requiredVotes } + : {}), + requireDefense: season.stewardingConfig.requireDefense, + defenseTimeLimit: season.stewardingConfig.defenseTimeLimit, + voteTimeLimit: season.stewardingConfig.voteTimeLimit, + protestDeadlineHours: season.stewardingConfig.protestDeadlineHours, + stewardingClosesHours: season.stewardingConfig.stewardingClosesHours, + notifyAccusedOnProtest: season.stewardingConfig.notifyAccusedOnProtest, + notifyOnVoteRequired: season.stewardingConfig.notifyOnVoteRequired, + }, + } + : {}), + }; + } + + async manageSeasonLifecycle(command: ManageSeasonLifecycleCommand): Promise { + const league = await this.leagueRepository.findById(command.leagueId); + if (!league) { + throw new Error(`League not found: ${command.leagueId}`); + } + + const season = await this.seasonRepository.findById(command.seasonId); + if (!season || season.leagueId !== league.id) { + throw new Error(`Season ${command.seasonId} does not belong to league ${league.id}`); + } + + let updated: Season; + switch (command.transition) { + case 'activate': + updated = season.activate(); + break; + case 'complete': + updated = season.complete(); + break; + case 'archive': + updated = season.archive(); + break; + case 'cancel': + updated = season.cancel(); + break; + default: + throw new Error('Unsupported Season lifecycle transition'); + } + + await this.seasonRepository.update(updated); + + return { + seasonId: updated.id, + status: updated.status, + ...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}), + ...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}), + }; + } + + private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): { + schedule?: SeasonSchedule; + scoringConfig?: SeasonScoringConfig; + dropPolicy?: SeasonDropPolicy; + stewardingConfig?: SeasonStewardingConfig; + maxDrivers?: number; + } { + const schedule = this.buildScheduleFromTimings(config); + const scoringConfig = new SeasonScoringConfig({ + scoringPresetId: config.scoring.patternId ?? 'custom', + customScoringEnabled: config.scoring.customScoringEnabled ?? false, + }); + const dropPolicy = new SeasonDropPolicy({ + strategy: config.dropPolicy.strategy, + ...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}), + }); + const stewardingConfig = new SeasonStewardingConfig({ + decisionMode: config.stewarding.decisionMode, + ...(config.stewarding.requiredVotes !== undefined + ? { requiredVotes: config.stewarding.requiredVotes } + : {}), + requireDefense: config.stewarding.requireDefense, + defenseTimeLimit: config.stewarding.defenseTimeLimit, + voteTimeLimit: config.stewarding.voteTimeLimit, + protestDeadlineHours: config.stewarding.protestDeadlineHours, + stewardingClosesHours: config.stewarding.stewardingClosesHours, + notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest, + notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired, + }); + + const structure = config.structure; + const maxDrivers = + typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0 + ? structure.maxDrivers + : undefined; + + return { + ...(schedule !== undefined ? { schedule } : {}), + scoringConfig, + dropPolicy, + stewardingConfig, + ...(maxDrivers !== undefined ? { maxDrivers } : {}), + }; + } + + private buildScheduleFromTimings(config: LeagueConfigFormModel): SeasonSchedule | undefined { + const { timings } = config; + if (!timings.seasonStartDate || !timings.raceStartTime) { + return undefined; + } + + const startDate = new Date(timings.seasonStartDate); + const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime); + const timezoneId = timings.timezoneId ?? 'UTC'; + const timezone = new LeagueTimezone(timezoneId); + + const plannedRounds = + typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0 + ? timings.roundsPlanned + : timings.sessionCount; + + const recurrence = (() => { + const weekdays: WeekdaySet = + timings.weekdays && timings.weekdays.length > 0 + ? WeekdaySet.fromArray(timings.weekdays as unknown as Weekday[]) + : WeekdaySet.fromArray(['Mon']); + switch (timings.recurrenceStrategy) { + case 'everyNWeeks': + return RecurrenceStrategyFactory.everyNWeeks( + timings.intervalWeeks ?? 2, + weekdays, + ); + case 'monthlyNthWeekday': { + const pattern = new MonthlyRecurrencePattern({ + ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4, + weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday, + }); + return RecurrenceStrategyFactory.monthlyNthWeekday(pattern); + } + case 'weekly': + default: + return RecurrenceStrategyFactory.weekly(weekdays); + } + })(); + + return new SeasonSchedule({ + startDate, + timeOfDay, + timezone, + recurrence, + plannedRounds, + }); + } +} diff --git a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts index 4e874ca85..8a9e7f6c5 100644 --- a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts +++ b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts @@ -10,9 +10,8 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import { Money } from '../../domain/value-objects/Money'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; -import type { Logger } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplyForSponsorshipPort } from '../ports/input/ApplyForSponsorshipPort'; import type { ApplyForSponsorshipResultPort } from '../ports/output/ApplyForSponsorshipResultPort'; diff --git a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts index f9fbbc82b..3f79cfd9c 100644 --- a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts +++ b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts @@ -11,9 +11,8 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { randomUUID } from 'crypto'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; -import type { Logger } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplyPenaltyCommandPort } from '../ports/input/ApplyPenaltyCommandPort'; diff --git a/core/racing/application/use-cases/CancelRaceUseCase.ts b/core/racing/application/use-cases/CancelRaceUseCase.ts index f7631ec6d..c6fd7d77f 100644 --- a/core/racing/application/use-cases/CancelRaceUseCase.ts +++ b/core/racing/application/use-cases/CancelRaceUseCase.ts @@ -1,6 +1,5 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO'; diff --git a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts index 836b6c0bc..631f2ff3b 100644 --- a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts +++ b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts @@ -1,5 +1,4 @@ -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts index c21afef06..76b0587b2 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts @@ -4,8 +4,7 @@ import { Season } from '../../domain/entities/Season'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort'; import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort'; import { diff --git a/core/racing/application/use-cases/CreateSponsorUseCase.ts b/core/racing/application/use-cases/CreateSponsorUseCase.ts index c40177f84..56d6a9fea 100644 --- a/core/racing/application/use-cases/CreateSponsorUseCase.ts +++ b/core/racing/application/use-cases/CreateSponsorUseCase.ts @@ -6,8 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Sponsor } from '../../domain/entities/Sponsor'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CreateSponsorOutputPort } from '../ports/output/CreateSponsorOutputPort'; diff --git a/core/racing/application/use-cases/CreateTeamUseCase.ts b/core/racing/application/use-cases/CreateTeamUseCase.ts index a56dc89fe..fd0bbee80 100644 --- a/core/racing/application/use-cases/CreateTeamUseCase.ts +++ b/core/racing/application/use-cases/CreateTeamUseCase.ts @@ -12,8 +12,7 @@ import type { TeamMembershipStatus, TeamRole, } from '../../domain/types/TeamMembership'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CreateTeamOutputPort } from '../ports/output/CreateTeamOutputPort'; diff --git a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts index c53baba4c..679898067 100644 --- a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts @@ -5,8 +5,7 @@ import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarO import type { TeamJoinRequestsOutputPort } from '../ports/output/TeamJoinRequestsOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; /** * Use Case for retrieving team join requests. diff --git a/core/racing/application/use-cases/GetTeamMembersUseCase.ts b/core/racing/application/use-cases/GetTeamMembersUseCase.ts index 6ea911c58..283f5bf25 100644 --- a/core/racing/application/use-cases/GetTeamMembersUseCase.ts +++ b/core/racing/application/use-cases/GetTeamMembersUseCase.ts @@ -5,8 +5,7 @@ import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarO import type { TeamMembersOutputPort } from '../ports/output/TeamMembersOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; /** * Use Case for retrieving team members. diff --git a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts index 97b8a1a10..3a2f1b34a 100644 --- a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts @@ -5,8 +5,7 @@ import type { TeamsLeaderboardOutputPort, SkillLevel } from '../ports/output/Tea import { SkillLevelService } from '@core/racing/domain/services/SkillLevelService'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; interface DriverStatsAdapter { rating: number | null; diff --git a/core/racing/application/use-cases/GetTotalDriversUseCase.ts b/core/racing/application/use-cases/GetTotalDriversUseCase.ts index 7245317fb..8fa659c8e 100644 --- a/core/racing/application/use-cases/GetTotalDriversUseCase.ts +++ b/core/racing/application/use-cases/GetTotalDriversUseCase.ts @@ -2,8 +2,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit import type { TotalDriversOutputPort } from '../ports/output/TotalDriversOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; /** * Use Case for retrieving total number of drivers. diff --git a/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts b/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts index d384f5dcd..3bd1a8a2c 100644 --- a/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts +++ b/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts @@ -2,8 +2,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { GetTotalLeaguesOutputPort } from '../ports/output/GetTotalLeaguesOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; export class GetTotalLeaguesUseCase implements AsyncUseCase { diff --git a/core/racing/application/use-cases/GetTotalRacesUseCase.ts b/core/racing/application/use-cases/GetTotalRacesUseCase.ts index f27d659b5..c22251c3e 100644 --- a/core/racing/application/use-cases/GetTotalRacesUseCase.ts +++ b/core/racing/application/use-cases/GetTotalRacesUseCase.ts @@ -2,8 +2,7 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { GetTotalRacesOutputPort } from '../ports/output/GetTotalRacesOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; export class GetTotalRacesUseCase implements AsyncUseCase { diff --git a/core/racing/application/use-cases/JoinLeagueUseCase.ts b/core/racing/application/use-cases/JoinLeagueUseCase.ts index 7e63a1367..d1a8bef94 100644 --- a/core/racing/application/use-cases/JoinLeagueUseCase.ts +++ b/core/racing/application/use-cases/JoinLeagueUseCase.ts @@ -1,7 +1,6 @@ -import type { Logger } from '@core/shared/application'; +import type { Logger , AsyncUseCase } from '@core/shared/application'; import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import { LeagueMembership, type MembershipRole, type MembershipStatus } from '../../domain/entities/LeagueMembership'; -import type { AsyncUseCase } from '@core/shared/application'; import { Result as SharedResult } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { JoinLeagueOutputPort } from '../ports/output/JoinLeagueOutputPort'; diff --git a/core/racing/application/use-cases/QuickPenaltyUseCase.ts b/core/racing/application/use-cases/QuickPenaltyUseCase.ts index b4bd50848..2c6daed8d 100644 --- a/core/racing/application/use-cases/QuickPenaltyUseCase.ts +++ b/core/racing/application/use-cases/QuickPenaltyUseCase.ts @@ -10,8 +10,7 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepos import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { randomUUID } from 'crypto'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; diff --git a/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts index 4b1875838..63da79b6c 100644 --- a/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts +++ b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts @@ -1,26 +1,36 @@ -import { RejectLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/RejectLeagueJoinRequestUseCase'; -import { RejectLeagueJoinRequestPresenter } from '@apps/api/src/modules/league/presenters/RejectLeagueJoinRequestPresenter'; +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { RejectLeagueJoinRequestUseCase, type RejectLeagueJoinRequestUseCaseParams } from './RejectLeagueJoinRequestUseCase'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +interface LeagueMembershipRepositoryMock { + removeJoinRequest: Mock; +} describe('RejectLeagueJoinRequestUseCase', () => { + let leagueMembershipRepository: LeagueMembershipRepositoryMock; let useCase: RejectLeagueJoinRequestUseCase; - let leagueMembershipRepository: jest.Mocked; - let presenter: RejectLeagueJoinRequestPresenter; beforeEach(() => { leagueMembershipRepository = { - removeJoinRequest: jest.fn(), - } as unknown; - presenter = new RejectLeagueJoinRequestPresenter(); - useCase = new RejectLeagueJoinRequestUseCase(leagueMembershipRepository); + removeJoinRequest: vi.fn(), + } as unknown as ILeagueMembershipRepository as any; + + useCase = new RejectLeagueJoinRequestUseCase( + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + ); }); - it('should reject join request', async () => { - const requestId = 'req-1'; + it('removes the join request and returns success output', async () => { + const params: RejectLeagueJoinRequestUseCaseParams = { + requestId: 'req-1', + }; - await useCase.execute({ requestId }, presenter); + (leagueMembershipRepository.removeJoinRequest as Mock).mockResolvedValue(undefined); - expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId); - expect(presenter.viewModel).toEqual({ success: true, message: 'Join request rejected.' }); + const result = await useCase.execute(params); + + expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith('req-1'); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ success: true, message: 'Join request rejected.' }); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/ReopenRaceUseCase.ts b/core/racing/application/use-cases/ReopenRaceUseCase.ts index a975b3135..40ad47de7 100644 --- a/core/racing/application/use-cases/ReopenRaceUseCase.ts +++ b/core/racing/application/use-cases/ReopenRaceUseCase.ts @@ -1,6 +1,5 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ReopenRaceCommandDTO } from '../dto/ReopenRaceCommandDTO'; diff --git a/core/racing/domain/types/ChampionshipConfig.ts b/core/racing/domain/types/ChampionshipConfig.ts index 4deca001c..da142dc99 100644 --- a/core/racing/domain/types/ChampionshipConfig.ts +++ b/core/racing/domain/types/ChampionshipConfig.ts @@ -1,8 +1,8 @@ -import type { ChampionshipType } from '../types/ChampionshipType'; -import type { SessionType } from '../types/SessionType'; +import type { ChampionshipType } from "./ChampionshipType"; +import type { SessionType } from "./SessionType"; import type { PointsTable } from '../value-objects/PointsTable'; -import type { BonusRule } from '../types/BonusRule'; -import type { DropScorePolicy } from '../types/DropScorePolicy'; +import type { BonusRule } from "./BonusRule"; +import type { DropScorePolicy } from "./DropScorePolicy"; /** * Domain Type: ChampionshipConfig diff --git a/core/racing/index.ts b/core/racing/index.ts index b856a0cbc..32b6a5e18 100644 --- a/core/racing/index.ts +++ b/core/racing/index.ts @@ -37,10 +37,6 @@ export * from './domain/repositories/ISponsorRepository'; export * from './domain/repositories/ISeasonSponsorshipRepository'; export * from './domain/repositories/ISponsorshipRequestRepository'; export * from './domain/repositories/ISponsorshipPricingRepository'; -export * from './infrastructure/repositories/InMemorySponsorRepository'; -export * from './infrastructure/repositories/InMemorySeasonSponsorshipRepository'; -export * from './infrastructure/repositories/InMemorySponsorshipRequestRepository'; -export * from './infrastructure/repositories/InMemorySponsorshipPricingRepository'; export * from './application/dtos/LeagueDriverSeasonStatsDTO'; export * from './application/dtos/LeagueScoringConfigDTO'; @@ -58,4 +54,4 @@ export * from './application/use-cases/RejectSponsorshipRequestUseCase'; export * from './application/use-cases/GetPendingSponsorshipRequestsUseCase'; export * from './application/use-cases/GetEntitySponsorshipPricingUseCase'; -export * from './application/ports/output/CreateSponsorOutputPort'; \ No newline at end of file +export * from './application/ports/output/CreateSponsorOutputPort'; diff --git a/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts b/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts index 88c162a20..d1b095fdb 100644 --- a/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts +++ b/core/social/application/use-cases/GetCurrentUserSocialUseCase.ts @@ -1,5 +1,4 @@ -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { ISocialGraphRepository } from '../../domain/repositories/ISocialGraphRepository'; import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO'; import type { FriendDTO } from '../dto/FriendDTO'; diff --git a/core/social/application/use-cases/GetUserFeedUseCase.ts b/core/social/application/use-cases/GetUserFeedUseCase.ts index 5d36cd0ac..85b558f16 100644 --- a/core/social/application/use-cases/GetUserFeedUseCase.ts +++ b/core/social/application/use-cases/GetUserFeedUseCase.ts @@ -1,4 +1,4 @@ -import type { AsyncUseCase } from '@core/shared/application'; +import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { IFeedRepository } from '../../domain/repositories/IFeedRepository'; import type { FeedItemDTO } from '../dto/FeedItemDTO'; import type { FeedItem } from '../../domain/types/FeedItem'; @@ -6,7 +6,6 @@ import type { IUserFeedPresenter, UserFeedViewModel, } from '../presenters/ISocialPresenters'; -import type { Logger } from '@core/shared/application'; export interface GetUserFeedParams { driverId: string;