fix issues in core

This commit is contained in:
2025-12-23 11:25:08 +01:00
parent 1efd971032
commit 2854ae3c5c
113 changed files with 1142 additions and 458 deletions

152
.roo/rules.md Normal file
View File

@@ -0,0 +1,152 @@
## User Authority (Absolute)
The user is the highest authority at all times.
Rules:
- Any new user instruction immediately interrupts all ongoing work.
- All current tasks, plans, or assumptions must be discarded unless the user says otherwise.
- No mode may continue its previous task after a user interruption.
- No mode may ignore, defer, or partially apply a user instruction.
- The system must always re-align immediately to the latest user intent.
User intent overrides:
- plans
- TODO order
- memory assumptions
- architectural decisions
- execution flow
---
## Memory Bank (MCP) — Brain, Not Storage
The memory bank represents **decision knowledge**, not process or history.
### What Memory Is For
Memory may contain ONLY:
- important product or domain decisions
- invariants that constrain future decisions
- irreversible choices
- non-obvious constraints or truths
Memory exists to prevent re-deciding things.
### What Memory Must NEVER Contain
- instructions
- plans
- TODOs
- documentation
- explanations
- code
- logs
- examples
- conversations
- implementation details
- process rules
If something belongs in a plan, doc, or prompt, it does NOT belong in memory.
### Memory Rules
- Only the Orchestrator may read from or write to memory.
- Other modes may not access memory directly.
- Memory is consulted only when making decisions, never during execution.
- Each memory entry must be atomic, declarative, and short.
---
## Plans (`./plans`) — Throwaway Thinking
Plans are **temporary artifacts**.
Rules:
- Plans are created by the Orchestrator only.
- Plans are stored in `./plans`.
- Filenames MUST include a timestamp.
- Plans MUST include a checkable TODO list.
- Plans are allowed to be incomplete or wrong.
- Plans are NOT a source of truth.
Plans exist to think, not to persist.
Plans MUST NOT:
- be stored in memory
- be treated as documentation
- override execution reality
- survive major user direction changes
Plans may be abandoned without ceremony.
---
## Documentation (`./docs`) — Permanent Knowledge
Documentation represents **stable, long-lived understanding**.
Rules:
- Documentation lives in `./docs`.
- Documentation is updated only when something is settled and stable.
- Documentation reflects *what is*, not *what we plan*.
- Documentation must not contain TODOs or speculative content.
- Documentation may summarize decisions that also exist in memory, but with explanation.
Docs are authoritative for humans.
Memory is authoritative for decisions.
---
## TODO Lists — Execution Control (Mandatory)
Every mode MUST maintain a TODO list via the TODO tool.
Rules:
- TODO lists contain ONLY outstanding work.
- Completed items must be removed immediately.
- No speculative TODOs.
- No TODOs for already-completed work.
- TODOs are the single source of truth for remaining execution.
- No mode may proceed if its TODO list is non-empty unless the user explicitly overrides.
TODO lists reflect reality, not intent.
---
## Execution Reality Overrides Plans
Actual execution results always override plans.
Rules:
- If an expert reports open work, the system must stop and update TODOs.
- Plans must never be followed blindly.
- No mode may “continue the plan” if reality diverges.
- Forward progress is blocked until open TODOs are resolved or the user overrides.
---
## Mode Boundaries
Each mode:
- operates only within its defined responsibility
- must not compensate for missing context
- must not infer intent
- must not perform another modes role
If required information is missing, the mode must stop and report it.
---
## Forbidden (Global)
No mode may:
- ignore a user interruption
- continue work after user redirection
- write instructions into memory
- store plans or TODOs in memory
- treat plans as permanent
- treat docs as throwaway
- invent tasks
- hide open work
- override TODO reality
- continue execution “for momentum”
---
## System Goal
The system must behave like a disciplined brain:
- Memory = decisions
- Plans = temporary thinking
- Docs = permanent knowledge
- TODOs = execution truth
- User = absolute authority

View File

@@ -0,0 +1,5 @@
export * from './EnsureInitialData';
export * from './LeagueConstraints';
export * from './LeagueScoringPresets';
export * from './PointsSystems';
export * from './ScoringDemoSetup';

View File

@@ -1,6 +1,6 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState'; import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState'; import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
import { Result } from '@gridpilot/shared/application/Result'; import { Result } from '@core/shared/application/Result';
/** /**
* Port for authentication services implementing zero-knowledge login. * Port for authentication services implementing zero-knowledge login.

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation'; import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation';
import type { CheckoutConfirmationRequestDTO } from '../dto/CheckoutConfirmationRequestDTO'; import type { CheckoutConfirmationRequestDTO } from '../dto/CheckoutConfirmationRequestDTO';

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO'; import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO';
export interface CheckoutServicePort { export interface CheckoutServicePort {

View File

@@ -1,4 +1,4 @@
import { SessionStateValue } from '@/automation/domain/value-objects/SessionState'; import { SessionStateValue } from '../../domain/value-objects/SessionState';
import { AutomationSession } from '../../domain/entities/AutomationSession'; import { AutomationSession } from '../../domain/entities/AutomationSession';

View File

@@ -1,4 +1,4 @@
import type { Result } from '@gridpilot/shared/application/Result'; import type { Result } from '@core/shared/application/Result';
export interface SessionValidatorPort { export interface SessionValidatorPort {
validateSession(): Promise<Result<boolean>>; validateSession(): Promise<Result<boolean>>;

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CheckAuthenticationUseCase } from 'apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase'; import { CheckAuthenticationUseCase } from 'apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState'; import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState'; import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import { Result } from '@gridpilot/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { AuthenticationServicePort } from 'apps/companion/main/automation/application/ports/AuthenticationServicePort'; import type { AuthenticationServicePort } from 'apps/companion/main/automation/application/ports/AuthenticationServicePort';
interface ISessionValidator { interface ISessionValidator {

View File

@@ -1,36 +1,13 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput, type GetAnalyticsMetricsOutput } from './GetAnalyticsMetricsUseCase'; import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput, type GetAnalyticsMetricsOutput } from './GetAnalyticsMetricsUseCase';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('GetAnalyticsMetricsUseCase', () => { describe('GetAnalyticsMetricsUseCase', () => {
let pageViewRepository: {
save: Mock;
findById: Mock;
findByEntityId: Mock;
findBySessionId: Mock;
countByEntityId: Mock;
getUniqueVisitorsCount: Mock;
getAverageSessionDuration: Mock;
getBounceRate: Mock;
};
let logger: Logger; let logger: Logger;
let output: UseCaseOutputPort<GetAnalyticsMetricsOutput> & { present: Mock }; let output: UseCaseOutputPort<GetAnalyticsMetricsOutput> & { present: Mock };
let useCase: GetAnalyticsMetricsUseCase; let useCase: GetAnalyticsMetricsUseCase;
beforeEach(() => { 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 = { logger = {
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
@@ -43,7 +20,6 @@ describe('GetAnalyticsMetricsUseCase', () => {
}; };
useCase = new GetAnalyticsMetricsUseCase( useCase = new GetAnalyticsMetricsUseCase(
pageViewRepository as unknown as IPageViewRepository,
logger, logger,
output, output,
); );
@@ -78,4 +54,4 @@ describe('GetAnalyticsMetricsUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect((logger.error as unknown as Mock)).toHaveBeenCalled(); expect((logger.error as unknown as Mock)).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,7 +1,6 @@
import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application'; import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
export interface GetAnalyticsMetricsInput { export interface GetAnalyticsMetricsInput {
startDate?: Date; startDate?: Date;
@@ -19,7 +18,7 @@ export type GetAnalyticsMetricsErrorCode = 'REPOSITORY_ERROR';
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, void, GetAnalyticsMetricsErrorCode> { export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, void, GetAnalyticsMetricsErrorCode> {
constructor( constructor(
private readonly pageViewRepository: IPageViewRepository, // private readonly pageViewRepository: IPageViewRepository, // TODO: Use when implementation is ready
private readonly logger: Logger, private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetAnalyticsMetricsOutput>, private readonly output: UseCaseOutputPort<GetAnalyticsMetricsOutput>,
) {} ) {}
@@ -29,7 +28,8 @@ export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsIn
const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
const endDate = input.endDate ?? new Date(); const endDate = input.endDate ?? new Date();
// TODO static data // TODO: Use pageViewRepository when implemented
// const pageViews = await this.pageViewRepository.countByDateRange(startDate, endDate);
const pageViews = 0; const pageViews = 0;
const uniqueVisitors = 0; const uniqueVisitors = 0;
const averageSessionDuration = 0; const averageSessionDuration = 0;

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetDashboardDataUseCase, type GetDashboardDataOutput } from './GetDashboardDataUseCase'; import { GetDashboardDataUseCase, type GetDashboardDataOutput } from './GetDashboardDataUseCase';
import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('GetDashboardDataUseCase', () => { describe('GetDashboardDataUseCase', () => {
let logger: Logger; let logger: Logger;
@@ -36,4 +35,4 @@ describe('GetDashboardDataUseCase', () => {
expect((logger.info as unknown as Mock)).toHaveBeenCalled(); expect((logger.info as unknown as Mock)).toHaveBeenCalled();
}); });
}); });

View File

@@ -19,7 +19,7 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, v
private readonly output: UseCaseOutputPort<GetDashboardDataOutput>, private readonly output: UseCaseOutputPort<GetDashboardDataOutput>,
) {} ) {}
async execute(input: GetDashboardDataInput = {}): Promise<Result<void, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> { async execute(): Promise<Result<void, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
try { try {
// Placeholder implementation - would need repositories from identity and racing domains // Placeholder implementation - would need repositories from identity and racing domains
const totalUsers = 0; const totalUsers = 0;

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetEntityAnalyticsQuery, type GetEntityAnalyticsInput } from './GetEntityAnalyticsQuery'; import { GetEntityAnalyticsQuery, type GetEntityAnalyticsInput } from './GetEntityAnalyticsQuery';
import type { IPageViewRepository } from '../repositories/IPageViewRepository'; import type { IPageViewRepository } from '../repositories/IPageViewRepository';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; 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 { Logger } from '@core/shared/application';
import type { EntityType } from '../../domain/types/PageView'; import type { EntityType } from '../../domain/types/PageView';
@@ -14,7 +13,6 @@ describe('GetEntityAnalyticsQuery', () => {
let engagementRepository: { let engagementRepository: {
getSponsorClicksForEntity: Mock; getSponsorClicksForEntity: Mock;
}; };
let snapshotRepository: IAnalyticsSnapshotRepository;
let logger: Logger; let logger: Logger;
let useCase: GetEntityAnalyticsQuery; let useCase: GetEntityAnalyticsQuery;
@@ -28,8 +26,6 @@ describe('GetEntityAnalyticsQuery', () => {
getSponsorClicksForEntity: vi.fn(), getSponsorClicksForEntity: vi.fn(),
} as unknown as IEngagementRepository as any; } as unknown as IEngagementRepository as any;
snapshotRepository = {} as IAnalyticsSnapshotRepository;
logger = { logger = {
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
@@ -40,7 +36,6 @@ describe('GetEntityAnalyticsQuery', () => {
useCase = new GetEntityAnalyticsQuery( useCase = new GetEntityAnalyticsQuery(
pageViewRepository as unknown as IPageViewRepository, pageViewRepository as unknown as IPageViewRepository,
engagementRepository as unknown as IEngagementRepository, engagementRepository as unknown as IEngagementRepository,
snapshotRepository,
logger, logger,
); );
}); });

View File

@@ -5,10 +5,9 @@
* Returns metrics formatted for display to sponsors and admins. * Returns metrics formatted for display to sponsors and admins.
*/ */
import type { AsyncUseCase , Logger, UseCaseOutputPort } from '@core/shared/application'; import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { IPageViewRepository } from '../repositories/IPageViewRepository'; import type { IPageViewRepository } from '../repositories/IPageViewRepository';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository';
import type { EntityType } from '../../domain/types/PageView'; import type { EntityType } from '../../domain/types/PageView';
import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot'; import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
@@ -51,7 +50,7 @@ export class GetEntityAnalyticsQuery
constructor( constructor(
private readonly pageViewRepository: IPageViewRepository, private readonly pageViewRepository: IPageViewRepository,
private readonly engagementRepository: IEngagementRepository, private readonly engagementRepository: IEngagementRepository,
private readonly snapshotRepository: IAnalyticsSnapshotRepository, // private readonly snapshotRepository: IAnalyticsSnapshotRepository, // TODO: Use when implementation is ready
private readonly logger: Logger private readonly logger: Logger
) {} ) {}

View File

