diff --git a/adapters/analytics/persistence/inmemory/InMemoryAnalyticsSnapshotRepository.test.ts b/adapters/analytics/persistence/inmemory/InMemoryAnalyticsSnapshotRepository.test.ts new file mode 100644 index 000000000..da8440d84 --- /dev/null +++ b/adapters/analytics/persistence/inmemory/InMemoryAnalyticsSnapshotRepository.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { AnalyticsSnapshot } from '@core/analytics'; +import { InMemoryAnalyticsSnapshotRepository } from './InMemoryAnalyticsSnapshotRepository'; + +describe('InMemoryAnalyticsSnapshotRepository', () => { + let repository: InMemoryAnalyticsSnapshotRepository; + let mockLogger: Logger; + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryAnalyticsSnapshotRepository(mockLogger); + repository.clear(); + }); + + it('initializes with logger', () => { + expect(repository).toBeDefined(); + expect(mockLogger.info).toHaveBeenCalledWith('InMemoryAnalyticsSnapshotRepository initialized.'); + }); + + it('saves and finds by id', async () => { + const snapshot = AnalyticsSnapshot.createEmpty( + 'snap-1', + 'league', + 'league-1', + 'monthly', + new Date('2025-01-01T00:00:00.000Z'), + new Date('2025-02-01T00:00:00.000Z'), + ); + + await repository.save(snapshot); + + const found = await repository.findById('snap-1'); + expect(found).toBe(snapshot); + }); + + it('finds by entity and latest', async () => { + const a = AnalyticsSnapshot.createEmpty( + 'snap-a', + 'league', + 'league-1', + 'monthly', + new Date('2025-01-01T00:00:00.000Z'), + new Date('2025-02-01T00:00:00.000Z'), + ); + const b = AnalyticsSnapshot.createEmpty( + 'snap-b', + 'league', + 'league-1', + 'monthly', + new Date('2025-02-01T00:00:00.000Z'), + new Date('2025-03-01T00:00:00.000Z'), + ); + const other = AnalyticsSnapshot.createEmpty( + 'snap-other', + 'team', + 'team-1', + 'monthly', + new Date('2025-01-01T00:00:00.000Z'), + new Date('2025-02-01T00:00:00.000Z'), + ); + + await repository.save(a); + await repository.save(b); + await repository.save(other); + + const byEntity = await repository.findByEntity('league', 'league-1'); + expect(byEntity.map(s => s.id).sort()).toEqual(['snap-a', 'snap-b']); + + const latest = await repository.findLatest('league', 'league-1', 'monthly'); + expect(latest?.id).toBe('snap-b'); + + const history = await repository.getHistoricalSnapshots('league', 'league-1', 'monthly', 1); + expect(history.map(s => s.id)).toEqual(['snap-b']); + }); + + it('finds by period range', async () => { + const snapshot = AnalyticsSnapshot.createEmpty( + 'snap-period', + 'league', + 'league-1', + 'monthly', + new Date('2025-01-01T00:00:00.000Z'), + new Date('2025-02-01T00:00:00.000Z'), + ); + + await repository.save(snapshot); + + const found = await repository.findByPeriod( + 'league', + 'league-1', + 'monthly', + new Date('2024-12-15T00:00:00.000Z'), + new Date('2025-03-15T00:00:00.000Z'), + ); + + expect(found?.id).toBe('snap-period'); + }); +}); diff --git a/adapters/analytics/persistence/inmemory/InMemoryEngagementRepository.test.ts b/adapters/analytics/persistence/inmemory/InMemoryEngagementRepository.test.ts new file mode 100644 index 000000000..a9fd9c041 --- /dev/null +++ b/adapters/analytics/persistence/inmemory/InMemoryEngagementRepository.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { EngagementEvent } from '@core/analytics'; +import { InMemoryEngagementRepository } from './InMemoryEngagementRepository'; + +describe('InMemoryEngagementRepository', () => { + let repository: InMemoryEngagementRepository; + let mockLogger: Logger; + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryEngagementRepository(mockLogger); + repository.clear(); + }); + + it('initializes with a logger', () => { + expect(repository).toBeDefined(); + expect(mockLogger.info).toHaveBeenCalledWith('InMemoryEngagementRepository initialized.'); + }); + + it('saves and queries engagement events', async () => { + const now = new Date('2025-01-01T12:00:00.000Z'); + const event1 = EngagementEvent.create({ + id: 'e1', + action: 'click_sponsor_logo', + entityType: 'league', + entityId: 'league-1', + actorType: 'driver', + actorId: 'driver-1', + sessionId: 's1', + timestamp: now, + }); + const event2 = EngagementEvent.create({ + id: 'e2', + action: 'view_schedule', + entityType: 'league', + entityId: 'league-1', + actorType: 'anonymous', + sessionId: 's2', + timestamp: new Date(now.getTime() + 1000), + }); + + await repository.save(event1); + await repository.save(event2); + + expect((await repository.findById('e1'))?.id).toBe('e1'); + expect((await repository.findByEntityId('league', 'league-1')).length).toBe(2); + expect((await repository.findByAction('view_schedule')).map(e => e.id)).toEqual(['e2']); + + const inRange = await repository.findByDateRange( + new Date('2025-01-01T00:00:00.000Z'), + new Date('2025-01-01T23:59:59.999Z'), + ); + expect(inRange.length).toBe(2); + + const clicks = await repository.getSponsorClicksForEntity('league-1'); + expect(clicks).toBe(1); + + const counted = await repository.countByAction('view_schedule', 'league-1'); + expect(counted).toBe(1); + }); + + it('seeds events', async () => { + const e = EngagementEvent.create({ + id: 'seed-1', + action: 'view_schedule', + entityType: 'league', + entityId: 'league-2', + actorType: 'anonymous', + sessionId: 's3', + }); + + repository.seed([e]); + + expect((await repository.findById('seed-1'))?.id).toBe('seed-1'); + }); +}); diff --git a/adapters/analytics/persistence/inmemory/InMemoryPageViewRepository.test.ts b/adapters/analytics/persistence/inmemory/InMemoryPageViewRepository.test.ts new file mode 100644 index 000000000..aa643c276 --- /dev/null +++ b/adapters/analytics/persistence/inmemory/InMemoryPageViewRepository.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { PageView } from '@core/analytics'; +import { InMemoryPageViewRepository } from './InMemoryPageViewRepository'; + +describe('InMemoryPageViewRepository', () => { + let repository: InMemoryPageViewRepository; + let mockLogger: Logger; + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryPageViewRepository(mockLogger); + repository.clear(); + }); + + it('initializes with a logger', () => { + expect(repository).toBeDefined(); + expect(mockLogger.info).toHaveBeenCalledWith('InMemoryPageViewRepository initialized.'); + }); + + it('saves and queries page views', async () => { + const pv1 = PageView.create({ + id: 'pv-1', + entityType: 'league', + entityId: 'league-1', + visitorType: 'anonymous', + sessionId: 'sess-1', + visitorId: 'visitor-1', + timestamp: new Date('2025-01-01T10:00:00.000Z'), + }); + const pv2 = PageView.create({ + id: 'pv-2', + entityType: 'league', + entityId: 'league-1', + visitorType: 'anonymous', + sessionId: 'sess-2', + visitorId: 'visitor-1', + timestamp: new Date('2025-01-02T10:00:00.000Z'), + }); + const pv3 = PageView.create({ + id: 'pv-3', + entityType: 'league', + entityId: 'league-1', + visitorType: 'anonymous', + sessionId: 'sess-3', + // no visitorId; should fall back to sessionId for uniqueness + timestamp: new Date('2025-01-02T11:00:00.000Z'), + }); + + await repository.save(pv1); + await repository.save(pv2); + await repository.save(pv3); + + expect((await repository.findById('pv-1'))?.id).toBe('pv-1'); + expect((await repository.findByEntityId('league', 'league-1')).length).toBe(3); + + const range = await repository.findByDateRange( + new Date('2025-01-02T00:00:00.000Z'), + new Date('2025-01-02T23:59:59.999Z'), + ); + expect(range.map(p => p.id).sort()).toEqual(['pv-2', 'pv-3']); + + expect((await repository.findBySession('sess-2')).map(p => p.id)).toEqual(['pv-2']); + + const count = await repository.countByEntityId('league', 'league-1'); + expect(count).toBe(3); + + const unique = await repository.countUniqueVisitors('league', 'league-1'); + // visitor-1 + sess-3 + expect(unique).toBe(2); + }); + + it('seeds page views', async () => { + const pv = PageView.create({ + id: 'seed-pv', + entityType: 'team', + entityId: 'team-1', + visitorType: 'anonymous', + sessionId: 'sess-seed', + }); + + repository.seed([pv]); + expect((await repository.findById('seed-pv'))?.id).toBe('seed-pv'); + }); +}); diff --git a/adapters/bootstrap/ScoringDemoSetup.ts b/adapters/bootstrap/ScoringDemoSetup.ts index 4184f6a19..050ee5462 100644 --- a/adapters/bootstrap/ScoringDemoSetup.ts +++ b/adapters/bootstrap/ScoringDemoSetup.ts @@ -1,10 +1,12 @@ import { Game } from '@core/racing/domain/entities/Game'; import { Season } from '@core/racing/domain/entities/season/Season'; import type { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; -import { InMemoryGameRepository } from '../racing/persistence/inmemory/InMemoryScoringRepositories'; -import { InMemorySeasonRepository } from '../racing/persistence/inmemory/InMemoryScoringRepositories'; -import { InMemoryLeagueScoringConfigRepository } from '../racing/persistence/inmemory/InMemoryScoringRepositories'; -import { InMemoryChampionshipStandingRepository } from '../racing/persistence/inmemory/InMemoryScoringRepositories'; +import { + InMemoryChampionshipStandingRepository, + InMemoryGameRepository, + InMemoryLeagueScoringConfigRepository, + InMemorySeasonRepository, +} from '../racing/persistence/inmemory/InMemoryScoringRepositories'; import type { Logger } from '@core/shared/application'; import { getLeagueScoringPresetById } from './LeagueScoringPresets'; diff --git a/adapters/identity/persistence/inmemory/InMemoryAchievementRepository.test.ts b/adapters/identity/persistence/inmemory/InMemoryAchievementRepository.test.ts new file mode 100644 index 000000000..095d592be --- /dev/null +++ b/adapters/identity/persistence/inmemory/InMemoryAchievementRepository.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import type { UserAchievement } from '@core/identity'; +import { InMemoryAchievementRepository } from './InMemoryAchievementRepository'; + +describe('InMemoryAchievementRepository (identity)', () => { + let repository: InMemoryAchievementRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryAchievementRepository(logger); + }); + + it('seeds predefined achievements', async () => { + const all = await repository.findAllAchievements(); + expect(all.length).toBeGreaterThan(0); + + const first = all[0]!; + const found = await repository.findAchievementById(first.id); + expect(found?.id).toBe(first.id); + }); + + it('creates and queries user achievements and stats', async () => { + const all = await repository.findAllAchievements(); + const achievement = all[0]!; + const userId = 'user-1'; + + const ua: UserAchievement = { + id: 'ua-1', + userId, + achievementId: achievement.id, + isComplete: () => true, + } as unknown as UserAchievement; + + await repository.createUserAchievement(ua); + + expect(await repository.hasUserEarnedAchievement(userId, achievement.id)).toBe(true); + + const leaderboard = await repository.getAchievementLeaderboard(10); + expect(leaderboard.length).toBe(1); + expect(leaderboard[0]?.userId).toBe(userId); + expect(leaderboard[0]?.count).toBe(1); + + const stats = await repository.getUserAchievementStats(userId); + expect(stats.total).toBe(1); + expect(stats.points).toBeGreaterThan(0); + expect(Object.values(stats.byCategory).reduce((a, b) => a + b, 0)).toBe(1); + }); +}); diff --git a/adapters/identity/persistence/inmemory/InMemoryAuthRepository.test.ts b/adapters/identity/persistence/inmemory/InMemoryAuthRepository.test.ts new file mode 100644 index 000000000..a6dfae220 --- /dev/null +++ b/adapters/identity/persistence/inmemory/InMemoryAuthRepository.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { UserId } from '@core/identity'; +import { User } from '@core/identity/domain/entities/User'; +import { InMemoryUserRepository } from './InMemoryUserRepository'; +import { InMemoryAuthRepository } from './InMemoryAuthRepository'; +import { InMemoryPasswordHashingService } from '../../services/InMemoryPasswordHashingService'; + +describe('InMemoryAuthRepository', () => { + let mockLogger: Logger; + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + }); + + it('creates and verifies a user password', async () => { + const userRepo = new InMemoryUserRepository(mockLogger); + const passwordService = new InMemoryPasswordHashingService(); + const authRepo = new InMemoryAuthRepository(userRepo, passwordService, mockLogger); + + const user = User.create({ + id: UserId.fromString('user-1'), + displayName: 'Test User', + email: 'test@example.com', + }); + + const created = await authRepo.create(user, 'password123'); + expect(created.getEmail()).toBe('test@example.com'); + + expect(await authRepo.userExists('test@example.com')).toBe(true); + + const ok = await authRepo.verifyPassword('test@example.com', 'password123'); + expect(ok).not.toBeNull(); + expect(ok?.getId().value).toBe('user-1'); + + const bad = await authRepo.verifyPassword('test@example.com', 'wrong'); + expect(bad).toBeNull(); + }); + + it('save updates existing user', async () => { + const userRepo = new InMemoryUserRepository(mockLogger); + const passwordService = new InMemoryPasswordHashingService(); + const authRepo = new InMemoryAuthRepository(userRepo, passwordService, mockLogger); + + const user = User.create({ + id: UserId.fromString('user-2'), + displayName: 'User Two', + email: 'two@example.com', + }); + + await authRepo.create(user, 'pw'); + + const updated = User.create({ + id: UserId.fromString('user-2'), + displayName: 'User Two Updated', + email: 'two@example.com', + }); + + await authRepo.save(updated); + + const stored = await userRepo.findById('user-2'); + expect(stored?.displayName).toBe('User Two Updated'); + }); +}); diff --git a/adapters/identity/persistence/inmemory/InMemorySponsorAccountRepository.test.ts b/adapters/identity/persistence/inmemory/InMemorySponsorAccountRepository.test.ts new file mode 100644 index 000000000..2c639c0fb --- /dev/null +++ b/adapters/identity/persistence/inmemory/InMemorySponsorAccountRepository.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { UserId, type SponsorAccount } from '@core/identity'; +import { InMemorySponsorAccountRepository } from './InMemorySponsorAccountRepository'; + +describe('InMemorySponsorAccountRepository', () => { + let repository: InMemorySponsorAccountRepository; + let logger: Logger; + + const makeAccount = (id: string, sponsorId: string, email: string): SponsorAccount => { + return { + getId: () => ({ value: id }), + getSponsorId: () => sponsorId, + getEmail: () => email, + } as unknown as SponsorAccount; + }; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemorySponsorAccountRepository(logger); + repository.clear(); + }); + + it('saves and finds sponsor accounts by id / sponsorId / email', async () => { + const account = makeAccount('user-1', 'sponsor-1', 'Sponsor@Example.com'); + + await repository.save(account); + + expect(await repository.findById(UserId.fromString('user-1'))).toBe(account); + expect(await repository.findBySponsorId('sponsor-1')).toBe(account); + expect(await repository.findByEmail('sponsor@example.com')).toBe(account); + }); + + it('deletes sponsor accounts', async () => { + const account = makeAccount('user-del', 'sponsor-del', 'del@example.com'); + await repository.save(account); + + await repository.delete(UserId.fromString('user-del')); + expect(await repository.findById(UserId.fromString('user-del'))).toBeNull(); + }); + + it('seeds via constructor', async () => { + const seeded = makeAccount('user-seed', 'sponsor-seed', 'seed@example.com'); + repository = new InMemorySponsorAccountRepository(logger, [seeded]); + + expect(await repository.findBySponsorId('sponsor-seed')).toBe(seeded); + }); +}); diff --git a/adapters/identity/persistence/inmemory/InMemoryUserRatingRepository.test.ts b/adapters/identity/persistence/inmemory/InMemoryUserRatingRepository.test.ts new file mode 100644 index 000000000..eca75b227 --- /dev/null +++ b/adapters/identity/persistence/inmemory/InMemoryUserRatingRepository.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import type { UserRating } from '@core/identity'; +import { InMemoryUserRatingRepository } from './InMemoryUserRatingRepository'; + +describe('InMemoryUserRatingRepository', () => { + let repository: InMemoryUserRatingRepository; + let mockLogger: Logger; + + const rating = (userId: string, driverValue: number, trustValue: number, canSteward: boolean): UserRating => { + return { + userId, + driver: { sampleSize: 10, value: driverValue }, + trust: { sampleSize: 5, value: trustValue }, + canBeSteward: () => canSteward, + getDriverTier: () => (driverValue >= 2400 ? 'elite' : 'rookie'), + } as unknown as UserRating; + }; + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryUserRatingRepository(mockLogger); + repository.clear(); + }); + + it('saves and finds ratings', async () => { + await repository.save(rating('u1', 2300, 50, false)); + await repository.save(rating('u2', 2500, 70, true)); + + expect((await repository.findByUserId('u1'))?.userId).toBe('u1'); + expect((await repository.findByUserIds(['u1', 'u2'])).length).toBe(2); + + const topDrivers = await repository.getTopDrivers(1); + expect(topDrivers[0]?.userId).toBe('u2'); + + const topTrusted = await repository.getTopTrusted(1); + expect(topTrusted[0]?.userId).toBe('u2'); + + const stewards = await repository.getEligibleStewards(); + expect(stewards.map(r => r.userId)).toEqual(['u2']); + + const elite = await repository.findByDriverTier('elite'); + expect(elite.map(r => r.userId)).toEqual(['u2']); + }); + + it('deletes ratings', async () => { + await repository.save(rating('u3', 2200, 10, false)); + await repository.delete('u3'); + expect(await repository.findByUserId('u3')).toBeNull(); + }); +}); diff --git a/adapters/identity/persistence/inmemory/InMemoryUserRepository.test.ts b/adapters/identity/persistence/inmemory/InMemoryUserRepository.test.ts new file mode 100644 index 000000000..eeeab7b72 --- /dev/null +++ b/adapters/identity/persistence/inmemory/InMemoryUserRepository.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import type { StoredUser } from '@core/identity/domain/repositories/IUserRepository'; +import { InMemoryUserRepository } from './InMemoryUserRepository'; + +describe('InMemoryUserRepository', () => { + let repository: InMemoryUserRepository; + let mockLogger: Logger; + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + const seeded: StoredUser[] = [ + { + id: 'u1', + email: 'seed@example.com', + displayName: 'Seed', + passwordHash: 'hash', + salt: 'salt', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }, + ]; + + repository = new InMemoryUserRepository(mockLogger, seeded); + }); + + it('finds by email and id', async () => { + expect((await repository.findByEmail('seed@example.com'))?.id).toBe('u1'); + expect((await repository.findById('u1'))?.email).toBe('seed@example.com'); + expect(await repository.findByEmail('missing@example.com')).toBeNull(); + }); + + it('creates and rejects duplicate emails', async () => { + const user: StoredUser = { + id: 'u2', + email: 'new@example.com', + displayName: 'New', + passwordHash: 'hash2', + salt: 'salt2', + createdAt: new Date(), + }; + + await repository.create(user); + expect(await repository.emailExists('new@example.com')).toBe(true); + + await expect(repository.create({ ...user, id: 'u3' })).rejects.toThrow('Email already exists'); + }); + + it('updates and maintains email index', async () => { + const existing = await repository.findById('u1'); + expect(existing).not.toBeNull(); + + const updated: StoredUser = { + ...(existing as StoredUser), + email: 'changed@example.com', + displayName: 'Changed', + }; + + await repository.update(updated); + + expect(await repository.findByEmail('seed@example.com')).toBeNull(); + expect((await repository.findByEmail('changed@example.com'))?.displayName).toBe('Changed'); + }); +}); diff --git a/adapters/identity/services/InMemoryPasswordHashingService.test.ts b/adapters/identity/services/InMemoryPasswordHashingService.test.ts new file mode 100644 index 000000000..aff296165 --- /dev/null +++ b/adapters/identity/services/InMemoryPasswordHashingService.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryPasswordHashingService } from './InMemoryPasswordHashingService'; + +describe('InMemoryPasswordHashingService', () => { + it('hashes and verifies deterministically', async () => { + const service = new InMemoryPasswordHashingService(); + + const hash = await service.hash('secret'); + expect(hash).toBe('demo_salt_terces'); + + expect(await service.verify('secret', hash)).toBe(true); + expect(await service.verify('wrong', hash)).toBe(false); + }); +}); diff --git a/adapters/logging/ConsoleLogger.test.ts b/adapters/logging/ConsoleLogger.test.ts index 1be54f2dd..33133352f 100644 --- a/adapters/logging/ConsoleLogger.test.ts +++ b/adapters/logging/ConsoleLogger.test.ts @@ -23,37 +23,47 @@ describe('ConsoleLogger', () => { consoleErrorSpy.mockRestore(); }); - it('should call console.debug with the correct arguments when debug is called', () => { + it('should call console.debug with a formatted message when debug is called', () => { const message = 'Debug message'; const context = { key: 'value' }; logger.debug(message, context); expect(consoleDebugSpy).toHaveBeenCalledTimes(1); - expect(consoleDebugSpy).toHaveBeenCalledWith(message, context); + expect(consoleDebugSpy).toHaveBeenCalledWith( + expect.stringContaining('DEBUG: Debug message | {"key":"value"}'), + ); }); - it('should call console.info with the correct arguments when info is called', () => { + it('should call console.info with a formatted message when info is called', () => { const message = 'Info message'; const context = { key: 'value' }; logger.info(message, context); expect(consoleInfoSpy).toHaveBeenCalledTimes(1); - expect(consoleInfoSpy).toHaveBeenCalledWith(message, context); + expect(consoleInfoSpy).toHaveBeenCalledWith( + expect.stringContaining('INFO: Info message | {"key":"value"}'), + ); }); - it('should call console.warn with the correct arguments when warn is called', () => { + it('should call console.warn with a formatted message when warn is called', () => { const message = 'Warn message'; const context = { key: 'value' }; logger.warn(message, context); expect(consoleWarnSpy).toHaveBeenCalledTimes(1); - expect(consoleWarnSpy).toHaveBeenCalledWith(message, context); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('WARN: Warn message | {"key":"value"}'), + ); }); - it('should call console.error with the correct arguments when error is called', () => { + it('should call console.error with a formatted message when error is called', () => { const message = 'Error message'; const error = new Error('Something went wrong'); const context = { key: 'value' }; logger.error(message, error, context); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - expect(consoleErrorSpy).toHaveBeenCalledWith(message, error, context); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'ERROR: Error message | {"key":"value"} | Error: Something went wrong', + ), + ); }); }); \ No newline at end of file diff --git a/adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository.test.ts b/adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository.test.ts new file mode 100644 index 000000000..0ca3ea771 --- /dev/null +++ b/adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest'; +import { InMemoryAvatarGenerationRepository } from './InMemoryAvatarGenerationRepository'; + +describe('InMemoryAvatarGenerationRepository', () => { + let repository: InMemoryAvatarGenerationRepository; + let mockLogger: Logger; + + beforeEach(() => { + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryAvatarGenerationRepository(mockLogger); + }); + + it('saves and finds latest by user', async () => { + const base1 = AvatarGenerationRequest.create({ + id: 'req-1', + userId: 'user-1', + facePhotoUrl: 'https://example.com/face1.png', + suitColor: 'red', + }); + + const base2 = AvatarGenerationRequest.create({ + id: 'req-2', + userId: 'user-1', + facePhotoUrl: 'https://example.com/face2.png', + suitColor: 'blue', + }); + + const r1Props = base1.toProps(); + const r2Props = base2.toProps(); + + const r1 = AvatarGenerationRequest.reconstitute(r1Props); + const r2 = AvatarGenerationRequest.reconstitute({ + ...r2Props, + createdAt: new Date(r1Props.createdAt.getTime() + 1000), + updatedAt: new Date(r1Props.updatedAt.getTime() + 1000), + }); + + await repository.save(r1); + await repository.save(r2); + + expect((await repository.findById('req-1'))?.id).toBe('req-1'); + expect((await repository.findByUserId('user-1')).length).toBe(2); + + const latest = await repository.findLatestByUserId('user-1'); + expect(latest?.id).toBe('req-2'); + }); + + it('deletes requests', async () => { + const r = AvatarGenerationRequest.create({ + id: 'req-del', + userId: 'user-del', + facePhotoUrl: 'https://example.com/face.png', + suitColor: 'green', + }); + + await repository.save(r); + await repository.delete('req-del'); + + expect(await repository.findById('req-del')).toBeNull(); + }); +}); diff --git a/adapters/media/ports/InMemoryFaceValidationAdapter.test.ts b/adapters/media/ports/InMemoryFaceValidationAdapter.test.ts new file mode 100644 index 000000000..877634b53 --- /dev/null +++ b/adapters/media/ports/InMemoryFaceValidationAdapter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { InMemoryFaceValidationAdapter } from './InMemoryFaceValidationAdapter'; + +describe('InMemoryFaceValidationAdapter', () => { + it('validates face photos as valid (mock)', async () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + const adapter = new InMemoryFaceValidationAdapter(logger); + + const result = await adapter.validateFacePhoto('data'); + expect(result.isValid).toBe(true); + expect(result.hasFace).toBe(true); + expect(result.faceCount).toBe(1); + }); +}); diff --git a/adapters/media/ports/InMemoryImageServiceAdapter.test.ts b/adapters/media/ports/InMemoryImageServiceAdapter.test.ts new file mode 100644 index 000000000..364bd72aa --- /dev/null +++ b/adapters/media/ports/InMemoryImageServiceAdapter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { InMemoryImageServiceAdapter } from './InMemoryImageServiceAdapter'; + +describe('InMemoryImageServiceAdapter', () => { + it('returns mock urls', () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + const adapter = new InMemoryImageServiceAdapter(logger); + + expect(adapter.getDriverAvatar('driver-1')).toContain('/avatars/driver-1.png'); + expect(adapter.getTeamLogo('team-1')).toContain('/logos/team-team-1.png'); + expect(adapter.getLeagueCover('league-1')).toContain('/covers/league-league-1.png'); + expect(adapter.getLeagueLogo('league-1')).toContain('/logos/league-league-1.png'); + }); +}); diff --git a/adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository.test.ts b/adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository.test.ts new file mode 100644 index 000000000..0fd709546 --- /dev/null +++ b/adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { NotificationPreference } from '@core/notifications/domain/entities/NotificationPreference'; +import { InMemoryNotificationPreferenceRepository } from './InMemoryNotificationPreferenceRepository'; + +describe('InMemoryNotificationPreferenceRepository', () => { + let repository: InMemoryNotificationPreferenceRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryNotificationPreferenceRepository(logger); + }); + + it('returns default preferences when missing', async () => { + const pref = await repository.getOrCreateDefault('driver-1'); + expect(pref).toBeInstanceOf(NotificationPreference); + expect(pref.driverId).toBe('driver-1'); + + const found = await repository.findByDriverId('driver-1'); + expect(found?.driverId).toBe('driver-1'); + }); + + it('saves and deletes', async () => { + const pref = NotificationPreference.createDefault('driver-2'); + await repository.save(pref); + + expect((await repository.findByDriverId('driver-2'))?.driverId).toBe('driver-2'); + + await repository.delete('driver-2'); + expect(await repository.findByDriverId('driver-2')).toBeNull(); + }); +}); diff --git a/adapters/notifications/persistence/inmemory/InMemoryNotificationRepository.test.ts b/adapters/notifications/persistence/inmemory/InMemoryNotificationRepository.test.ts new file mode 100644 index 000000000..24bba78ea --- /dev/null +++ b/adapters/notifications/persistence/inmemory/InMemoryNotificationRepository.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { Notification } from '@core/notifications/domain/entities/Notification'; +import { InMemoryNotificationRepository } from './InMemoryNotificationRepository'; + +describe('InMemoryNotificationRepository', () => { + let repository: InMemoryNotificationRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryNotificationRepository(logger); + }); + + it('creates, finds, counts and marks as read', async () => { + const n1 = Notification.create({ + id: 'n1', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'T1', + body: 'B1', + channel: 'in_app', + status: 'unread', + }); + const n2 = Notification.create({ + id: 'n2', + recipientId: 'driver-1', + type: 'system_announcement', + title: 'T2', + body: 'B2', + channel: 'in_app', + status: 'unread', + }); + const n3 = Notification.create({ + id: 'n3', + recipientId: 'driver-2', + type: 'system_announcement', + title: 'T3', + body: 'B3', + channel: 'in_app', + status: 'unread', + }); + + await repository.create(n1); + await repository.create(n2); + await repository.create(n3); + + expect((await repository.findById('n1'))?.id).toBe('n1'); + expect((await repository.findByRecipientId('driver-1')).length).toBe(2); + expect(await repository.countUnreadByRecipientId('driver-1')).toBe(2); + + await repository.markAllAsReadByRecipientId('driver-1'); + expect(await repository.countUnreadByRecipientId('driver-1')).toBe(0); + + const unread = await repository.findUnreadByRecipientId('driver-1'); + expect(unread).toEqual([]); + }); + + it('deletes all by recipient', async () => { + const n = Notification.create({ + id: 'n-del', + recipientId: 'driver-del', + type: 'system_announcement', + title: 'T', + body: 'B', + channel: 'in_app', + }); + + await repository.create(n); + await repository.deleteAllByRecipientId('driver-del'); + + expect(await repository.findById('n-del')).toBeNull(); + }); +}); diff --git a/adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository.test.ts b/adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository.test.ts new file mode 100644 index 000000000..da35fed33 --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryMembershipFeeRepository.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application/Logger'; +import type { MembershipFee } from '@core/payments/domain/entities/MembershipFee'; +import { MembershipFeeType } from '@core/payments/domain/entities/MembershipFee'; +import type { MemberPayment } from '@core/payments/domain/entities/MemberPayment'; +import { MemberPaymentStatus } from '@core/payments/domain/entities/MemberPayment'; +import { InMemoryMemberPaymentRepository, InMemoryMembershipFeeRepository } from './InMemoryMembershipFeeRepository'; + +describe('InMemoryMembershipFeeRepository', () => { + let feeRepo: InMemoryMembershipFeeRepository; + let paymentRepo: InMemoryMemberPaymentRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + feeRepo = new InMemoryMembershipFeeRepository(logger); + paymentRepo = new InMemoryMemberPaymentRepository(logger); + }); + + it('creates and finds membership fees', async () => { + const fee: MembershipFee = { + id: 'fee-1', + leagueId: 'league-1', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + await feeRepo.create(fee); + + expect((await feeRepo.findById('fee-1'))?.id).toBe('fee-1'); + expect((await feeRepo.findByLeagueId('league-1'))?.id).toBe('fee-1'); + + const updated = await feeRepo.update({ + ...fee, + amount: 120, + updatedAt: new Date('2025-01-02T00:00:00.000Z'), + }); + expect(updated.amount).toBe(120); + }); + + it('creates and queries member payments by league via fee lookup', async () => { + const fee1: MembershipFee = { + id: 'fee-a', + leagueId: 'league-a', + type: MembershipFeeType.SEASON, + amount: 100, + enabled: true, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + }; + const fee2: MembershipFee = { + id: 'fee-b', + leagueId: 'league-b', + type: MembershipFeeType.SEASON, + amount: 50, + enabled: true, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + await feeRepo.create(fee1); + await feeRepo.create(fee2); + + const p1: MemberPayment = { + id: 'mp-1', + feeId: 'fee-a', + driverId: 'driver-1', + amount: 100, + platformFee: 5, + netAmount: 95, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2025-02-01T00:00:00.000Z'), + }; + const p2: MemberPayment = { + id: 'mp-2', + feeId: 'fee-b', + driverId: 'driver-1', + amount: 50, + platformFee: 2.5, + netAmount: 47.5, + status: MemberPaymentStatus.PENDING, + dueDate: new Date('2025-02-01T00:00:00.000Z'), + }; + + await paymentRepo.create(p1); + await paymentRepo.create(p2); + + expect((await paymentRepo.findByFeeIdAndDriverId('fee-a', 'driver-1'))?.id).toBe('mp-1'); + + const leagueAPayments = await paymentRepo.findByLeagueIdAndDriverId('league-a', 'driver-1', feeRepo); + expect(leagueAPayments.map(p => p.id)).toEqual(['mp-1']); + }); +}); diff --git a/adapters/payments/persistence/inmemory/InMemoryPaymentRepository.test.ts b/adapters/payments/persistence/inmemory/InMemoryPaymentRepository.test.ts new file mode 100644 index 000000000..5aa81a4c8 --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryPaymentRepository.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application/Logger'; +import type { Payment } from '@core/payments/domain/entities/Payment'; +import { PaymentType, PaymentStatus, PayerType } from '@core/payments/domain/entities/Payment'; +import { InMemoryPaymentRepository } from './InMemoryPaymentRepository'; + +describe('InMemoryPaymentRepository', () => { + let repository: InMemoryPaymentRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + repository = new InMemoryPaymentRepository(logger); + }); + + it('creates and finds by filters', async () => { + const payment: Payment = { + id: 'pay-1', + type: PaymentType.SPONSORSHIP, + amount: 100, + platformFee: 5, + netAmount: 95, + payerId: 'sponsor-1', + payerType: PayerType.SPONSOR, + leagueId: 'league-1', + status: PaymentStatus.PENDING, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + await repository.create(payment); + + expect((await repository.findById('pay-1'))?.id).toBe('pay-1'); + expect((await repository.findByLeagueId('league-1')).length).toBeGreaterThanOrEqual(1); + expect((await repository.findByPayerId('sponsor-1')).length).toBeGreaterThanOrEqual(1); + expect((await repository.findByType(PaymentType.SPONSORSHIP)).length).toBeGreaterThanOrEqual(1); + + const filtered = await repository.findByFilters({ leagueId: 'league-1', payerId: 'sponsor-1', type: PaymentType.SPONSORSHIP }); + expect(filtered.map(p => p.id)).toContain('pay-1'); + }); + + it('updates', async () => { + const payment: Payment = { + id: 'pay-2', + type: PaymentType.MEMBERSHIP_FEE, + amount: 50, + platformFee: 2.5, + netAmount: 47.5, + payerId: 'driver-1', + payerType: PayerType.DRIVER, + leagueId: 'league-2', + status: PaymentStatus.PENDING, + createdAt: new Date('2025-01-02T00:00:00.000Z'), + }; + + await repository.create(payment); + const updated = await repository.update({ ...payment, status: PaymentStatus.COMPLETED, completedAt: new Date('2025-01-03T00:00:00.000Z') }); + expect(updated.status).toBe(PaymentStatus.COMPLETED); + }); +}); diff --git a/adapters/payments/persistence/inmemory/InMemoryPrizeRepository.test.ts b/adapters/payments/persistence/inmemory/InMemoryPrizeRepository.test.ts new file mode 100644 index 000000000..f5e29ff3c --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryPrizeRepository.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application/Logger'; +import type { Prize } from '@core/payments/domain/entities/Prize'; +import { PrizeType } from '@core/payments/domain/entities/Prize'; +import { InMemoryPrizeRepository } from './InMemoryPrizeRepository'; + +describe('InMemoryPrizeRepository', () => { + let repository: InMemoryPrizeRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + repository = new InMemoryPrizeRepository(logger); + }); + + it('creates and queries prizes', async () => { + const prize: Prize = { + id: 'prize-1', + leagueId: 'league-1', + seasonId: 'season-1', + position: 1, + name: 'First Place', + amount: 100, + type: PrizeType.CASH, + description: 'First place', + awarded: false, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + await repository.create(prize); + + expect((await repository.findById('prize-1'))?.id).toBe('prize-1'); + expect((await repository.findByLeagueId('league-1')).map(p => p.id)).toContain('prize-1'); + expect((await repository.findByLeagueIdAndSeasonId('league-1', 'season-1')).map(p => p.id)).toContain('prize-1'); + expect((await repository.findByPosition('league-1', 'season-1', 1))?.id).toBe('prize-1'); + + await repository.delete('prize-1'); + expect(await repository.findById('prize-1')).toBeNull(); + }); +}); diff --git a/adapters/payments/persistence/inmemory/InMemoryWalletRepository.test.ts b/adapters/payments/persistence/inmemory/InMemoryWalletRepository.test.ts new file mode 100644 index 000000000..15babcb30 --- /dev/null +++ b/adapters/payments/persistence/inmemory/InMemoryWalletRepository.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application/Logger'; +import type { Wallet, Transaction } from '@core/payments/domain/entities/Wallet'; +import { TransactionType } from '@core/payments/domain/entities/Wallet'; +import { InMemoryTransactionRepository, InMemoryWalletRepository } from './InMemoryWalletRepository'; + +describe('InMemoryWalletRepository', () => { + let walletRepo: InMemoryWalletRepository; + let txRepo: InMemoryTransactionRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + walletRepo = new InMemoryWalletRepository(logger); + txRepo = new InMemoryTransactionRepository(logger); + }); + + it('creates and finds wallets', async () => { + const wallet: Wallet = { + id: 'wallet-1', + leagueId: 'league-1', + balance: 0, + totalRevenue: 0, + totalPlatformFees: 0, + totalWithdrawn: 0, + currency: 'USD', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + }; + + await walletRepo.create(wallet); + + expect((await walletRepo.findById('wallet-1'))?.id).toBe('wallet-1'); + expect((await walletRepo.findByLeagueId('league-1'))?.id).toBe('wallet-1'); + + const updated = await walletRepo.update({ ...wallet, balance: 10 }); + expect(updated.balance).toBe(10); + }); + + it('creates and queries transactions', async () => { + const tx: Transaction = { + id: 'tx-1', + walletId: 'wallet-2', + type: TransactionType.DEPOSIT, + amount: 25, + description: 'Test deposit', + createdAt: new Date('2025-01-02T00:00:00.000Z'), + }; + + await txRepo.create(tx); + + expect((await txRepo.findById('tx-1'))?.id).toBe('tx-1'); + expect((await txRepo.findByWalletId('wallet-2')).map(t => t.id)).toContain('tx-1'); + }); +}); diff --git a/adapters/persistence/inmemory/achievement/InMemoryAchievementRepository.test.ts b/adapters/persistence/inmemory/achievement/InMemoryAchievementRepository.test.ts new file mode 100644 index 000000000..2873d533c --- /dev/null +++ b/adapters/persistence/inmemory/achievement/InMemoryAchievementRepository.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryAchievementRepository } from './InMemoryAchievementRepository'; + +describe('adapters/persistence/inmemory/achievement/InMemoryAchievementRepository', () => { + it('saves and queries achievements', async () => { + const repo = new InMemoryAchievementRepository(); + + const achievement: { id: string } = { id: 'a1' }; + + await repo.save(achievement as unknown as Parameters[0]); + + const found = await repo.findById('a1'); + expect((found as { id: string } | null)?.id).toBe('a1'); + + const all = await repo.findAll(); + expect(all.map(a => (a as { id: string }).id)).toContain('a1'); + }); +}); diff --git a/adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository.test.ts b/adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository.test.ts new file mode 100644 index 000000000..34ac07ec9 --- /dev/null +++ b/adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { InMemoryLeagueMembershipRepository } from './InMemoryLeagueMembershipRepository'; + +describe('InMemoryLeagueMembershipRepository', () => { + let repository: InMemoryLeagueMembershipRepository; + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + repository = new InMemoryLeagueMembershipRepository(logger); + }); + + it('saves and queries memberships and join requests', async () => { + const membership: { id: string; leagueId: string; driverId: string; status: string } = { + id: 'm1', + leagueId: 'league-1', + driverId: 'driver-1', + status: 'active', + }; + + await repository.saveMembership( + membership as unknown as Parameters[0], + ); + + expect((await repository.getMembership('league-1', 'driver-1'))?.id).toBe('m1'); + expect((await repository.findActiveByLeagueIdAndDriverId('league-1', 'driver-1'))?.id).toBe('m1'); + + expect((await repository.findAllByLeagueId('league-1')).length).toBe(1); + expect((await repository.findAllByDriverId('driver-1')).length).toBe(1); + expect((await repository.getLeagueMembers('league-1')).length).toBe(1); + + const joinRequest: { id: string; leagueId: string } = { id: 'jr1', leagueId: 'league-1' }; + await repository.saveJoinRequest( + joinRequest as unknown as Parameters[0], + ); + + expect((await repository.getJoinRequests('league-1')).map(r => r.id)).toEqual(['jr1']); + + await repository.removeJoinRequest('jr1'); + expect((await repository.getJoinRequests('league-1')).length).toBe(0); + + await repository.removeMembership('league-1', 'driver-1'); + expect(await repository.getMembership('league-1', 'driver-1')).toBeNull(); + }); +}); diff --git a/adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts b/adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts index 5eb05c207..fe202dfdb 100644 --- a/adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts +++ b/adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts @@ -213,10 +213,19 @@ export class InMemoryStandingRepository implements IStandingRepository { const standingsMap = new Map(); - results.forEach(result => { + const normalizePosition = (position: unknown): number => { + if (typeof position === 'number') return position; + if (typeof position === 'string') return Number(position); + if (position && typeof (position as { toNumber?: unknown }).toNumber === 'function') { + return (position as { toNumber: () => number }).toNumber(); + } + return Number(position); + }; + + results.forEach((result) => { const driverIdStr = result.driverId.toString(); let standing = standingsMap.get(driverIdStr); - + if (!standing) { standing = Standing.create({ leagueId, @@ -225,7 +234,8 @@ export class InMemoryStandingRepository implements IStandingRepository { this.logger.debug(`Created new standing for driver ${driverIdStr} in league ${leagueId}.`); } - standing = standing.addRaceResult(result.position.toNumber(), resolvedPointsSystem); + const position = normalizePosition((result as { position: unknown }).position); + standing = standing.addRaceResult(position, resolvedPointsSystem); standingsMap.set(driverIdStr, standing); this.logger.debug(`Driver ${driverIdStr} in league ${leagueId} accumulated ${standing.points} points.`); }); diff --git a/adapters/racing/ports/InMemoryDriverExtendedProfileProvider.test.ts b/adapters/racing/ports/InMemoryDriverExtendedProfileProvider.test.ts new file mode 100644 index 000000000..e6ef6aef5 --- /dev/null +++ b/adapters/racing/ports/InMemoryDriverExtendedProfileProvider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { InMemoryDriverExtendedProfileProvider } from './InMemoryDriverExtendedProfileProvider'; + +describe('InMemoryDriverExtendedProfileProvider', () => { + it('returns an extended profile shape', () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + const provider = new InMemoryDriverExtendedProfileProvider(logger); + + const profile = provider.getExtendedProfile('driver-1'); + expect(profile).not.toBeNull(); + expect(profile?.socialHandles).toBeInstanceOf(Array); + expect(profile?.achievements).toBeInstanceOf(Array); + expect(typeof profile?.favoriteTrack).toBe('string'); + }); +}); diff --git a/adapters/racing/ports/InMemoryDriverRatingProvider.test.ts b/adapters/racing/ports/InMemoryDriverRatingProvider.test.ts new file mode 100644 index 000000000..73d1771f0 --- /dev/null +++ b/adapters/racing/ports/InMemoryDriverRatingProvider.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { InMemoryDriverRatingProvider } from './InMemoryDriverRatingProvider'; + +describe('InMemoryDriverRatingProvider', () => { + it('returns ratings for known drivers and null for unknown', () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + const provider = new InMemoryDriverRatingProvider(logger); + + expect(provider.getRating('driver-1')).toBe(2500); + expect(provider.getRating('driver-2')).toBe(2400); + expect(provider.getRating('driver-x')).toBeNull(); + + const map = provider.getRatings(['driver-1', 'driver-x', 'driver-2']); + expect(map.get('driver-1')).toBe(2500); + expect(map.get('driver-2')).toBe(2400); + expect(map.has('driver-x')).toBe(false); + }); +}); diff --git a/adapters/racing/services/InMemoryDriverStatsService.test.ts b/adapters/racing/services/InMemoryDriverStatsService.test.ts new file mode 100644 index 000000000..0663f8950 --- /dev/null +++ b/adapters/racing/services/InMemoryDriverStatsService.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { InMemoryDriverStatsService } from './InMemoryDriverStatsService'; + +describe('InMemoryDriverStatsService', () => { + it('returns stats for known drivers', () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + const service = new InMemoryDriverStatsService(logger); + + const stats = service.getDriverStats('driver-1'); + expect(stats?.rating).toBe(2500); + + expect(service.getDriverStats('unknown')).toBeNull(); + }); +}); diff --git a/adapters/racing/services/InMemoryRankingService.test.ts b/adapters/racing/services/InMemoryRankingService.test.ts new file mode 100644 index 000000000..75e1abd0c --- /dev/null +++ b/adapters/racing/services/InMemoryRankingService.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import { InMemoryRankingService } from './InMemoryRankingService'; + +describe('InMemoryRankingService', () => { + it('returns mock rankings', () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + const service = new InMemoryRankingService(logger); + const rankings = service.getAllDriverRankings(); + + expect(rankings.length).toBeGreaterThanOrEqual(3); + expect(rankings[0]).toHaveProperty('driverId'); + expect(rankings[0]).toHaveProperty('rating'); + }); +}); diff --git a/adapters/social/persistence/inmemory/InMemorySocialAndFeed.test.ts b/adapters/social/persistence/inmemory/InMemorySocialAndFeed.test.ts new file mode 100644 index 000000000..8e23a6e88 --- /dev/null +++ b/adapters/social/persistence/inmemory/InMemorySocialAndFeed.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Logger } from '@core/shared/application'; +import type { RacingSeedData } from './InMemorySocialAndFeed'; +import { InMemoryFeedRepository, InMemorySocialGraphRepository } from './InMemorySocialAndFeed'; + +describe('InMemorySocialAndFeed', () => { + let logger: Logger; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + }); + + it('builds feed for a driver based on friendships', async () => { + const seed = { + drivers: [{ id: 'd1' }, { id: 'd2' }, { id: 'd3' }], + friendships: [ + { driverId: 'd1', friendId: 'd2' }, + { driverId: 'd1', friendId: 'd3' }, + ], + feedEvents: [ + { id: 'f1', actorDriverId: 'd2', timestamp: new Date('2025-01-02T00:00:00.000Z') }, + { id: 'f2', actorDriverId: 'd3', timestamp: new Date('2025-01-03T00:00:00.000Z') }, + { id: 'f3', actorDriverId: 'd1', timestamp: new Date('2025-01-04T00:00:00.000Z') }, + ], + }; + + const feedRepo = new InMemoryFeedRepository(logger, seed as unknown as RacingSeedData); + const feed = await feedRepo.getFeedForDriver('d1'); + expect(feed.map(f => (f as { id: string }).id)).toEqual(['f2', 'f1']); + + const global = await feedRepo.getGlobalFeed(2); + expect(global.length).toBe(2); + }); + + it('returns friends and suggestions', async () => { + const seed = { + drivers: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], + friendships: [ + { driverId: 'a', friendId: 'b' }, + { driverId: 'b', friendId: 'c' }, + { driverId: 'a', friendId: 'd' }, + { driverId: 'd', friendId: 'c' }, + ], + feedEvents: [], + }; + + const repo = new InMemorySocialGraphRepository(logger, seed as unknown as RacingSeedData); + + expect(await repo.getFriendIds('a')).toEqual(['b', 'd']); + + const friends = await repo.getFriends('a'); + expect(friends.map(d => (d as { id: string }).id).sort()).toEqual(['b', 'd']); + + const suggested = await repo.getSuggestedFriends('a'); + expect(suggested.map(d => (d as { id: string }).id)).toContain('c'); + }); +});