resolve todos in website

This commit is contained in:
2025-12-20 12:55:07 +01:00
parent 20588e1c0b
commit 92be9d2e1b
56 changed files with 2476 additions and 78 deletions

View File

@@ -210,6 +210,18 @@
}
]
}
},
{
"files": ["core/**/*.ts"],
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "ExportDefaultDeclaration",
"message": "Default exports are forbidden. Use named exports instead."
}
]
}
}
]
}

View File

@@ -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();
});
});

View File

@@ -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<typeof vi.fn>)).toHaveBeenCalled();
});
});

View File

@@ -14,7 +14,7 @@ export class GetDashboardDataUseCase {
private readonly logger: Logger,
) {}
async execute(_input: GetDashboardDataInput = {}): Promise<GetDashboardDataOutput> {
async execute(): Promise<GetDashboardDataOutput> {
try {
// Placeholder implementation - would need repositories from identity and racing domains
const totalUsers = 0;

View File

@@ -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();
});
});

View File

@@ -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';

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -21,6 +21,3 @@ 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';

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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';

View File

@@ -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,
},
]);
});
});

View File

@@ -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',
});
});
});

View File

@@ -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',
});
});
});

View File

@@ -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',
});
});
});

View File

@@ -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',
],
});
});
});

View File

@@ -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',
});
});
});

View File

@@ -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();
});
});

View File

@@ -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';

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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';

View File

@@ -7,5 +7,5 @@ export { InMemoryNotificationRepository } from './repositories/InMemoryNotificat
export { InMemoryNotificationPreferenceRepository } from './repositories/InMemoryNotificationPreferenceRepository';
// Adapters
export { InAppNotificationAdapter } from './/InAppNotificationAdapter';
export { NotificationGatewayRegistry } from './/NotificationGatewayRegistry';
export { InAppNotificationAdapter } from "./InAppNotificationAdapter";
export { NotificationGatewayRegistry } from "./NotificationGatewayRegistry";

View File

@@ -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,
},
],
});
});
});

View File

@@ -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<CreateSeasonForLeagueResultDTO> {
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<ListSeasonsForLeagueResultDTO> {
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<SeasonDetailsDTO> {
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<ManageSeasonLifecycleResultDTO> {
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,
});
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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.

View File

@@ -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.

View File

@@ -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;

View File

@@ -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.

View File

@@ -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<void, GetTotalLeaguesOutputPort, 'REPOSITORY_ERROR'>
{

View File

@@ -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<void, GetTotalRacesOutputPort, 'REPOSITORY_ERROR'>
{

View File

@@ -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';

View File

@@ -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';

View File

@@ -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<ILeagueMembershipRepository>;
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.' });
});
});

View File

@@ -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';

View File

@@ -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

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;