@@ -4,7 +4,6 @@ import type { IEngagementRepository } from '../../domain/repositories/IEngagemen
import { EngagementEvent } from '../../domain/entities/EngagementEvent'; import { EngagementEvent } from '../../domain/entities/EngagementEvent';
import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent'; import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent';
import { Result } from '@core/shared/application/Result';
describe('RecordEngagementUseCase', () => { describe('RecordEngagementUseCase', () => {
let engagementRepository: { let engagementRepository: {
@@ -54,7 +53,7 @@ describe('RecordEngagementUseCase', () => {
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(engagementRepository.save).toHaveBeenCalledTimes(1); expect(engagementRepository.save).toHaveBeenCalledTimes(1);
const saved = (engagementRepository.save as unknown as Mock).mock.calls[0][0] as EngagementEvent; const saved = (engagementRepository.save as unknown as Mock).mock.calls?.[0]?.[0] as EngagementEvent;
expect(saved).toBeInstanceOf(EngagementEvent); expect(saved).toBeInstanceOf(EngagementEvent);
expect(saved.id).toBeDefined(); expect(saved.id).toBeDefined();
@@ -85,4 +84,4 @@ describe('RecordEngagementUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect((logger.error as unknown as Mock)).toHaveBeenCalled(); expect((logger.error as unknown as Mock)).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,9 +1,9 @@
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent';
export interface RecordEngagementInput { export interface RecordEngagementInput {
action: EngagementAction; action: EngagementAction;
@@ -36,10 +36,10 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, v
action: input.action, action: input.action,
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
actorId: input.actorId,
actorType: input.actorType, actorType: input.actorType,
sessionId: input.sessionId, sessionId: input.sessionId,
metadata: input.metadata, ...(input.actorId !== undefined && { actorId: input.actorId }),
...(input.metadata !== undefined && { metadata: input.metadata }),
}); });
await this.engagementRepository.save(engagementEvent); await this.engagementRepository.save(engagementEvent);

View File

@@ -4,7 +4,6 @@ import type { IPageViewRepository } from '../../domain/repositories/IPageViewRep
import { PageView } from '../../domain/entities/PageView'; import { PageView } from '../../domain/entities/PageView';
import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { EntityType, VisitorType } from '../../domain/types/PageView'; import type { EntityType, VisitorType } from '../../domain/types/PageView';
import { Result } from '@core/shared/application/Result';
describe('RecordPageViewUseCase', () => { describe('RecordPageViewUseCase', () => {
let pageViewRepository: { let pageViewRepository: {
@@ -55,7 +54,7 @@ describe('RecordPageViewUseCase', () => {
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(pageViewRepository.save).toHaveBeenCalledTimes(1); expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
const saved = (pageViewRepository.save as unknown as Mock).mock.calls[0][0] as PageView; const saved = (pageViewRepository.save as unknown as Mock).mock.calls?.[0]?.[0] as PageView;
expect(saved).toBeInstanceOf(PageView); expect(saved).toBeInstanceOf(PageView);
expect(saved.id).toBeDefined(); expect(saved.id).toBeDefined();
@@ -84,4 +83,4 @@ describe('RecordPageViewUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect((logger.error as unknown as Mock)).toHaveBeenCalled(); expect((logger.error as unknown as Mock)).toHaveBeenCalled();
}); });
}); });

View File

@@ -31,17 +31,28 @@ export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, void,
async execute(input: RecordPageViewInput): Promise<Result<void, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> { async execute(input: RecordPageViewInput): Promise<Result<void, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
try { try {
const pageView = PageView.create({ const props = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
visitorId: input.visitorId,
visitorType: input.visitorType, visitorType: input.visitorType,
sessionId: input.sessionId, sessionId: input.sessionId,
referrer: input.referrer, } as any;
userAgent: input.userAgent,
country: input.country, if (input.visitorId !== undefined) {
}); props.visitorId = input.visitorId;
}
if (input.referrer !== undefined) {
props.referrer = input.referrer;
}
if (input.userAgent !== undefined) {
props.userAgent = input.userAgent;
}
if (input.country !== undefined) {
props.country = input.country;
}
const pageView = PageView.create(props);
await this.pageViewRepository.save(pageView); await this.pageViewRepository.save(pageView);

View File

@@ -1,6 +1,8 @@
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
// TODO not so sure if this here is proper clean architecture
export interface IdentitySessionPort { export interface IdentitySessionPort {
getCurrentSession(): Promise<AuthSessionDTO | null>; getCurrentSession(): Promise<AuthSessionDTO | null>;
createSession(user: AuthenticatedUserDTO): Promise<AuthSessionDTO>; createSession(user: AuthenticatedUserDTO): Promise<AuthSessionDTO>;

View File

@@ -2,6 +2,7 @@ import { vi, type Mock } from 'vitest';
import { GetCurrentSessionUseCase } from './GetCurrentSessionUseCase'; import { GetCurrentSessionUseCase } from './GetCurrentSessionUseCase';
import { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; import { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
describe('GetCurrentSessionUseCase', () => { describe('GetCurrentSessionUseCase', () => {
let useCase: GetCurrentSessionUseCase; let useCase: GetCurrentSessionUseCase;
@@ -12,6 +13,8 @@ describe('GetCurrentSessionUseCase', () => {
update: Mock; update: Mock;
emailExists: Mock; emailExists: Mock;
}; };
let logger: Logger;
let output: UseCaseOutputPort<any> & { present: Mock };
beforeEach(() => { beforeEach(() => {
mockUserRepo = { mockUserRepo = {
@@ -21,7 +24,20 @@ describe('GetCurrentSessionUseCase', () => {
update: vi.fn(), update: vi.fn(),
emailExists: vi.fn(), emailExists: vi.fn(),
}; };
useCase = new GetCurrentSessionUseCase(mockUserRepo as IUserRepository); logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetCurrentSessionUseCase(
mockUserRepo as IUserRepository,
logger,
output,
);
}); });
it('should return User when user exists', async () => { it('should return User when user exists', async () => {
@@ -37,21 +53,24 @@ describe('GetCurrentSessionUseCase', () => {
}; };
mockUserRepo.findById.mockResolvedValue(storedUser); mockUserRepo.findById.mockResolvedValue(storedUser);
const result = await useCase.execute(userId); const result = await useCase.execute({ userId });
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId); expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
expect(result).toBeInstanceOf(User); expect(result.isOk()).toBe(true);
expect(result?.getId().value).toBe(userId); expect(output.present).toHaveBeenCalled();
expect(result?.getDisplayName()).toBe('Test User'); const callArgs = output.present.mock.calls?.[0]?.[0];
expect(callArgs?.user).toBeInstanceOf(User);
expect(callArgs?.user.getId().value).toBe(userId);
expect(callArgs?.user.getDisplayName()).toBe('Test User');
}); });
it('should return null when user does not exist', async () => { it('should return error when user does not exist', async () => {
const userId = 'user-123'; const userId = 'user-123';
mockUserRepo.findById.mockResolvedValue(null); mockUserRepo.findById.mockResolvedValue(null);
const result = await useCase.execute(userId); const result = await useCase.execute({ userId });
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId); expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
expect(result).toBeNull(); expect(result.isErr()).toBe(true);
}); });
}); });

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetCurrentUserSessionUseCase } from './GetCurrentUserSessionUseCase'; import { GetCurrentUserSessionUseCase } from './GetCurrentUserSessionUseCase';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
describe('GetCurrentUserSessionUseCase', () => { describe('GetCurrentUserSessionUseCase', () => {
let sessionPort: { let sessionPort: {
@@ -9,7 +10,8 @@ describe('GetCurrentUserSessionUseCase', () => {
createSession: Mock; createSession: Mock;
clearSession: Mock; clearSession: Mock;
}; };
let logger: Logger;
let output: UseCaseOutputPort<any> & { present: Mock };
let useCase: GetCurrentUserSessionUseCase; let useCase: GetCurrentUserSessionUseCase;
beforeEach(() => { beforeEach(() => {
@@ -19,7 +21,22 @@ describe('GetCurrentUserSessionUseCase', () => {
clearSession: vi.fn(), clearSession: vi.fn(),
}; };
useCase = new GetCurrentUserSessionUseCase(sessionPort as unknown as IdentitySessionPort); logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetCurrentUserSessionUseCase(
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
}); });
it('returns the current auth session when one exists', async () => { it('returns the current auth session when one exists', async () => {
@@ -40,7 +57,8 @@ describe('GetCurrentUserSessionUseCase', () => {
const result = await useCase.execute(); const result = await useCase.execute();
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1); expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(result).toEqual(session); expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith(session);
}); });
it('returns null when there is no active session', async () => { it('returns null when there is no active session', async () => {
@@ -49,6 +67,7 @@ describe('GetCurrentUserSessionUseCase', () => {
const result = await useCase.execute(); const result = await useCase.execute();
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1); expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(result).toBeNull(); expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith(null);
}); });
}); });

View File

@@ -2,12 +2,15 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetUserUseCase } from './GetUserUseCase'; import { GetUserUseCase } from './GetUserUseCase';
import { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Result } from '@core/shared/application/Result';
describe('GetUserUseCase', () => { describe('GetUserUseCase', () => {
let userRepository: { let userRepository: {
findById: Mock; findById: Mock;
}; };
let logger: Logger;
let output: UseCaseOutputPort<any> & { present: Mock };
let useCase: GetUserUseCase; let useCase: GetUserUseCase;
beforeEach(() => { beforeEach(() => {
@@ -15,7 +18,22 @@ describe('GetUserUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
}; };
useCase = new GetUserUseCase(userRepository as unknown as IUserRepository); logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetUserUseCase(
userRepository as unknown as IUserRepository,
logger,
output,
);
}); });
it('returns a User when the user exists', async () => { it('returns a User when the user exists', async () => {
@@ -31,18 +49,24 @@ describe('GetUserUseCase', () => {
userRepository.findById.mockResolvedValue(storedUser); userRepository.findById.mockResolvedValue(storedUser);
const result = await useCase.execute('user-1'); const result = await useCase.execute({ userId: 'user-1' });
expect(userRepository.findById).toHaveBeenCalledWith('user-1'); expect(userRepository.findById).toHaveBeenCalledWith('user-1');
expect(result).toBeInstanceOf(User); expect(result.isOk()).toBe(true);
expect(result.getId().value).toBe('user-1'); expect(output.present).toHaveBeenCalled();
expect(result.getDisplayName()).toBe('Test User'); const callArgs = output.present.mock.calls?.[0]?.[0] as Result<any, any>;
const user = callArgs.unwrap().user;
expect(user).toBeInstanceOf(User);
expect(user.getId().value).toBe('user-1');
expect(user.getDisplayName()).toBe('Test User');
}); });
it('throws when the user does not exist', async () => { it('returns error when the user does not exist', async () => {
userRepository.findById.mockResolvedValue(null); userRepository.findById.mockResolvedValue(null);
await expect(useCase.execute('missing-user')).rejects.toThrow('User not found'); const result = await useCase.execute({ userId: 'missing-user' });
expect(userRepository.findById).toHaveBeenCalledWith('missing-user'); expect(userRepository.findById).toHaveBeenCalledWith('missing-user');
expect(result.isErr()).toBe(true);
}); });
}); });

View File

@@ -5,6 +5,7 @@ import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { AuthCallbackCommandDTO } from '../dto/AuthCallbackCommandDTO'; import type { AuthCallbackCommandDTO } from '../dto/AuthCallbackCommandDTO';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
describe('HandleAuthCallbackUseCase', () => { describe('HandleAuthCallbackUseCase', () => {
let provider: { let provider: {
@@ -15,6 +16,8 @@ describe('HandleAuthCallbackUseCase', () => {
getCurrentSession: Mock; getCurrentSession: Mock;
clearSession: Mock; clearSession: Mock;
}; };
let logger: Logger;
let output: UseCaseOutputPort<any> & { present: Mock };
let useCase: HandleAuthCallbackUseCase; let useCase: HandleAuthCallbackUseCase;
beforeEach(() => { beforeEach(() => {
@@ -26,18 +29,30 @@ describe('HandleAuthCallbackUseCase', () => {
getCurrentSession: vi.fn(), getCurrentSession: vi.fn(),
clearSession: vi.fn(), clearSession: vi.fn(),
}; };
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new HandleAuthCallbackUseCase( useCase = new HandleAuthCallbackUseCase(
provider as unknown as IdentityProviderPort, provider as unknown as IdentityProviderPort,
sessionPort as unknown as IdentitySessionPort, sessionPort as unknown as IdentitySessionPort,
logger,
output,
); );
}); });
it('completes auth and creates a session', async () => { it('completes auth and creates a session', async () => {
const command: AuthCallbackCommandDTO = { const command: AuthCallbackCommandDTO = {
provider: 'IRACING_DEMO',
code: 'auth-code', code: 'auth-code',
state: 'state-123', state: 'state-123',
redirectUri: 'https://app/callback', returnTo: 'https://app/callback',
}; };
const user: AuthenticatedUserDTO = { const user: AuthenticatedUserDTO = {
@@ -60,6 +75,7 @@ describe('HandleAuthCallbackUseCase', () => {
expect(provider.completeAuth).toHaveBeenCalledWith(command); expect(provider.completeAuth).toHaveBeenCalledWith(command);
expect(sessionPort.createSession).toHaveBeenCalledWith(user); expect(sessionPort.createSession).toHaveBeenCalledWith(user);
expect(result).toEqual(session); expect(output.present).toHaveBeenCalledWith(session);
expect(result.isOk()).toBe(true);
}); });
}); });

View File

@@ -48,7 +48,7 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
const isValid = await this.passwordService.verify(input.password, passwordHash.value); const isValid = await this.passwordService.verify(input.password, passwordHash.value);
if (!isValid) { if (!isValid) {
return Result.err<LoginApplicationError>({ return Result.err<void, LoginApplicationError>({
code: 'INVALID_CREDENTIALS', code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid credentials' }, details: { message: 'Invalid credentials' },
}); });
@@ -66,7 +66,7 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
input, input,
}); });
return Result.err<LoginApplicationError>({ return Result.err<void, LoginApplicationError>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message }, details: { message },
}); });

View File

@@ -24,7 +24,7 @@ export class LogoutUseCase implements UseCase<LogoutInput, void, LogoutErrorCode
this.sessionPort = sessionPort; this.sessionPort = sessionPort;
} }
async execute(input: LogoutInput): Promise<Result<void, LogoutApplicationError>> { async execute(): Promise<Result<void, LogoutApplicationError>> {
try { try {
await this.sessionPort.clearSession(); await this.sessionPort.clearSession();

View File

@@ -5,6 +5,7 @@ import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository'; import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
vi.mock('../../domain/value-objects/PasswordHash', () => ({ vi.mock('../../domain/value-objects/PasswordHash', () => ({
PasswordHash: { PasswordHash: {
@@ -20,6 +21,8 @@ describe('SignupUseCase', () => {
let passwordService: { let passwordService: {
hash: Mock; hash: Mock;
}; };
let logger: Logger;
let output: UseCaseOutputPort<any> & { present: Mock };
let useCase: SignupUseCase; let useCase: SignupUseCase;
beforeEach(() => { beforeEach(() => {
@@ -30,42 +33,61 @@ describe('SignupUseCase', () => {
passwordService = { passwordService = {
hash: vi.fn(), hash: vi.fn(),
}; };
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new SignupUseCase( useCase = new SignupUseCase(
authRepo as unknown as IAuthRepository, authRepo as unknown as IAuthRepository,
passwordService as unknown as IPasswordHashingService, passwordService as unknown as IPasswordHashingService,
logger,
output,
); );
}); });
it('creates and saves a new user when email is free', async () => { it('creates and saves a new user when email is free', async () => {
const email = 'new@example.com'; const input = {
const password = 'password123'; email: 'new@example.com',
const displayName = 'New User'; password: 'password123',
displayName: 'New User',
};
authRepo.findByEmail.mockResolvedValue(null); authRepo.findByEmail.mockResolvedValue(null);
passwordService.hash.mockResolvedValue('hashed-password'); passwordService.hash.mockResolvedValue('hashed-password');
const result = await useCase.execute(email, password, displayName); const result = await useCase.execute(input);
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(email)); expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(passwordService.hash).toHaveBeenCalledWith(password); expect(passwordService.hash).toHaveBeenCalledWith(input.password);
expect(authRepo.save).toHaveBeenCalled(); expect(authRepo.save).toHaveBeenCalled();
expect(result).toBeInstanceOf(User); expect(result.isOk()).toBe(true);
expect(result.getDisplayName()).toBe(displayName); expect(output.present).toHaveBeenCalled();
}); });
it('throws when user already exists', async () => { it('throws when user already exists', async () => {
const email = 'existing@example.com'; const input = {
email: 'existing@example.com',
password: 'password123',
displayName: 'Existing User',
};
const existingUser = User.create({ const existingUser = User.create({
id: UserId.create(), id: UserId.create(),
displayName: 'Existing User', displayName: 'Existing User',
email, email: input.email,
}); });
authRepo.findByEmail.mockResolvedValue(existingUser); authRepo.findByEmail.mockResolvedValue(existingUser);
await expect(useCase.execute(email, 'password', 'Existing User')).rejects.toThrow('User already exists'); const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
}); });
}); });

View File

@@ -1,8 +1,10 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { SignupWithEmailUseCase, type SignupCommandDTO } from './SignupWithEmailUseCase'; import { SignupWithEmailUseCase } from './SignupWithEmailUseCase';
import type { SignupWithEmailInput } from './SignupWithEmailUseCase';
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
describe('SignupWithEmailUseCase', () => { describe('SignupWithEmailUseCase', () => {
let userRepository: { let userRepository: {
@@ -14,6 +16,8 @@ describe('SignupWithEmailUseCase', () => {
getCurrentSession: Mock; getCurrentSession: Mock;
clearSession: Mock; clearSession: Mock;
}; };
let logger: Logger;
let output: UseCaseOutputPort<any> & { present: Mock };
let useCase: SignupWithEmailUseCase; let useCase: SignupWithEmailUseCase;
beforeEach(() => { beforeEach(() => {
@@ -26,14 +30,25 @@ describe('SignupWithEmailUseCase', () => {
getCurrentSession: vi.fn(), getCurrentSession: vi.fn(),
clearSession: vi.fn(), clearSession: vi.fn(),
}; };
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new SignupWithEmailUseCase( useCase = new SignupWithEmailUseCase(
userRepository as unknown as IUserRepository, userRepository as unknown as IUserRepository,
sessionPort as unknown as IdentitySessionPort, sessionPort as unknown as IdentitySessionPort,
logger,
output,
); );
}); });
it('creates a new user and session for valid input', async () => { it('creates a new user and session for valid input', async () => {
const command: SignupCommandDTO = { const command: SignupWithEmailInput = {
email: 'new@example.com', email: 'new@example.com',
password: 'password123', password: 'password123',
displayName: 'New User', displayName: 'New User',
@@ -64,42 +79,58 @@ describe('SignupWithEmailUseCase', () => {
displayName: command.displayName, displayName: command.displayName,
}); });
expect(result.session).toEqual(session); expect(result.isOk()).toBe(true);
expect(result.isNewUser).toBe(true); expect(output.present).toHaveBeenCalledWith({
sessionToken: 'session-token',
userId: 'user-1',
displayName: 'New User',
email: 'new@example.com',
createdAt: expect.any(Date),
isNewUser: true,
});
}); });
it('throws when email format is invalid', async () => { it('returns error when email format is invalid', async () => {
const command: SignupCommandDTO = { const command: SignupWithEmailInput = {
email: 'invalid-email', email: 'invalid-email',
password: 'password123', password: 'password123',
displayName: 'User', displayName: 'User',
}; };
await expect(useCase.execute(command)).rejects.toThrow('Invalid email format'); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('INVALID_EMAIL_FORMAT');
}); });
it('throws when password is too short', async () => { it('returns error when password is too short', async () => {
const command: SignupCommandDTO = { const command: SignupWithEmailInput = {
email: 'valid@example.com', email: 'valid@example.com',
password: 'short', password: 'short',
displayName: 'User', displayName: 'User',
}; };
await expect(useCase.execute(command)).rejects.toThrow('Password must be at least 8 characters'); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('WEAK_PASSWORD');
}); });
it('throws when display name is too short', async () => { it('returns error when display name is too short', async () => {
const command: SignupCommandDTO = { const command: SignupWithEmailInput = {
email: 'valid@example.com', email: 'valid@example.com',
password: 'password123', password: 'password123',
displayName: ' ', displayName: ' ',
}; };
await expect(useCase.execute(command)).rejects.toThrow('Display name must be at least 2 characters'); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('INVALID_DISPLAY_NAME');
}); });
it('throws when email already exists', async () => { it('returns error when email already exists', async () => {
const command: SignupCommandDTO = { const command: SignupWithEmailInput = {
email: 'existing@example.com', email: 'existing@example.com',
password: 'password123', password: 'password123',
displayName: 'Existing User', displayName: 'Existing User',
@@ -116,6 +147,9 @@ describe('SignupWithEmailUseCase', () => {
userRepository.findByEmail.mockResolvedValue(existingUser); userRepository.findByEmail.mockResolvedValue(existingUser);
await expect(useCase.execute(command)).rejects.toThrow('An account with this email already exists'); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('EMAIL_ALREADY_EXISTS');
}); });
}); });

View File

@@ -1,12 +1,15 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { CreateAchievementUseCase, type IAchievementRepository } from './CreateAchievementUseCase'; import { CreateAchievementUseCase, type IAchievementRepository } from './CreateAchievementUseCase';
import { Achievement } from '@core/identity/domain/entities/Achievement'; import { Achievement } from '@core/identity/domain/entities/Achievement';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
describe('CreateAchievementUseCase', () => { describe('CreateAchievementUseCase', () => {
let achievementRepository: { let achievementRepository: {
save: Mock; save: Mock;
findById: Mock; findById: Mock;
}; };
let logger: Logger;
let output: UseCaseOutputPort<any> & { present: Mock };
let useCase: CreateAchievementUseCase; let useCase: CreateAchievementUseCase;
beforeEach(() => { beforeEach(() => {
@@ -15,7 +18,22 @@ describe('CreateAchievementUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
}; };
useCase = new CreateAchievementUseCase(achievementRepository as unknown as IAchievementRepository); logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new CreateAchievementUseCase(
achievementRepository as unknown as IAchievementRepository,
logger,
output,
);
}); });
it('creates an achievement and persists it', async () => { it('creates an achievement and persists it', async () => {
@@ -29,9 +47,9 @@ describe('CreateAchievementUseCase', () => {
points: 50, points: 50,
requirements: [ requirements: [
{ {
type: 'wins', type: 'wins' as const,
value: 1, value: 1,
operator: '>=', operator: '>=' as const,
}, },
], ],
isSecret: false, isSecret: false,
@@ -41,13 +59,12 @@ describe('CreateAchievementUseCase', () => {
const result = await useCase.execute(props); const result = await useCase.execute(props);
expect(result).toBeInstanceOf(Achievement); expect(result.isOk()).toBe(true);
expect(result.id).toBe(props.id); expect(achievementRepository.save).toHaveBeenCalledTimes(1);
expect(result.name).toBe(props.name); const savedAchievement = achievementRepository.save.mock.calls?.[0]?.[0];
expect(result.description).toBe(props.description); expect(savedAchievement).toBeInstanceOf(Achievement);
expect(result.category).toBe(props.category); expect(savedAchievement.id).toBe(props.id);
expect(result.points).toBe(props.points); expect(savedAchievement.name).toBe(props.name);
expect(result.requirements).toHaveLength(1); expect(output.present).toHaveBeenCalledWith({ achievement: savedAchievement });
expect(achievementRepository.save).toHaveBeenCalledWith(result);
}); });
}); });

View File

@@ -9,6 +9,8 @@ import { UserRating } from '../value-objects/UserRating';
* Centralizes rating calculation logic and ensures consistency across the system. * Centralizes rating calculation logic and ensures consistency across the system.
*/ */
export class RatingUpdateService implements IDomainService { export class RatingUpdateService implements IDomainService {
readonly serviceName = 'RatingUpdateService';
constructor( constructor(
private readonly userRatingRepository: IUserRatingRepository private readonly userRatingRepository: IUserRatingRepository
) {} ) {}

View File

@@ -11,7 +11,6 @@ import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media'; import { Media } from '../../domain/entities/Media';
import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestOutputPort extends UseCaseOutputPort<DeleteMediaResult> { interface TestOutputPort extends UseCaseOutputPort<DeleteMediaResult> {
present: Mock; present: Mock;

View File

@@ -43,7 +43,7 @@ export class DeleteMediaUseCase {
const media = await this.mediaRepo.findById(input.mediaId); const media = await this.mediaRepo.findById(input.mediaId);
if (!media) { if (!media) {
return Result.err({ return Result.err<void, DeleteMediaApplicationError>({
code: 'MEDIA_NOT_FOUND', code: 'MEDIA_NOT_FOUND',
details: { message: 'Media not found' }, details: { message: 'Media not found' },
}); });
@@ -65,14 +65,13 @@ export class DeleteMediaUseCase {
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[DeleteMediaUseCase] Error deleting media', { this.logger.error('[DeleteMediaUseCase] Error deleting media', err, {
error: err.message,
mediaId: input.mediaId, mediaId: input.mediaId,
}); });
return Result.err({ return Result.err<void, DeleteMediaApplicationError>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Unexpected repository error' }, details: { message: err.message || 'Unexpected repository error' },
}); });
} }
} }

View File

@@ -10,7 +10,6 @@ import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media'; import { Media } from '../../domain/entities/Media';
import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestOutputPort extends UseCaseOutputPort<GetMediaResult> { interface TestOutputPort extends UseCaseOutputPort<GetMediaResult> {
present: Mock; present: Mock;
@@ -71,7 +70,7 @@ describe('GetMediaUseCase', () => {
originalName: 'file.png', originalName: 'file.png',
mimeType: 'image/png', mimeType: 'image/png',
size: 123, size: 123,
url: MediaUrl.create('https://example.com/file.png'), url: 'https://example.com/file.png',
type: 'image', type: 'image',
uploadedBy: 'user-1', uploadedBy: 'user-1',
}); });

View File

@@ -48,26 +48,29 @@ export class GetMediaUseCase {
const media = await this.mediaRepo.findById(input.mediaId); const media = await this.mediaRepo.findById(input.mediaId);
if (!media) { if (!media) {
return Result.err({ return Result.err<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
code: 'MEDIA_NOT_FOUND', code: 'MEDIA_NOT_FOUND',
details: { message: 'Media not found' }, details: { message: 'Media not found' },
}); });
} }
this.output.present({ const mediaResult: GetMediaResult['media'] = {
media: { id: media.id,
id: media.id, filename: media.filename,
filename: media.filename, originalName: media.originalName,
originalName: media.originalName, mimeType: media.mimeType,
mimeType: media.mimeType, size: media.size,
size: media.size, url: media.url.value,
url: media.url.value, type: media.type,
type: media.type, uploadedBy: media.uploadedBy,
uploadedBy: media.uploadedBy, uploadedAt: media.uploadedAt,
uploadedAt: media.uploadedAt, };
metadata: media.metadata,
}, if (media.metadata !== undefined) {
}); mediaResult.metadata = media.metadata;
}
this.output.present({ media: mediaResult });
return Result.ok(undefined); return Result.ok(undefined);
} catch (error) { } catch (error) {
@@ -76,7 +79,7 @@ export class GetMediaUseCase {
mediaId: input.mediaId, mediaId: input.mediaId,
}); });
return Result.err({ return Result.err<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });

View File

@@ -56,13 +56,18 @@ export class RequestAvatarGenerationUseCase {
try { try {
const requestId = uuidv4(); const requestId = uuidv4();
const request = AvatarGenerationRequest.create({ const requestProps: Parameters<typeof AvatarGenerationRequest.create>[0] = {
id: requestId, id: requestId,
userId: input.userId, userId: input.userId,
facePhotoUrl: input.facePhotoData, facePhotoUrl: input.facePhotoData,
suitColor: input.suitColor, suitColor: input.suitColor,
style: input.style, };
});
if (input.style !== undefined) {
requestProps.style = input.style;
}
const request = AvatarGenerationRequest.create(requestProps);
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
@@ -77,7 +82,7 @@ export class RequestAvatarGenerationUseCase {
request.fail(errorMessage); request.fail(errorMessage);
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
return Result.err({ return Result.err<void, RequestAvatarGenerationApplicationError>({
code: 'FACE_VALIDATION_FAILED', code: 'FACE_VALIDATION_FAILED',
details: { message: errorMessage }, details: { message: errorMessage },
}); });
@@ -101,7 +106,7 @@ export class RequestAvatarGenerationUseCase {
request.fail(errorMessage); request.fail(errorMessage);
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
return Result.err({ return Result.err<void, RequestAvatarGenerationApplicationError>({
code: 'GENERATION_FAILED', code: 'GENERATION_FAILED',
details: { message: errorMessage }, details: { message: errorMessage },
}); });

View File

@@ -46,14 +46,14 @@ export class SelectAvatarUseCase {
const request = await this.avatarRepo.findById(input.requestId); const request = await this.avatarRepo.findById(input.requestId);
if (!request) { if (!request) {
return Result.err({ return Result.err<void, SelectAvatarApplicationError>({
code: 'REQUEST_NOT_FOUND', code: 'REQUEST_NOT_FOUND',
details: { message: 'Avatar generation request not found' }, details: { message: 'Avatar generation request not found' },
}); });
} }
if (request.status !== 'completed') { if (request.status !== 'completed') {
return Result.err({ return Result.err<void, SelectAvatarApplicationError>({
code: 'REQUEST_NOT_COMPLETED', code: 'REQUEST_NOT_COMPLETED',
details: { message: 'Avatar generation is not completed yet' }, details: { message: 'Avatar generation is not completed yet' },
}); });
@@ -62,7 +62,7 @@ export class SelectAvatarUseCase {
request.selectAvatar(input.selectedIndex); request.selectAvatar(input.selectedIndex);
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
const selectedAvatarUrl = request.selectedAvatarUrl; const selectedAvatarUrl = request.selectedAvatarUrl!;
this.output.present({ this.output.present({
requestId: input.requestId, requestId: input.requestId,

View File

@@ -69,8 +69,8 @@ export class UploadMediaUseCase {
} }
const uploadResult = await this.mediaStorage.uploadMedia(input.file.buffer, uploadOptions); const uploadResult = await this.mediaStorage.uploadMedia(input.file.buffer, uploadOptions);
if (!uploadResult.success) { if (!uploadResult.success || !uploadResult.url) {
return Result.err({ return Result.err<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
code: 'UPLOAD_FAILED', code: 'UPLOAD_FAILED',
details: { details: {
message: message:
@@ -88,7 +88,7 @@ export class UploadMediaUseCase {
// Create media entity // Create media entity
const mediaId = uuidv4(); const mediaId = uuidv4();
const media = Media.create({ const mediaProps: Parameters<typeof Media.create>[0] = {
id: mediaId, id: mediaId,
filename: uploadResult.filename || input.file.originalname, filename: uploadResult.filename || input.file.originalname,
originalName: input.file.originalname, originalName: input.file.originalname,
@@ -97,8 +97,13 @@ export class UploadMediaUseCase {
url: uploadResult.url, url: uploadResult.url,
type: mediaType, type: mediaType,
uploadedBy: input.uploadedBy, uploadedBy: input.uploadedBy,
metadata: input.metadata, };
});
if (input.metadata !== undefined) {
mediaProps.metadata = input.metadata;
}
const media = Media.create(mediaProps);
// Save to repository // Save to repository
await this.mediaRepo.save(media); await this.mediaRepo.save(media);
@@ -121,7 +126,7 @@ export class UploadMediaUseCase {
filename: input.file.originalname, filename: input.file.originalname,
}); });
return Result.err({ return Result.err<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });

View File

@@ -19,7 +19,7 @@ export interface MediaProps {
type: MediaType; type: MediaType;
uploadedBy: string; uploadedBy: string;
uploadedAt: Date; uploadedAt: Date;
metadata?: Record<string, any>; metadata?: Record<string, any> | undefined;
} }
export class Media implements IEntity<string> { export class Media implements IEntity<string> {
@@ -32,7 +32,7 @@ export class Media implements IEntity<string> {
readonly type: MediaType; readonly type: MediaType;
readonly uploadedBy: string; readonly uploadedBy: string;
readonly uploadedAt: Date; readonly uploadedAt: Date;
readonly metadata?: Record<string, any>; readonly metadata?: Record<string, any> | undefined;
private constructor(props: MediaProps) { private constructor(props: MediaProps) {
this.id = props.id; this.id = props.id;

View File

@@ -1,4 +1,4 @@
import { MediaUrl } from '../../../../../core/media/domain/value-objects/MediaUrl'; import { MediaUrl } from './MediaUrl';
describe('MediaUrl', () => { describe('MediaUrl', () => {
it('creates from valid http/https URLs', () => { it('creates from valid http/https URLs', () => {

View File

@@ -8,7 +8,7 @@ import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; // import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
export interface MarkNotificationReadCommand { export interface MarkNotificationReadCommand {
notificationId: string; notificationId: string;
@@ -45,7 +45,7 @@ export class MarkNotificationReadUseCase {
if (!notification) { if (!notification) {
this.logger.warn(`Notification not found for ID: ${command.notificationId}`); this.logger.warn(`Notification not found for ID: ${command.notificationId}`);
return Result.err({ return Result.err<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
code: 'NOTIFICATION_NOT_FOUND', code: 'NOTIFICATION_NOT_FOUND',
details: { message: 'Notification not found' }, details: { message: 'Notification not found' },
}); });
@@ -55,7 +55,7 @@ export class MarkNotificationReadUseCase {
this.logger.warn( this.logger.warn(
`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`, `Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`,
); );
return Result.err({ return Result.err<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
code: 'RECIPIENT_MISMATCH', code: 'RECIPIENT_MISMATCH',
details: { message: "Cannot mark another user's notification as read" }, details: { message: "Cannot mark another user's notification as read" },
}); });
@@ -91,7 +91,7 @@ export class MarkNotificationReadUseCase {
this.logger.error( this.logger.error(
`Failed to mark notification ${command.notificationId} as read: ${err.message}`, `Failed to mark notification ${command.notificationId} as read: ${err.message}`,
); );
return Result.err({ return Result.err<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });
@@ -129,7 +129,7 @@ export class MarkAllNotificationsReadUseCase {
return Result.ok(undefined); return Result.ok(undefined);
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
return Result.err({ return Result.err<void, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });
@@ -174,14 +174,14 @@ export class DismissNotificationUseCase {
); );
if (!notification) { if (!notification) {
return Result.err({ return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
code: 'NOTIFICATION_NOT_FOUND', code: 'NOTIFICATION_NOT_FOUND',
details: { message: 'Notification not found' }, details: { message: 'Notification not found' },
}); });
} }
if (notification.recipientId !== command.recipientId) { if (notification.recipientId !== command.recipientId) {
return Result.err({ return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
code: 'RECIPIENT_MISMATCH', code: 'RECIPIENT_MISMATCH',
details: { message: "Cannot dismiss another user's notification" }, details: { message: "Cannot dismiss another user's notification" },
}); });
@@ -197,7 +197,7 @@ export class DismissNotificationUseCase {
} }
if (!notification.canDismiss()) { if (!notification.canDismiss()) {
return Result.err({ return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
code: 'CANNOT_DISMISS_REQUIRING_RESPONSE', code: 'CANNOT_DISMISS_REQUIRING_RESPONSE',
details: { message: 'Cannot dismiss notification that requires response' }, details: { message: 'Cannot dismiss notification that requires response' },
}); });
@@ -215,7 +215,7 @@ export class DismissNotificationUseCase {
return Result.ok(undefined); return Result.ok(undefined);
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
return Result.err({ return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });

View File

@@ -11,7 +11,7 @@ import { NotificationPreference } from '../../domain/entities/NotificationPrefer
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes'; import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; // import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
/** /**
* Query: GetNotificationPreferencesQuery * Query: GetNotificationPreferencesQuery
@@ -46,7 +46,7 @@ export class GetNotificationPreferencesQuery {
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, err); this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, err);
return Result.err({ return Result.err<void, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });
@@ -101,7 +101,7 @@ export class UpdateChannelPreferenceUseCase {
`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`, `Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`,
err, err,
); );
return Result.err({ return Result.err<void, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });
@@ -155,7 +155,7 @@ export class UpdateTypePreferenceUseCase {
`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`, `Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`,
err, err,
); );
return Result.err({ return Result.err<void, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });
@@ -202,7 +202,7 @@ export class UpdateQuietHoursUseCase {
this.logger.warn( this.logger.warn(
`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`, `Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`,
); );
return Result.err({ return Result.err<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>({
code: 'INVALID_START_HOUR', code: 'INVALID_START_HOUR',
details: { message: 'Start hour must be between 0 and 23' }, details: { message: 'Start hour must be between 0 and 23' },
}); });
@@ -211,7 +211,7 @@ export class UpdateQuietHoursUseCase {
this.logger.warn( this.logger.warn(
`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`, `Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`,
); );
return Result.err({ return Result.err<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>({
code: 'INVALID_END_HOUR', code: 'INVALID_END_HOUR',
details: { message: 'End hour must be between 0 and 23' }, details: { message: 'End hour must be between 0 and 23' },
}); });
@@ -235,7 +235,7 @@ export class UpdateQuietHoursUseCase {
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, err); this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, err);
return Result.err({ return Result.err<void, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });
@@ -255,7 +255,7 @@ export interface SetDigestModeCommand {
export interface SetDigestModeResult { export interface SetDigestModeResult {
driverId: string; driverId: string;
enabled: boolean; enabled: boolean;
frequencyHours?: number; frequencyHours?: number | undefined;
} }
export type SetDigestModeErrorCode = export type SetDigestModeErrorCode =
@@ -272,7 +272,7 @@ export class SetDigestModeUseCase {
command: SetDigestModeCommand, command: SetDigestModeCommand,
): Promise<Result<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>> { ): Promise<Result<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>> {
if (command.frequencyHours !== undefined && command.frequencyHours < 1) { if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
return Result.err({ return Result.err<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>({
code: 'INVALID_FREQUENCY', code: 'INVALID_FREQUENCY',
details: { message: 'Digest frequency must be at least 1 hour' }, details: { message: 'Digest frequency must be at least 1 hour' },
}); });
@@ -287,15 +287,16 @@ export class SetDigestModeUseCase {
command.frequencyHours, command.frequencyHours,
); );
await this.preferenceRepository.save(updated); await this.preferenceRepository.save(updated);
this.output.present({ const result: SetDigestModeResult = {
driverId: command.driverId, driverId: command.driverId,
enabled: command.enabled, enabled: command.enabled,
frequencyHours: command.frequencyHours, frequencyHours: command.frequencyHours,
}); };
this.output.present(result);
return Result.ok(undefined); return Result.ok(undefined);
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
return Result.err({ return Result.err<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message: err.message }, details: { message: err.message },
}); });

View File

@@ -1,5 +1,5 @@
import { NotificationId } from '../../../../../core/notifications/domain/value-objects/NotificationId'; import { NotificationId } from './NotificationId';
import { NotificationDomainError } from '../../../../../core/notifications/domain/errors/NotificationDomainError'; import { NotificationDomainError } from '../errors/NotificationDomainError';
describe('NotificationId', () => { describe('NotificationId', () => {
it('creates a valid NotificationId from a non-empty string', () => { it('creates a valid NotificationId from a non-empty string', () => {

View File

@@ -1,4 +1,4 @@
import { QuietHours } from '../../../../../core/notifications/domain/value-objects/QuietHours'; import { QuietHours } from './QuietHours';
describe('QuietHours', () => { describe('QuietHours', () => {
it('creates a valid normal-range window', () => { it('creates a valid normal-range window', () => {

View File

@@ -2,10 +2,5 @@
* Infrastructure layer exports for notifications package * Infrastructure layer exports for notifications package
*/ */
// Repositories // This infrastructure layer is empty as the actual implementations
export { InMemoryNotificationRepository } from './repositories/InMemoryNotificationRepository'; // are in the adapters directory
export { InMemoryNotificationPreferenceRepository } from './repositories/InMemoryNotificationPreferenceRepository';
// Adapters
export { InAppNotificationAdapter } from "./InAppNotificationAdapter";
export { NotificationGatewayRegistry } from "./NotificationGatewayRegistry";

View File

@@ -149,8 +149,8 @@ export class GetSponsorBillingUseCase
if (invoices.length === 0) return 0; if (invoices.length === 0) return 0;
const sorted = [...invoices].sort((a, b) => a.date.localeCompare(b.date)); const sorted = [...invoices].sort((a, b) => a.date.localeCompare(b.date));
const first = new Date(sorted[0].date); const first = new Date(sorted[0]!.date);
const last = new Date(sorted[sorted.length - 1].date); const last = new Date(sorted[sorted.length - 1]!.date);
const months = this.monthDiff(first, last) || 1; const months = this.monthDiff(first, last) || 1;
const total = sorted.reduce((sum, inv) => sum + inv.totalAmount, 0); const total = sorted.reduce((sum, inv) => sum + inv.totalAmount, 0);

View File

@@ -0,0 +1,13 @@
export * from './AwardPrizeUseCase';
export * from './CreatePaymentUseCase';
export * from './CreatePrizeUseCase';
export * from './DeletePrizeUseCase';
export * from './GetMembershipFeesUseCase';
export * from './GetPaymentsUseCase';
export * from './GetPrizesUseCase';
export * from './GetSponsorBillingUseCase';
export * from './GetWalletUseCase';
export * from './ProcessWalletTransactionUseCase';
export * from './UpdateMemberPaymentUseCase';
export * from './UpdatePaymentStatusUseCase';
export * from './UpsertMembershipFeeUseCase';

View File

@@ -0,0 +1,93 @@
export interface LeagueConfigFormModel {
basics?: {
name?: string;
description?: string;
visibility?: string;
gameId?: string;
};
structure?: {
mode?: string;
maxDrivers?: number;
};
championships?: {
enableDriverChampionship?: boolean;
enableTeamChampionship?: boolean;
enableNationsChampionship?: boolean;
enableTrophyChampionship?: boolean;
};
scoring?: {
patternId?: string;
customScoringEnabled?: boolean;
};
dropPolicy?: {
strategy?: string;
n?: number;
};
timings?: {
qualifyingMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
seasonStartDate?: string;
raceStartTime?: string;
timezoneId?: string;
recurrenceStrategy?: string;
weekdays?: string[];
intervalWeeks?: number;
monthlyOrdinal?: number;
monthlyWeekday?: string;
};
stewarding?: {
decisionMode?: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
};
}
export interface LeagueStructureFormDTO {
name: string;
description: string;
ownerId: string;
}
export interface LeagueChampionshipsFormDTO {
pointsSystem: string;
customPoints?: Record<number, number>;
}
export interface LeagueScoringFormDTO {
pointsSystem: string;
customPoints?: Record<number, number>;
}
export interface LeagueDropPolicyFormDTO {
dropWeeks?: number;
bestResults?: number;
}
export interface LeagueStructureMode {
mode: 'simple' | 'advanced';
}
export interface LeagueTimingsFormDTO {
sessionDuration?: number;
qualifyingFormat?: string;
}
export interface LeagueStewardingFormDTO {
decisionMode: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
}

View File

@@ -0,0 +1,30 @@
export interface LeagueDTO {
id: string;
name: string;
description: string;
ownerId: string;
settings: {
pointsSystem: string;
sessionDuration?: number;
qualifyingFormat?: string;
customPoints?: Record<number, number>;
maxDrivers?: number;
stewarding?: {
decisionMode: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
};
};
createdAt: Date;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
}

View File

@@ -0,0 +1,11 @@
export interface LeagueDriverSeasonStatsDTO {
driverId: string;
leagueId: string;
seasonId: string;
totalPoints: number;
averagePoints: number;
bestFinish: number;
podiums: number;
races: number;
wins: number;
}

View File

@@ -0,0 +1,21 @@
export interface LeagueScheduleDTO {
leagueId: string;
seasonId: string;
races: Array<{
id: string;
name: string;
scheduledTime: Date;
trackId: string;
status: string;
}>;
}
export interface LeagueSchedulePreviewDTO {
leagueId: string;
preview: Array<{
id: string;
name: string;
scheduledTime: Date;
trackId: string;
}>;
}

View File

@@ -0,0 +1,9 @@
export interface RaceDTO {
id: string;
leagueId: string;
name: string;
scheduledTime: Date;
trackId: string;
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
results?: string[];
}

View File

@@ -1,3 +0,0 @@
export interface ReopenRaceCommandDTO {
raceId: string;
}

View File

@@ -0,0 +1,9 @@
export interface ResultDTO {
id: string;
raceId: string;
driverId: string;
position: number;
points: number;
time?: string;
incidents?: number;
}

View File

@@ -0,0 +1,10 @@
export interface StandingDTO {
id: string;
leagueId: string;
driverId: string;
position: number;
points: number;
races: number;
wins: number;
podiums: number;
}

View File

@@ -0,0 +1,7 @@
export * from './LeagueConfigFormDTO';
export * from './LeagueDTO';
export * from './LeagueDriverSeasonStatsDTO';
export * from './LeagueScheduleDTO';
export * from './RaceDTO';
export * from './ResultDTO';
export * from './StandingDTO';

View File

@@ -0,0 +1,27 @@
export interface DriverRatingChange {
driverId: string;
oldRating: number;
newRating: number;
change: number;
}
export interface RatingChange {
driverId: string;
oldRating: number;
newRating: number;
change: number;
}
export interface DriverRatingPort {
calculateRatingChange(
driverId: string,
raceId: string,
finalPosition: number,
incidents: number,
baseRating: number,
): Promise<RatingChange>;
getDriverRating(driverId: string): Promise<number>;
updateDriverRating(driverId: string, newRating: number): Promise<void>;
}

View File

@@ -0,0 +1,15 @@
export interface AllRacesPageOutputPort {
races: Array<{
id: string;
name: string;
leagueId: string;
leagueName: string;
scheduledTime: Date;
trackId: string;
status: string;
participants: number;
}>;
total: number;
page: number;
limit: number;
}

View File

@@ -0,0 +1,12 @@
export interface ChampionshipStandingsOutputPort {
leagueId: string;
seasonId: string;
standings: Array<{
driverId: string;
position: number;
points: number;
driverName: string;
teamId?: string;
teamName?: string;
}>;
}

View File

@@ -0,0 +1,8 @@
export interface ChampionshipStandingsRowOutputPort {
driverId: string;
position: number;
points: number;
driverName: string;
teamId?: string;
teamName?: string;
}

View File

@@ -0,0 +1,7 @@
export interface DriverRegistrationStatusOutputPort {
driverId: string;
raceId: string;
leagueId: string;
registered: boolean;
status: 'registered' | 'withdrawn' | 'pending' | 'not_registered';
}

View File

@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Season } from '../../domain/entities/Season'; import { Season } from '../../domain/entities/season/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { Weekday } from '../../domain/types/Weekday'; import type { Weekday } from '../../domain/types/Weekday';
@@ -32,7 +32,7 @@ export interface SeasonSummaryDTO {
seasonId: string; seasonId: string;
leagueId: string; leagueId: string;
name: string; name: string;
status: import('../../domain/entities/Season').SeasonStatus; status: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
isPrimary: boolean; isPrimary: boolean;
@@ -56,7 +56,7 @@ export interface SeasonDetailsDTO {
leagueId: string; leagueId: string;
gameId: string; gameId: string;
name: string; name: string;
status: import('../../domain/entities/Season').SeasonStatus; status: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
maxDrivers?: number; maxDrivers?: number;
@@ -69,11 +69,11 @@ export interface SeasonDetailsDTO {
customScoringEnabled: boolean; customScoringEnabled: boolean;
}; };
dropPolicy?: { dropPolicy?: {
strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy; strategy: string;
n?: number; n?: number;
}; };
stewarding?: { stewarding?: {
decisionMode: import('../../domain/entities/League').StewardingDecisionMode; decisionMode: string;
requiredVotes?: number; requiredVotes?: number;
requireDefense: boolean; requireDefense: boolean;
defenseTimeLimit: number; defenseTimeLimit: number;
@@ -95,7 +95,7 @@ export interface ManageSeasonLifecycleCommand {
export interface ManageSeasonLifecycleResultDTO { export interface ManageSeasonLifecycleResultDTO {
seasonId: string; seasonId: string;
status: import('../../domain/entities/Season').SeasonStatus; status: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
startDate?: Date; startDate?: Date;
endDate?: Date; endDate?: Date;
} }
@@ -140,7 +140,7 @@ export class SeasonApplicationService {
const season = Season.create({ const season = Season.create({
id: seasonId, id: seasonId,
leagueId: league.id, leagueId: league.id.toString(),
gameId: command.gameId, gameId: command.gameId,
name: command.name, name: command.name,
year: new Date().getFullYear(), year: new Date().getFullYear(),
@@ -163,7 +163,7 @@ export class SeasonApplicationService {
throw new Error(`League not found: ${query.leagueId}`); throw new Error(`League not found: ${query.leagueId}`);
} }
const seasons = await this.seasonRepository.listByLeague(league.id); const seasons = await this.seasonRepository.listByLeague(league.id.toString());
const items: SeasonSummaryDTO[] = seasons.map((s) => ({ const items: SeasonSummaryDTO[] = seasons.map((s) => ({
seasonId: s.id, seasonId: s.id,
leagueId: s.leagueId, leagueId: s.leagueId,
@@ -184,7 +184,7 @@ export class SeasonApplicationService {
} }
const season = await this.seasonRepository.findById(query.seasonId); const season = await this.seasonRepository.findById(query.seasonId);
if (!season || season.leagueId !== league.id) { if (!season || season.leagueId !== league.id.toString()) {
throw new Error(`Season ${query.seasonId} does not belong to league ${league.id}`); throw new Error(`Season ${query.seasonId} does not belong to league ${league.id}`);
} }
@@ -248,7 +248,7 @@ export class SeasonApplicationService {
} }
const season = await this.seasonRepository.findById(command.seasonId); const season = await this.seasonRepository.findById(command.seasonId);
if (!season || season.leagueId !== league.id) { if (!season || season.leagueId !== league.id.toString()) {
throw new Error(`Season ${command.seasonId} does not belong to league ${league.id}`); throw new Error(`Season ${command.seasonId} does not belong to league ${league.id}`);
} }
@@ -288,29 +288,38 @@ export class SeasonApplicationService {
maxDrivers?: number; maxDrivers?: number;
} { } {
const schedule = this.buildScheduleFromTimings(config); const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom', const scoringConfig = config.scoring
customScoringEnabled: config.scoring.customScoringEnabled ?? false, ? new SeasonScoringConfig({
}); scoringPresetId: config.scoring.patternId ?? 'custom',
const dropPolicy = new SeasonDropPolicy({ customScoringEnabled: config.scoring.customScoringEnabled ?? false,
strategy: config.dropPolicy.strategy, })
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}), : undefined;
});
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 dropPolicy = config.dropPolicy
? new SeasonDropPolicy({
strategy: config.dropPolicy.strategy as any,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
})
: undefined;
const stewardingConfig = config.stewarding
? new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode as any,
...(config.stewarding.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense ?? false,
defenseTimeLimit: config.stewarding.defenseTimeLimit ?? 48,
voteTimeLimit: config.stewarding.voteTimeLimit ?? 72,
protestDeadlineHours: config.stewarding.protestDeadlineHours ?? 48,
stewardingClosesHours: config.stewarding.stewardingClosesHours ?? 168,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired ?? true,
})
: undefined;
const structure = config.structure ?? {};
const maxDrivers = const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0 typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers ? structure.maxDrivers
@@ -318,28 +327,28 @@ export class SeasonApplicationService {
return { return {
...(schedule !== undefined ? { schedule } : {}), ...(schedule !== undefined ? { schedule } : {}),
scoringConfig, ...(scoringConfig !== undefined ? { scoringConfig } : {}),
dropPolicy, ...(dropPolicy !== undefined ? { dropPolicy } : {}),
stewardingConfig, ...(stewardingConfig !== undefined ? { stewardingConfig } : {}),
...(maxDrivers !== undefined ? { maxDrivers } : {}), ...(maxDrivers !== undefined ? { maxDrivers } : {}),
}; };
} }
private buildScheduleFromTimings(config: LeagueConfigFormModel): SeasonSchedule | undefined { private buildScheduleFromTimings(config: LeagueConfigFormModel): SeasonSchedule | undefined {
const { timings } = config; const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) { if (!timings || !timings.seasonStartDate || !timings.raceStartTime) {
return undefined; return undefined;
} }
const startDate = new Date(timings.seasonStartDate); const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime); const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC'; const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId); const timezone = LeagueTimezone.create(timezoneId);
const plannedRounds = const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0 typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
? timings.roundsPlanned ? timings.roundsPlanned
: timings.sessionCount; : timings.sessionCount ?? 0;
const recurrence = (() => { const recurrence = (() => {
const weekdays: WeekdaySet = const weekdays: WeekdaySet =
@@ -353,10 +362,10 @@ export class SeasonApplicationService {
weekdays, weekdays,
); );
case 'monthlyNthWeekday': { case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({ const pattern = MonthlyRecurrencePattern.create(
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4, (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday, (timings.monthlyWeekday ?? 'Mon') as Weekday,
}); );
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern); return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
} }
case 'weekly': case 'weekly':

View File

@@ -1,4 +1,4 @@
import type { NotificationService } from '@/notifications/application/ports/NotificationService'; import type { NotificationService } from '@core/notifications/application/ports/NotificationService';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';

View File

@@ -5,7 +5,7 @@
* This creates an active sponsorship and notifies the sponsor. * This creates an active sponsorship and notifies the sponsor.
*/ */
import type { NotificationService } from '@/notifications/application/ports/NotificationService'; import type { NotificationService } from '@core/notifications/application/ports/NotificationService';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';

View File

@@ -200,11 +200,16 @@ describe('ApplyForSponsorshipUseCase', () => {
}); });
it('should return error when offered amount is less than minimum', async () => { it('should return error when offered amount is less than minimum', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase( const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository, mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
); );
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
@@ -228,11 +233,16 @@ describe('ApplyForSponsorshipUseCase', () => {
}); });
it('should create sponsorship request and return result on success', async () => { it('should create sponsorship request and return result on success', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase( const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository, mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
); );
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });

View File

@@ -52,6 +52,7 @@ export class ApplyForSponsorshipUseCase {
| 'NO_SLOTS_AVAILABLE' | 'NO_SLOTS_AVAILABLE'
| 'PENDING_REQUEST_EXISTS' | 'PENDING_REQUEST_EXISTS'
| 'OFFERED_AMOUNT_TOO_LOW' | 'OFFERED_AMOUNT_TOO_LOW'
| 'VALIDATION_ERROR'
> >
> >
> { > {
@@ -82,10 +83,17 @@ export class ApplyForSponsorshipUseCase {
return Result.err({ code: 'ENTITY_NOT_ACCEPTING_APPLICATIONS' }); return Result.err({ code: 'ENTITY_NOT_ACCEPTING_APPLICATIONS' });
} }
// Validate tier type
const tier = input.tier as 'main' | 'secondary';
if (tier !== 'main' && tier !== 'secondary') {
this.logger.warn('Invalid tier', { tier: input.tier });
return Result.err({ code: 'VALIDATION_ERROR' });
}
// Check if the requested tier slot is available // Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(input.tier); const slotAvailable = pricing.isSlotAvailable(tier);
if (!slotAvailable) { if (!slotAvailable) {
this.logger.warn(`No ${input.tier} sponsorship slots are available for entity ${input.entityId}`); this.logger.warn(`No ${tier} sponsorship slots are available for entity ${input.entityId}`);
return Result.err({ code: 'NO_SLOTS_AVAILABLE' }); return Result.err({ code: 'NO_SLOTS_AVAILABLE' });
} }
@@ -105,24 +113,24 @@ export class ApplyForSponsorshipUseCase {
} }
// Validate offered amount meets minimum price // Validate offered amount meets minimum price
const minPrice = pricing.getPrice(input.tier); const minPrice = pricing.getPrice(tier);
if (minPrice && input.offeredAmount < minPrice.amount) { if (minPrice && input.offeredAmount < minPrice.amount) {
this.logger.warn( this.logger.warn(
`Offered amount ${input.offeredAmount} is less than minimum ${minPrice.amount} for entity ${input.entityId}, tier ${input.tier}`, `Offered amount ${input.offeredAmount} is less than minimum ${minPrice.amount} for entity ${input.entityId}, tier ${tier}`,
); );
return Result.err({ code: 'OFFERED_AMOUNT_TOO_LOW' }); return Result.err({ code: 'OFFERED_AMOUNT_TOO_LOW' });
} }
// Create the sponsorship request // Create the sponsorship request
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const offeredAmount = Money.create(input.offeredAmount, input.currency ?? 'USD'); const offeredAmount = Money.create(input.offeredAmount, (input.currency as any) || 'USD');
const request = SponsorshipRequest.create({ const request = SponsorshipRequest.create({
id: requestId, id: requestId,
sponsorId: input.sponsorId, sponsorId: input.sponsorId,
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
tier: input.tier, tier,
offeredAmount, offeredAmount,
...(input.message !== undefined ? { message: input.message } : {}), ...(input.message !== undefined ? { message: input.message } : {}),
}); });

View File

@@ -5,7 +5,6 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ApplyPenaltyUseCase', () => { describe('ApplyPenaltyUseCase', () => {
let mockPenaltyRepo: { let mockPenaltyRepo: {
@@ -49,12 +48,17 @@ describe('ApplyPenaltyUseCase', () => {
}); });
it('should return error when race does not exist', async () => { it('should return error when race does not exist', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase( const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository, mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository, mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository, mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as any,
); );
mockRaceRepo.findById.mockResolvedValue(null); mockRaceRepo.findById.mockResolvedValue(null);
@@ -73,12 +77,17 @@ describe('ApplyPenaltyUseCase', () => {
}); });
it('should return error when steward does not have authority', async () => { it('should return error when steward does not have authority', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase( const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository, mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository, mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository, mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as any,
); );
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -100,12 +109,17 @@ describe('ApplyPenaltyUseCase', () => {
}); });
it('should return error when protest does not exist', async () => { it('should return error when protest does not exist', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase( const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository, mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository, mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository, mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as any,
); );
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -129,12 +143,17 @@ describe('ApplyPenaltyUseCase', () => {
}); });
it('should return error when protest is not upheld', async () => { it('should return error when protest is not upheld', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase( const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository, mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository, mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository, mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as any,
); );
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -158,12 +177,17 @@ describe('ApplyPenaltyUseCase', () => {
}); });
it('should return error when protest is not for this race', async () => { it('should return error when protest is not for this race', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase( const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository, mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository, mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository, mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as any,
); );
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -187,12 +211,17 @@ describe('ApplyPenaltyUseCase', () => {
}); });
it('should create penalty and return result on success', async () => { it('should create penalty and return result on success', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyPenaltyUseCase( const useCase = new ApplyPenaltyUseCase(
mockPenaltyRepo as unknown as IPenaltyRepository, mockPenaltyRepo as unknown as IPenaltyRepository,
mockProtestRepo as unknown as IProtestRepository, mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository, mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
output as any,
); );
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });

View File

@@ -68,10 +68,10 @@ export class ApplyPenaltyUseCase {
// Validate steward has authority (owner or admin of the league) // Validate steward has authority (owner or admin of the league)
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const stewardMembership = memberships.find( const stewardMembership = memberships.find(
m => m.driverId === command.stewardId && m.status === 'active' m => m.driverId.toString() === command.stewardId && m.status.toString() === 'active'
); );
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) { if (!stewardMembership || (stewardMembership.role.toString() !== 'owner' && stewardMembership.role.toString() !== 'admin')) {
this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`); this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`);
return Result.err({ code: 'INSUFFICIENT_AUTHORITY' }); return Result.err({ code: 'INSUFFICIENT_AUTHORITY' });
} }
@@ -84,7 +84,7 @@ export class ApplyPenaltyUseCase {
this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`); this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`);
return Result.err({ code: 'PROTEST_NOT_FOUND' }); return Result.err({ code: 'PROTEST_NOT_FOUND' });
} }
if (protest.status !== 'upheld') { if (protest.status.toString() !== 'upheld') {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`); this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`);
return Result.err({ code: 'PROTEST_NOT_UPHELD' }); return Result.err({ code: 'PROTEST_NOT_UPHELD' });
} }

View File

@@ -25,7 +25,6 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
const useCase = new ApproveLeagueJoinRequestUseCase( const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output as unknown as UseCaseOutputPort<any>,
); );
const leagueId = 'league-1'; const leagueId = 'league-1';
@@ -34,22 +33,24 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue(joinRequests); mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue(joinRequests);
const result = await useCase.execute({ leagueId, requestId }); const result = await useCase.execute({ leagueId, requestId }, output as unknown as UseCaseOutputPort<any>);
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toBeUndefined();
expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId); expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith({ expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith(
id: expect.any(String), expect.objectContaining({
leagueId, id: expect.any(String),
driverId: 'driver-1', leagueId: expect.objectContaining({ toString: expect.any(Function) }),
role: 'member', driverId: expect.objectContaining({ toString: expect.any(Function) }),
status: 'active', role: expect.objectContaining({ toString: expect.any(Function) }),
joinedAt: expect.any(Date), status: expect.objectContaining({ toString: expect.any(Function) }),
}); joinedAt: expect.any(Date),
})
);
expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request approved.' }); expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request approved.' });
}); });
it('should return error if request not found', async () => { it('should return error if request not found', async () => {
const output = { const output = {
present: vi.fn(), present: vi.fn(),
@@ -57,12 +58,11 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
const useCase = new ApproveLeagueJoinRequestUseCase( const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output as unknown as UseCaseOutputPort<any>,
); );
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]); mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]);
const result = await useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }); const result = await useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }, output as unknown as UseCaseOutputPort<any>);
expect(result.isOk()).toBe(false); expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('JOIN_REQUEST_NOT_FOUND'); expect(result.error!.code).toBe('JOIN_REQUEST_NOT_FOUND');

View File

@@ -4,6 +4,10 @@ import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorC
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { JoinedAt } from '../../domain/value-objects/JoinedAt'; import { JoinedAt } from '../../domain/value-objects/JoinedAt';
import { LeagueId } from '../../domain/entities/LeagueId';
import { DriverId } from '../../domain/entities/DriverId';
import { MembershipRole } from '../../domain/entities/MembershipRole';
import { MembershipStatus } from '../../domain/entities/MembershipStatus';
export interface ApproveLeagueJoinRequestInput { export interface ApproveLeagueJoinRequestInput {
leagueId: string; leagueId: string;
@@ -33,10 +37,10 @@ export class ApproveLeagueJoinRequestUseCase {
await this.leagueMembershipRepository.removeJoinRequest(input.requestId); await this.leagueMembershipRepository.removeJoinRequest(input.requestId);
await this.leagueMembershipRepository.saveMembership({ await this.leagueMembershipRepository.saveMembership({
id: randomUUID(), id: randomUUID(),
leagueId: input.leagueId, leagueId: LeagueId.create(input.leagueId),
driverId: request.driverId, driverId: DriverId.create(request.driverId.toString()),
role: 'member', role: MembershipRole.create('member'),
status: 'active', status: MembershipStatus.create('active'),
joinedAt: JoinedAt.create(new Date()), joinedAt: JoinedAt.create(new Date()),
}); });

View File

@@ -92,7 +92,7 @@ describe('CancelRaceUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED'); expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED');
expect(result.unwrapErr().details?.message).toContain('already cancelled'); expect((result.unwrapErr() as any).details.message).toContain('already cancelled');
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -114,7 +114,7 @@ describe('CancelRaceUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED'); expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED');
expect(result.unwrapErr().details?.message).toContain('completed race'); expect((result.unwrapErr() as any).details.message).toContain('completed race');
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -42,7 +42,10 @@ export class CancelRaceUseCase {
const race = await this.raceRepository.findById(raceId); const race = await this.raceRepository.findById(raceId);
if (!race) { if (!race) {
this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`); this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
return Result.err({ code: 'RACE_NOT_FOUND' }); return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' }
});
} }
const cancelledRace = race.cancel(); const cancelledRace = race.cancel();

View File

@@ -3,7 +3,7 @@ import { CloseRaceEventStewardingUseCase, type CloseRaceEventStewardingResult }
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { DomainEventPublisher } from '@/shared/domain/DomainEvent'; import type { DomainEventPublisher } from '@core/shared/domain/DomainEvent';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { RaceEvent } from '../../domain/entities/RaceEvent'; import { RaceEvent } from '../../domain/entities/RaceEvent';
import { Session } from '../../domain/entities/Session'; import { Session } from '../../domain/entities/Session';
@@ -118,7 +118,7 @@ describe('CloseRaceEventStewardingUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details?.message).toContain('DB error'); expect((result.unwrapErr() as any).details.message).toContain('DB error');
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -2,7 +2,7 @@ import type { Logger } from '@core/shared/application';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { DomainEventPublisher } from '@/shared/domain/DomainEvent'; import type { DomainEventPublisher } from '@core/shared/domain/DomainEvent';
import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';

View File

@@ -3,13 +3,11 @@ import {
CompleteDriverOnboardingUseCase, CompleteDriverOnboardingUseCase,
type CompleteDriverOnboardingInput, type CompleteDriverOnboardingInput,
type CompleteDriverOnboardingResult, type CompleteDriverOnboardingResult,
type CompleteDriverOnboardingApplicationError,
} from './CompleteDriverOnboardingUseCase'; } from './CompleteDriverOnboardingUseCase';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver'; import { Driver } from '../../domain/entities/Driver';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
import type { Result } from '@core/shared/application/Result';
describe('CompleteDriverOnboardingUseCase', () => { describe('CompleteDriverOnboardingUseCase', () => {
let useCase: CompleteDriverOnboardingUseCase; let useCase: CompleteDriverOnboardingUseCase;
@@ -35,7 +33,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
useCase = new CompleteDriverOnboardingUseCase( useCase = new CompleteDriverOnboardingUseCase(
driverRepository as unknown as IDriverRepository, driverRepository as unknown as IDriverRepository,
logger, logger,
output,
); );
}); });
@@ -62,9 +59,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command); const result = await useCase.execute(command);
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toEqual({ driver: createdDriver });
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
expect(driverRepository.findById).toHaveBeenCalledWith('user-1'); expect(driverRepository.findById).toHaveBeenCalledWith('user-1');
expect(driverRepository.create).toHaveBeenCalledWith( expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -99,7 +94,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('DRIVER_ALREADY_EXISTS'); expect(result.unwrapErr().code).toBe('DRIVER_ALREADY_EXISTS');
expect(driverRepository.create).not.toHaveBeenCalled(); expect(driverRepository.create).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
}); });
it('should return error when repository create throws', async () => { it('should return error when repository create throws', async () => {
@@ -120,7 +114,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
const error = result.unwrapErr() as { code: 'REPOSITORY_ERROR'; details?: { message: string } }; const error = result.unwrapErr() as { code: 'REPOSITORY_ERROR'; details?: { message: string } };
expect(error.code).toBe('REPOSITORY_ERROR'); expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('DB error'); expect(error.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
}); });
it('should handle bio being undefined', async () => { it('should handle bio being undefined', async () => {
@@ -144,7 +137,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command); const result = await useCase.execute(command);
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toEqual({ driver: createdDriver });
expect(driverRepository.create).toHaveBeenCalledWith( expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: 'user-1', id: 'user-1',
@@ -154,7 +147,5 @@ describe('CompleteDriverOnboardingUseCase', () => {
bio: undefined, bio: undefined,
}) })
); );
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
}); });
}); });

View File

@@ -2,7 +2,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import { Driver } from '../../domain/entities/Driver'; import { Driver } from '../../domain/entities/Driver';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCase, UseCaseOutputPort } from '@core/shared/application'; import type { UseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
export interface CompleteDriverOnboardingInput { export interface CompleteDriverOnboardingInput {

View File

@@ -57,13 +57,19 @@ export class CompleteRaceUseCase {
const race = await this.raceRepository.findById(raceId); const race = await this.raceRepository.findById(raceId);
if (!race) { if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND' }); return Result.err<void, ApplicationErrorCode<CompleteRaceErrorCode, { message: string }>>({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' },
});
} }
// Get registered drivers for this race // Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) { if (registeredDriverIds.length === 0) {
return Result.err({ code: 'NO_REGISTERED_DRIVERS' }); return Result.err<void, ApplicationErrorCode<CompleteRaceErrorCode, { message: string }>>({
code: 'NO_REGISTERED_DRIVERS',
details: { message: 'No registered drivers for this race' },
});
} }
// Get driver ratings using injected provider // Get driver ratings using injected provider
@@ -175,9 +181,10 @@ export class CompleteRaceUseCase {
// Group results by driver // Group results by driver
const resultsByDriver = new Map<string, RaceResult[]>(); const resultsByDriver = new Map<string, RaceResult[]>();
for (const result of results) { for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || []; const driverIdStr = result.driverId.toString();
const existing = resultsByDriver.get(driverIdStr) || [];
existing.push(result); existing.push(result);
resultsByDriver.set(result.driverId, existing); resultsByDriver.set(driverIdStr, existing);
} }
// Update or create standings for each driver // Update or create standings for each driver
@@ -193,7 +200,7 @@ export class CompleteRaceUseCase {
// Add all results for this driver (should be just one for this race) // Add all results for this driver (should be just one for this race)
for (const result of driverResults) { for (const result of driverResults) {
standing = standing.addRaceResult(result.position, { standing = standing.addRaceResult(result.position.toNumber(), {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1 1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
}); });
} }

View File

@@ -52,16 +52,25 @@ export class CompleteRaceUseCaseWithRatings {
const race = await this.raceRepository.findById(raceId); const race = await this.raceRepository.findById(raceId);
if (!race) { if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND' }); return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' }
});
} }
if (race.status === 'completed') { if (race.status === 'completed') {
return Result.err({ code: 'ALREADY_COMPLETED' }); return Result.err({
code: 'ALREADY_COMPLETED',
details: { message: 'Race already completed' }
});
} }
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) { if (registeredDriverIds.length === 0) {
return Result.err({ code: 'NO_REGISTERED_DRIVERS' }); return Result.err({
code: 'NO_REGISTERED_DRIVERS',
details: { message: 'No registered drivers' }
});
} }
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds); const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
@@ -107,23 +116,24 @@ export class CompleteRaceUseCaseWithRatings {
private async updateStandings(leagueId: string, results: RaceResult[]): Promise<void> { private async updateStandings(leagueId: string, results: RaceResult[]): Promise<void> {
const resultsByDriver = new Map<string, RaceResult[]>(); const resultsByDriver = new Map<string, RaceResult[]>();
for (const result of results) { for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || []; const driverIdStr = result.driverId.toString();
const existing = resultsByDriver.get(driverIdStr) || [];
existing.push(result); existing.push(result);
resultsByDriver.set(result.driverId, existing); resultsByDriver.set(driverIdStr, existing);
} }
for (const [driverId, driverResults] of resultsByDriver) { for (const [driverIdStr, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId); let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverIdStr, leagueId);
if (!standing) { if (!standing) {
standing = Standing.create({ standing = Standing.create({
leagueId, leagueId,
driverId, driverId: driverIdStr,
}); });
} }
for (const result of driverResults) { for (const result of driverResults) {
standing = standing.addRaceResult(result.position, { standing = standing.addRaceResult(result.position.toNumber(), {
1: 25, 1: 25,
2: 18, 2: 18,
3: 15, 3: 15,
@@ -143,11 +153,11 @@ export class CompleteRaceUseCaseWithRatings {
private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise<void> { private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise<void> {
const driverResults = results.map((result) => ({ const driverResults = results.map((result) => ({
driverId: result.driverId, driverId: result.driverId.toString(),
position: result.position, position: result.position.toNumber(),
totalDrivers, totalDrivers,
incidents: result.incidents, incidents: result.incidents.toNumber(),
startPosition: result.startPosition, startPosition: result.startPosition.toNumber(),
})); }));
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults); await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);

View File

@@ -89,10 +89,10 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1); expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateLeagueWithSeasonAndScoringResult; const presented = (output.present as Mock).mock.calls[0]?.[0] as unknown as CreateLeagueWithSeasonAndScoringResult;
expect(presented.league.id.toString()).toBeDefined(); expect(presented?.league.id.toString()).toBeDefined();
expect(presented.season.id).toBeDefined(); expect(presented?.season.id).toBeDefined();
expect(presented.scoringConfig.seasonId.toString()).toBe(presented.season.id); expect(presented?.scoringConfig.seasonId.toString()).toBe(presented?.season.id);
expect(leagueRepository.create).toHaveBeenCalledTimes(1); expect(leagueRepository.create).toHaveBeenCalledTimes(1);
expect(seasonRepository.create).toHaveBeenCalledTimes(1); expect(seasonRepository.create).toHaveBeenCalledTimes(1);
expect(leagueScoringConfigRepository.save).toHaveBeenCalledTimes(1); expect(leagueScoringConfigRepository.save).toHaveBeenCalledTimes(1);
@@ -114,8 +114,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); const err = result.unwrapErr();
expect(result.unwrapErr().details?.message).toBe('League name is required'); expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('League name is required');
}
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -135,8 +138,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand); const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); const err = result.unwrapErr();
expect(result.unwrapErr().details?.message).toBe('League ownerId is required'); expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('League ownerId is required');
}
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -157,7 +163,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('gameId is required'); expect((result.unwrapErr() as any).details.message).toBe('gameId is required');
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -175,8 +181,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand); const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); const err = result.unwrapErr();
expect(result.unwrapErr().details?.message).toBe('visibility is required'); expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('visibility is required');
}
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -197,8 +206,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); const err = result.unwrapErr();
expect(result.unwrapErr().details?.message).toBe('maxDrivers must be greater than 0 when provided'); expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('maxDrivers must be greater than 0 when provided');
}
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -219,8 +231,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); const err = result.unwrapErr();
expect(result.unwrapErr().details?.message).toContain('Ranked leagues require at least 10 drivers'); expect(err.code).toBe('VALIDATION_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toContain('Ranked leagues require at least 10 drivers');
}
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -243,8 +258,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('UNKNOWN_PRESET'); const err = result.unwrapErr();
expect(result.unwrapErr().details?.message).toBe('Unknown scoring preset: unknown-preset'); expect(err.code).toBe('UNKNOWN_PRESET');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('Unknown scoring preset: unknown-preset');
}
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
@@ -272,8 +290,11 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command); const result = await useCase.execute(command);
expect(result.isErr()).toBe(true); expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); const err = result.unwrapErr();
expect(result.unwrapErr().details?.message).toBe('DB error'); expect(err.code).toBe('REPOSITORY_ERROR');
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
expect(err.details.message).toBe('DB error');
}
expect(output.present).not.toHaveBeenCalled(); expect(output.present).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -117,12 +117,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase {
const scoringConfig = LeagueScoringConfig.create({ const scoringConfig = LeagueScoringConfig.create({
seasonId, seasonId,
scoringPresetId: preset.id, scoringPresetId: preset.id,
championships: { championships: [], // Empty array - will be populated by preset
driver: command.enableDriverChampionship,
team: command.enableTeamChampionship,
nations: command.enableNationsChampionship,
trophy: command.enableTrophyChampionship,
},
}); });
this.logger.debug(`Scoring configuration created from preset ${preset.id}.`); this.logger.debug(`Scoring configuration created from preset ${preset.id}.`);

View File

@@ -70,7 +70,9 @@ function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>)
}; };
} }
type CreateSeasonErrorCode = ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'>; type CreateSeasonErrorCode = ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'> & {
details?: { message: string };
};
describe('CreateSeasonForLeagueUseCase', () => { describe('CreateSeasonForLeagueUseCase', () => {
const mockLeagueFindById = vi.fn(); const mockLeagueFindById = vi.fn();
@@ -146,9 +148,9 @@ describe('CreateSeasonForLeagueUseCase', () => {
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1); expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult; const presented = (output.present as Mock).mock.calls[0]?.[0] as CreateSeasonForLeagueResult;
expect(presented.season).toBeInstanceOf(Season); expect(presented?.season).toBeInstanceOf(Season);
expect(presented.league.id).toBe('league-1'); expect(presented?.league.id).toBe('league-1');
}); });
it('clones configuration from a source season when sourceSeasonId is provided', async () => { it('clones configuration from a source season when sourceSeasonId is provided', async () => {
@@ -179,8 +181,8 @@ describe('CreateSeasonForLeagueUseCase', () => {
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1); expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult; const presented = (output.present as Mock).mock.calls[0]?.[0] as CreateSeasonForLeagueResult;
expect(presented.season.maxDrivers).toBe(40); expect(presented?.season.maxDrivers).toBe(40);
}); });
it('returns error when league not found and does not call output', async () => { it('returns error when league not found and does not call output', async () => {

View File

@@ -1,8 +1,8 @@
import { Season } from '../../domain/entities/Season'; import { Season } from '../../domain/entities/season/Season';
import { League } from '../../domain/entities/League'; import { League } from '../../domain/entities/League';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO'; import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule'; import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig'; import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig';
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy'; import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
@@ -93,7 +93,7 @@ export class CreateSeasonForLeagueUseCase {
const season = Season.create({ const season = Season.create({
id: seasonId, id: seasonId,
leagueId: league.id, leagueId: league.id.toString(),
gameId: input.gameId, gameId: input.gameId,
name: input.name, name: input.name,
year: new Date().getFullYear(), year: new Date().getFullYear(),
@@ -129,28 +129,28 @@ export class CreateSeasonForLeagueUseCase {
} { } {
const schedule = this.buildScheduleFromTimings(config); const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({ const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom', scoringPresetId: config.scoring?.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false, customScoringEnabled: config.scoring?.customScoringEnabled ?? false,
}); });
const dropPolicy = new SeasonDropPolicy({ const dropPolicy = new SeasonDropPolicy({
strategy: config.dropPolicy.strategy, strategy: (config.dropPolicy?.strategy as any) ?? 'none',
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}), ...(config.dropPolicy?.n !== undefined ? { n: config.dropPolicy.n } : {}),
}); });
const stewardingConfig = new SeasonStewardingConfig({ const stewardingConfig = new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode, decisionMode: (config.stewarding?.decisionMode as any) ?? 'auto',
...(config.stewarding.requiredVotes !== undefined ...(config.stewarding?.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes } ? { requiredVotes: config.stewarding.requiredVotes }
: {}), : {}),
requireDefense: config.stewarding.requireDefense, requireDefense: config.stewarding?.requireDefense ?? false,
defenseTimeLimit: config.stewarding.defenseTimeLimit, defenseTimeLimit: config.stewarding?.defenseTimeLimit ?? 0,
voteTimeLimit: config.stewarding.voteTimeLimit, voteTimeLimit: config.stewarding?.voteTimeLimit ?? 0,
protestDeadlineHours: config.stewarding.protestDeadlineHours, protestDeadlineHours: config.stewarding?.protestDeadlineHours ?? 0,
stewardingClosesHours: config.stewarding.stewardingClosesHours, stewardingClosesHours: config.stewarding?.stewardingClosesHours ?? 0,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest, notifyAccusedOnProtest: config.stewarding?.notifyAccusedOnProtest ?? false,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired, notifyOnVoteRequired: config.stewarding?.notifyOnVoteRequired ?? false,
}); });
const structure = config.structure; const structure = config.structure ?? {};
const maxDrivers = const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0 typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers ? structure.maxDrivers
@@ -169,14 +169,14 @@ export class CreateSeasonForLeagueUseCase {
config: LeagueConfigFormModel, config: LeagueConfigFormModel,
): SeasonSchedule | undefined { ): SeasonSchedule | undefined {
const { timings } = config; const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) { if (!timings || !timings.seasonStartDate || !timings.raceStartTime) {
return undefined; return undefined;
} }
const startDate = new Date(timings.seasonStartDate); const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime); const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC'; const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId); const timezone = LeagueTimezone.create(timezoneId);
const plannedRounds = const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0 typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
@@ -197,10 +197,10 @@ export class CreateSeasonForLeagueUseCase {
weekdays, weekdays,
); );
case 'monthlyNthWeekday': { case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({ const pattern = MonthlyRecurrencePattern.create(
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4, (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday, (timings.monthlyWeekday ?? 'Mon') as Weekday,
}); );
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern); return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
} }
case 'weekly': case 'weekly':
@@ -214,7 +214,7 @@ export class CreateSeasonForLeagueUseCase {
timeOfDay, timeOfDay,
timezone, timezone,
recurrence, recurrence,
plannedRounds, plannedRounds: plannedRounds ?? 0,
}); });
} }
} }

View File

@@ -54,13 +54,13 @@ describe('CreateSponsorUseCase', () => {
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1); expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0]; const presented = (output.present as Mock).mock.calls[0]?.[0];
expect(presented.sponsor.id).toBeDefined(); expect(presented?.sponsor.id).toBeDefined();
expect(presented.sponsor.name).toBe('Test Sponsor'); expect(presented?.sponsor.name).toBe('Test Sponsor');
expect(presented.sponsor.contactEmail).toBe('test@example.com'); expect(presented?.sponsor.contactEmail).toBe('test@example.com');
expect(presented.sponsor.websiteUrl).toBe('https://example.com'); expect(presented?.sponsor.websiteUrl).toBe('https://example.com');
expect(presented.sponsor.logoUrl).toBe('https://example.com/logo.png'); expect(presented?.sponsor.logoUrl).toBe('https://example.com/logo.png');
expect(presented.sponsor.createdAt).toBeInstanceOf(Date); expect(presented?.sponsor.createdAt).toBeInstanceOf(Date);
expect(sponsorRepository.create).toHaveBeenCalledTimes(1); expect(sponsorRepository.create).toHaveBeenCalledTimes(1);
}); });
@@ -77,7 +77,7 @@ describe('CreateSponsorUseCase', () => {
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1); expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0]; const presented = (output.present as Mock).mock.calls[0]?.[0];
expect(presented.sponsor.websiteUrl).toBeUndefined(); expect(presented.sponsor.websiteUrl).toBeUndefined();
expect(presented.sponsor.logoUrl).toBeUndefined(); expect(presented.sponsor.logoUrl).toBeUndefined();
}); });

View File

@@ -61,27 +61,27 @@ export class CreateSponsorUseCase {
} }
} }
private validate(command: CreateSponsorCommand): Result<void, ApplicationErrorCode<'VALIDATION_ERROR', { message: string }>> { private validate(input: CreateSponsorInput): Result<void, ApplicationErrorCode<'VALIDATION_ERROR', { message: string }>> {
this.logger.debug('Validating CreateSponsorCommand', { command }); this.logger.debug('Validating CreateSponsorInput', { input });
if (!command.name || command.name.trim().length === 0) { if (!input.name || input.name.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor name is required', { command }); this.logger.warn('Validation failed: Sponsor name is required', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor name is required' } }); return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor name is required' } });
} }
if (!command.contactEmail || command.contactEmail.trim().length === 0) { if (!input.contactEmail || input.contactEmail.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor contact email is required', { command }); this.logger.warn('Validation failed: Sponsor contact email is required', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor contact email is required' } }); return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor contact email is required' } });
} }
// Basic email validation // Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(command.contactEmail)) { if (!emailRegex.test(input.contactEmail)) {
this.logger.warn('Validation failed: Invalid sponsor contact email format', { command }); this.logger.warn('Validation failed: Invalid sponsor contact email format', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor contact email format' } }); return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor contact email format' } });
} }
if (command.websiteUrl && command.websiteUrl.trim().length > 0) { if (input.websiteUrl && input.websiteUrl.trim().length > 0) {
try { try {
new URL(command.websiteUrl); new URL(input.websiteUrl);
} catch { } catch {
this.logger.warn('Validation failed: Invalid sponsor website URL', { command }); this.logger.warn('Validation failed: Invalid sponsor website URL', { input });
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor website URL' } }); return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor website URL' } });
} }
} }

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
DashboardOverviewUseCase, DashboardOverviewUseCase,
@@ -10,12 +10,11 @@ import { Race } from '@core/racing/domain/entities/Race';
import { League } from '@core/racing/domain/entities/League'; import { League } from '@core/racing/domain/entities/League';
import { Standing } from '@core/racing/domain/entities/Standing'; import { Standing } from '@core/racing/domain/entities/Standing';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { Result as RaceResult } from '@core/racing/domain/entities/Result'; import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
import type { FeedItem } from '@core/social/domain/types/FeedItem'; import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { Result as UseCaseResult } from '@core/shared/application/Result'; import { Result as UseCaseResult } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('DashboardOverviewUseCase', () => { describe('DashboardOverviewUseCase', () => {
it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => { it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => {

View File

@@ -9,7 +9,6 @@ import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepo
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { League } from '../../domain/entities/League'; import { League } from '../../domain/entities/League';
import { Race } from '../../domain/entities/Race'; import { Race } from '../../domain/entities/Race';
import { Result as RaceResult } from '../../domain/entities/Result'; import { Result as RaceResult } from '../../domain/entities/Result';

View File

@@ -49,12 +49,12 @@ export class GetAllTeamsUseCase {
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id); const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return { return {
id: team.id, id: team.id,
name: team.name, name: team.name.props,
tag: team.tag, tag: team.tag.props,
description: team.description, description: team.description.props,
ownerId: team.ownerId, ownerId: team.ownerId.toString(),
leagues: [...team.leagues], leagues: team.leagues.map(l => l.toString()),
createdAt: team.createdAt, createdAt: team.createdAt.toDate(),
memberCount, memberCount,
}; };
}), }),

View File

@@ -13,8 +13,9 @@ import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
export type SponsorshipEntityType = 'season' | 'league' | 'team'; export type SponsorshipEntityType = SponsorableEntityType;
export type GetEntitySponsorshipPricingInput = { export type GetEntitySponsorshipPricingInput = {
entityType: SponsorshipEntityType; entityType: SponsorshipEntityType;
@@ -23,11 +24,10 @@ export type GetEntitySponsorshipPricingInput = {
export type SponsorshipPricingTier = { export type SponsorshipPricingTier = {
name: string; name: string;
price: SponsorshipPricing['mainSlot'] extends SponsorshipSlotConfig price: {
? SponsorshipSlotConfig['price'] amount: number;
: SponsorshipPricing['secondarySlots'] extends SponsorshipSlotConfig currency: string;
? SponsorshipSlotConfig['price'] };
: never;
benefits: string[]; benefits: string[];
}; };

View File

@@ -14,8 +14,8 @@ export type DriverSeasonStats = {
driverId: string; driverId: string;
position: number; position: number;
driverName: string; driverName: string;
teamId?: string; teamId: string | undefined;
teamName?: string; teamName: string | undefined;
totalPoints: number; totalPoints: number;
basePoints: number; basePoints: number;
penaltyPoints: number; penaltyPoints: number;
@@ -102,8 +102,8 @@ export class GetLeagueDriverSeasonStatsUseCase {
const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>(); const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>();
for (const standing of standings) { for (const standing of standings) {
const driverId = String(standing.driverId); const driverId = String(standing.driverId);
const ratingInfo = this.driverRatingPort.getRating(driverId); const rating = await this.driverRatingPort.getDriverRating(driverId);
driverRatings.set(driverId, ratingInfo); driverRatings.set(driverId, { rating, ratingChange: null });
} }
const driverResults = new Map<string, Array<{ position: number }>>(); const driverResults = new Map<string, Array<{ position: number }>>();

View File

@@ -2,7 +2,7 @@ import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { League } from '../../domain/entities/League'; import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season'; import type { Season } from '../../domain/entities/season/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';

View File

@@ -7,7 +7,7 @@ import {
} from './GetSeasonDetailsUseCase'; } from './GetSeasonDetailsUseCase';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Season } from '../../domain/entities/Season'; import { Season } from '../../domain/entities/season/Season';
import type { UseCaseOutputPort } from '@core/shared/application'; import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';

View File

@@ -3,7 +3,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application'; import type { UseCaseOutputPort } from '@core/shared/application';
import type { Season } from '../../domain/entities/Season'; import type { Season } from '../../domain/entities/season/Season';
export type GetSeasonDetailsInput = { export type GetSeasonDetailsInput = {
leagueId: string; leagueId: string;

View File

@@ -12,8 +12,8 @@ import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { Sponsor } from '../../domain/entities/Sponsor'; import { Sponsor } from '../../domain/entities/Sponsor';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship'; import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship';
import { Season } from '../../domain/entities/Season'; import { Season } from '../../domain/entities/season/Season';
import { League } from '../../domain/entities/League'; import { League } from '../../domain/entities/League';
import { Money } from '../../domain/value-objects/Money'; import { Money } from '../../domain/value-objects/Money';
import type { UseCaseOutputPort } from '@core/shared/application'; import type { UseCaseOutputPort } from '@core/shared/application';

View File

@@ -142,8 +142,8 @@ export class GetSponsorDashboardUseCase {
); );
sponsoredLeagues.push({ sponsoredLeagues.push({
leagueId: league.id, leagueId: league.id.toString(),
leagueName: league.name, leagueName: league.name.toString(),
tier: sponsorship.tier, tier: sponsorship.tier,
metrics: { metrics: {
drivers: driverCount, drivers: driverCount,

View File

@@ -3,7 +3,7 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { Result as RaceResult } from '../../domain/entities/Result'; import { Result as RaceResult } from '../../domain/entities/result/Result';
import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';

View File

@@ -56,7 +56,7 @@ export class RegisterForRaceUseCase {
const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId); const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId);
if (alreadyRegistered) { if (alreadyRegistered) {
this.logger.warn(`RegisterForRaceUseCase: driver ${driverId} already registered for race ${raceId}`); this.logger.warn(`RegisterForRaceUseCase: driver ${driverId} already registered for race ${raceId}`);
return Result.err({ return Result.err<void, ApplicationErrorCode<RegisterForRaceErrorCode, { message: string }>>({
code: 'ALREADY_REGISTERED', code: 'ALREADY_REGISTERED',
details: { message: 'Already registered for this race' }, details: { message: 'Already registered for this race' },
}); });
@@ -65,7 +65,7 @@ export class RegisterForRaceUseCase {
const membership = await this.membershipRepository.getMembership(leagueId, driverId); const membership = await this.membershipRepository.getMembership(leagueId, driverId);
if (!membership || membership.status !== 'active') { if (!membership || membership.status !== 'active') {
this.logger.error(`RegisterForRaceUseCase: driver ${driverId} not an active member of league ${leagueId}`); this.logger.error(`RegisterForRaceUseCase: driver ${driverId} not an active member of league ${leagueId}`);
return Result.err({ return Result.err<void, ApplicationErrorCode<RegisterForRaceErrorCode, { message: string }>>({
code: 'NOT_ACTIVE_MEMBER', code: 'NOT_ACTIVE_MEMBER',
details: { message: 'Must be an active league member to register for races' }, details: { message: 'Must be an active league member to register for races' },
}); });
@@ -94,14 +94,13 @@ export class RegisterForRaceUseCase {
? error.message ? error.message
: 'Failed to register for race'; : 'Failed to register for race';
this.logger.error('RegisterForRaceUseCase: unexpected error during registration', { this.logger.error('RegisterForRaceUseCase: unexpected error during registration', error instanceof Error ? error : undefined, {
raceId, raceId,
leagueId, leagueId,
driverId, driverId,
error,
}); });
return Result.err({ return Result.err<void, ApplicationErrorCode<RegisterForRaceErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { message }, details: { message },
}); });

View File

@@ -1,4 +1,4 @@
import { Season } from '../../domain/entities/Season'; import { Season } from '../../domain/entities/season/Season';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO'; import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';

View File

@@ -39,12 +39,13 @@ export class JoinRequest implements IEntity<string> {
const id = props.id && props.id.trim().length > 0 ? props.id : `${props.leagueId}:${props.driverId}`; const id = props.id && props.id.trim().length > 0 ? props.id : `${props.leagueId}:${props.driverId}`;
const requestedAt = props.requestedAt ?? new Date(); const requestedAt = props.requestedAt ?? new Date();
const message = props.message;
return new JoinRequest({ return new JoinRequest({
id, id,
leagueId: props.leagueId, leagueId: props.leagueId,
driverId: props.driverId, driverId: props.driverId,
requestedAt, requestedAt,
message: props.message, ...(message !== undefined && { message }),
}); });
} }

View File

@@ -11,6 +11,7 @@ import { MembershipRole, MembershipRoleValue } from './MembershipRole';
import { MembershipStatus, MembershipStatusValue } from './MembershipStatus'; import { MembershipStatus, MembershipStatusValue } from './MembershipStatus';
import { JoinedAt } from '../value-objects/JoinedAt'; import { JoinedAt } from '../value-objects/JoinedAt';
import { DriverId } from './DriverId'; import { DriverId } from './DriverId';
import { JoinRequest } from './JoinRequest';
export interface LeagueMembershipProps { export interface LeagueMembershipProps {
id?: string; id?: string;
@@ -82,4 +83,6 @@ export class LeagueMembership implements IEntity<string> {
throw new RacingDomainValidationError('Membership role is required'); throw new RacingDomainValidationError('Membership role is required');
} }
} }
} }
export { MembershipRole, MembershipStatus, JoinRequest };

View File

@@ -94,23 +94,26 @@ export class Protest implements IEntity<string> {
const defenseRequestedAt = props.defenseRequestedAt ? DefenseRequestedAt.create(props.defenseRequestedAt) : undefined; const defenseRequestedAt = props.defenseRequestedAt ? DefenseRequestedAt.create(props.defenseRequestedAt) : undefined;
const defenseRequestedBy = props.defenseRequestedBy ? StewardId.create(props.defenseRequestedBy) : undefined; const defenseRequestedBy = props.defenseRequestedBy ? StewardId.create(props.defenseRequestedBy) : undefined;
return new Protest({ const protestProps: ProtestProps = {
id, id,
raceId, raceId,
protestingDriverId, protestingDriverId,
accusedDriverId, accusedDriverId,
incident, incident,
comment,
proofVideoUrl,
status, status,
reviewedBy,
decisionNotes,
filedAt, filedAt,
reviewedAt, };
defense,
defenseRequestedAt, if (comment !== undefined) protestProps.comment = comment;
defenseRequestedBy, if (proofVideoUrl !== undefined) protestProps.proofVideoUrl = proofVideoUrl;
}); if (reviewedBy !== undefined) protestProps.reviewedBy = reviewedBy;
if (decisionNotes !== undefined) protestProps.decisionNotes = decisionNotes;
if (reviewedAt !== undefined) protestProps.reviewedAt = reviewedAt;
if (defense !== undefined) protestProps.defense = defense;
if (defenseRequestedAt !== undefined) protestProps.defenseRequestedAt = defenseRequestedAt;
if (defenseRequestedBy !== undefined) protestProps.defenseRequestedBy = defenseRequestedBy;
return new Protest(protestProps);
} }
get id(): string { return this.props.id.toString(); } get id(): string { return this.props.id.toString(); }

View File

@@ -9,7 +9,7 @@ import { RacingDomainValidationError, RacingDomainInvariantError } from '../erro
import type { IEntity } from '@core/shared/domain'; import type { IEntity } from '@core/shared/domain';
import type { Money } from '../value-objects/Money'; import type { Money } from '../value-objects/Money';
import type { SponsorshipTier } from './SeasonSponsorship'; import type { SponsorshipTier } from './season/SeasonSponsorship';
export type SponsorableEntityType = 'driver' | 'team' | 'race' | 'season'; export type SponsorableEntityType = 'driver' | 'team' | 'race' | 'season';
export type SponsorshipRequestStatus = 'pending' | 'accepted' | 'rejected' | 'withdrawn'; export type SponsorshipRequestStatus = 'pending' | 'accepted' | 'rejected' | 'withdrawn';

View File

@@ -0,0 +1 @@
export * from './Result';

Some files were not shown because too many files have changed in this diff Show More