refactor use cases

This commit is contained in:
2025-12-21 01:20:27 +01:00
parent c12656d671
commit 8ecd638396
39 changed files with 2523 additions and 686 deletions

View File

@@ -1,6 +1,23 @@
import { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository'; import { IUserRepository } from '../../domain/repositories/IUserRepository';
// No direct import of apps/api DTOs in core module import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type GetCurrentSessionInput = {
userId: string;
};
export type GetCurrentSessionResult = {
user: User;
};
export type GetCurrentSessionErrorCode = 'USER_NOT_FOUND' | 'REPOSITORY_ERROR';
export type GetCurrentSessionApplicationError = ApplicationErrorCode<
GetCurrentSessionErrorCode,
{ message: string }
>;
/** /**
* Application Use Case: GetCurrentSessionUseCase * Application Use Case: GetCurrentSessionUseCase
@@ -8,13 +25,45 @@ import { IUserRepository } from '../../domain/repositories/IUserRepository';
* Retrieves the current user session information. * Retrieves the current user session information.
*/ */
export class GetCurrentSessionUseCase { export class GetCurrentSessionUseCase {
constructor(private userRepo: IUserRepository) {} constructor(
private readonly userRepo: IUserRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetCurrentSessionResult>,
) {}
async execute(userId: string): Promise<User | null> { async execute(input: GetCurrentSessionInput): Promise<
const stored = await this.userRepo.findById(userId); Result<void, GetCurrentSessionApplicationError>
if (!stored) { > {
return null; try {
const stored = await this.userRepo.findById(input.userId);
if (!stored) {
return Result.err({
code: 'USER_NOT_FOUND',
details: { message: 'User not found' },
} as GetCurrentSessionApplicationError);
}
const user = User.fromStored(stored);
const result: GetCurrentSessionResult = { user };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute GetCurrentSessionUseCase';
this.logger.error(
'GetCurrentSessionUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as GetCurrentSessionApplicationError);
} }
return User.fromStored(stored);
} }
} }

View File

@@ -1,14 +1,50 @@
import type { AuthSessionDTO } from '../dto/AuthSessionDTO'; import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type GetCurrentUserSessionInput = void;
export type GetCurrentUserSessionResult = AuthSessionDTO | null;
export type GetCurrentUserSessionErrorCode = 'REPOSITORY_ERROR';
export type GetCurrentUserSessionApplicationError = ApplicationErrorCode<
GetCurrentUserSessionErrorCode,
{ message: string }
>;
export class GetCurrentUserSessionUseCase { export class GetCurrentUserSessionUseCase {
private readonly sessionPort: IdentitySessionPort; constructor(
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetCurrentUserSessionResult>,
) {}
constructor(sessionPort: IdentitySessionPort) { async execute(): Promise<Result<void, GetCurrentUserSessionApplicationError>> {
this.sessionPort = sessionPort; try {
} const session = await this.sessionPort.getCurrentSession();
async execute(): Promise<AuthSessionDTO | null> { this.output.present(session);
return this.sessionPort.getCurrentSession();
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute GetCurrentUserSessionUseCase';
this.logger.error(
'GetCurrentUserSessionUseCase.execute failed',
error instanceof Error ? error : undefined,
{},
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as GetCurrentUserSessionApplicationError);
}
} }
} }

View File

@@ -1,14 +1,58 @@
import { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import { IUserRepository } from '../../domain/repositories/IUserRepository'; import { IUserRepository } from '../../domain/repositories/IUserRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type GetUserInput = {
userId: string;
};
export type GetUserResult = {
user: User;
};
export type GetUserErrorCode = 'USER_NOT_FOUND' | 'REPOSITORY_ERROR';
export type GetUserApplicationError = ApplicationErrorCode<
GetUserErrorCode,
{ message: string }
>;
export class GetUserUseCase { export class GetUserUseCase {
constructor(private userRepo: IUserRepository) {} constructor(
private readonly userRepo: IUserRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetUserResult>,
) {}
async execute(userId: string): Promise<User> { async execute(input: GetUserInput): Promise<Result<void, GetUserApplicationError>> {
const stored = await this.userRepo.findById(userId); try {
if (!stored) { const stored = await this.userRepo.findById(input.userId);
throw new Error('User not found'); if (!stored) {
return Result.err({
code: 'USER_NOT_FOUND',
details: { message: 'User not found' },
} as GetUserApplicationError);
}
const user = User.fromStored(stored);
const result: GetUserResult = { user };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : 'Failed to get user';
this.logger.error('GetUserUseCase.execute failed', error instanceof Error ? error : undefined, {
input,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as GetUserApplicationError);
} }
return User.fromStored(stored);
} }
} }

View File

@@ -3,19 +3,55 @@ import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type HandleAuthCallbackInput = AuthCallbackCommandDTO;
export type HandleAuthCallbackResult = AuthSessionDTO;
export type HandleAuthCallbackErrorCode = 'REPOSITORY_ERROR';
export type HandleAuthCallbackApplicationError = ApplicationErrorCode<
HandleAuthCallbackErrorCode,
{ message: string }
>;
export class HandleAuthCallbackUseCase { export class HandleAuthCallbackUseCase {
private readonly provider: IdentityProviderPort; constructor(
private readonly sessionPort: IdentitySessionPort; private readonly provider: IdentityProviderPort,
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<HandleAuthCallbackResult>,
) {}
constructor(provider: IdentityProviderPort, sessionPort: IdentitySessionPort) { async execute(input: HandleAuthCallbackInput): Promise<
this.provider = provider; Result<void, HandleAuthCallbackApplicationError>
this.sessionPort = sessionPort; > {
} try {
const user: AuthenticatedUserDTO = await this.provider.completeAuth(input);
const session = await this.sessionPort.createSession(user);
async execute(command: AuthCallbackCommandDTO): Promise<AuthSessionDTO> { this.output.present(session);
const user: AuthenticatedUserDTO = await this.provider.completeAuth(command);
const session = await this.sessionPort.createSession(user); return Result.ok(undefined);
return session; } catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute HandleAuthCallbackUseCase';
this.logger.error(
'HandleAuthCallbackUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as HandleAuthCallbackApplicationError);
}
} }
} }

View File

@@ -1,9 +1,17 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { LoginUseCase } from './LoginUseCase'; import {
LoginUseCase,
type LoginInput,
type LoginResult,
type LoginErrorCode,
} from './LoginUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress'; import { EmailAddress } from '../../domain/value-objects/EmailAddress';
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 { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
describe('LoginUseCase', () => { describe('LoginUseCase', () => {
let authRepo: { let authRepo: {
@@ -12,6 +20,8 @@ describe('LoginUseCase', () => {
let passwordService: { let passwordService: {
verify: Mock; verify: Mock;
}; };
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LoginResult> & { present: Mock };
let useCase: LoginUseCase; let useCase: LoginUseCase;
beforeEach(() => { beforeEach(() => {
@@ -21,16 +31,29 @@ describe('LoginUseCase', () => {
passwordService = { passwordService = {
verify: vi.fn(), verify: vi.fn(),
}; };
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<LoginResult> & { present: Mock };
useCase = new LoginUseCase( useCase = new LoginUseCase(
authRepo as unknown as IAuthRepository, authRepo as unknown as IAuthRepository,
passwordService as unknown as IPasswordHashingService, passwordService as unknown as IPasswordHashingService,
logger,
output,
); );
}); });
it('returns the user when credentials are valid', async () => { it('returns ok and presents user when credentials are valid', async () => {
const email = 'test@example.com'; const input: LoginInput = {
const password = 'password123'; email: 'test@example.com',
const emailVO = EmailAddress.create(email); password: 'password123',
};
const emailVO = EmailAddress.create(input.email);
const user = User.create({ const user = User.create({
id: { value: 'user-1' } as any, id: { value: 'user-1' } as any,
@@ -43,25 +66,45 @@ describe('LoginUseCase', () => {
authRepo.findByEmail.mockResolvedValue(user); authRepo.findByEmail.mockResolvedValue(user);
passwordService.verify.mockResolvedValue(true); passwordService.verify.mockResolvedValue(true);
const result = await useCase.execute(email, password); const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(authRepo.findByEmail).toHaveBeenCalledWith(emailVO); expect(authRepo.findByEmail).toHaveBeenCalledWith(emailVO);
expect(passwordService.verify).toHaveBeenCalledWith(password, 'stored-hash'); expect(passwordService.verify).toHaveBeenCalledWith(input.password, 'stored-hash');
expect(result).toBe(user);
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as LoginResult;
expect(presented.user).toBe(user);
}); });
it('throws when user is not found', async () => { it('returns INVALID_CREDENTIALS when user is not found', async () => {
const email = 'missing@example.com'; const input: LoginInput = {
email: 'missing@example.com',
password: 'password123',
};
authRepo.findByEmail.mockResolvedValue(null); authRepo.findByEmail.mockResolvedValue(null);
await expect(useCase.execute(email, 'password')).rejects.toThrow('Invalid credentials'); const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details?.message).toBe('Invalid credentials');
expect(output.present).not.toHaveBeenCalled();
}); });
it('throws when password is invalid', async () => { it('returns INVALID_CREDENTIALS when password is invalid', async () => {
const email = 'test@example.com'; const input: LoginInput = {
const password = 'wrong-password'; email: 'test@example.com',
const emailVO = EmailAddress.create(email); password: 'wrong-password',
};
const emailVO = EmailAddress.create(input.email);
const user = User.create({ const user = User.create({
id: { value: 'user-1' } as any, id: { value: 'user-1' } as any,
@@ -74,8 +117,34 @@ describe('LoginUseCase', () => {
authRepo.findByEmail.mockResolvedValue(user); authRepo.findByEmail.mockResolvedValue(user);
passwordService.verify.mockResolvedValue(false); passwordService.verify.mockResolvedValue(false);
await expect(useCase.execute(email, password)).rejects.toThrow('Invalid credentials'); const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
expect(authRepo.findByEmail).toHaveBeenCalled(); await useCase.execute(input);
expect(passwordService.verify).toHaveBeenCalled();
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details?.message).toBe('Invalid credentials');
expect(output.present).not.toHaveBeenCalled();
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const input: LoginInput = {
email: 'test@example.com',
password: 'password123',
};
authRepo.findByEmail.mockRejectedValue(new Error('DB failure'));
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('DB failure');
expect(output.present).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
}); });
}); });

View File

@@ -2,6 +2,22 @@ import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import { IAuthRepository } from '../../domain/repositories/IAuthRepository'; import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type LoginInput = {
email: string;
password: string;
};
export type LoginResult = {
user: User;
};
export type LoginErrorCode = 'INVALID_CREDENTIALS' | 'REPOSITORY_ERROR';
export type LoginApplicationError = ApplicationErrorCode<LoginErrorCode, { message: string }>;
/** /**
* Application Use Case: LoginUseCase * Application Use Case: LoginUseCase
@@ -10,20 +26,52 @@ import { IPasswordHashingService } from '../../domain/services/PasswordHashingSe
*/ */
export class LoginUseCase { export class LoginUseCase {
constructor( constructor(
private authRepo: IAuthRepository, private readonly authRepo: IAuthRepository,
private passwordService: IPasswordHashingService private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LoginResult>,
) {} ) {}
async execute(email: string, password: string): Promise<User> { async execute(input: LoginInput): Promise<Result<void, LoginApplicationError>> {
const emailVO = EmailAddress.create(email); try {
const user = await this.authRepo.findByEmail(emailVO); const emailVO = EmailAddress.create(input.email);
if (!user || !user.getPasswordHash()) { const user = await this.authRepo.findByEmail(emailVO);
throw new Error('Invalid credentials');
if (!user || !user.getPasswordHash()) {
return Result.err({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid credentials' },
} as LoginApplicationError);
}
const passwordHash = user.getPasswordHash()!;
const isValid = await this.passwordService.verify(input.password, passwordHash.value);
if (!isValid) {
return Result.err({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid credentials' },
} as LoginApplicationError);
}
const result: LoginResult = { user };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute LoginUseCase';
this.logger.error('LoginUseCase.execute failed', error instanceof Error ? error : undefined, {
input,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as LoginApplicationError);
} }
const isValid = await this.passwordService.verify(password, user.getPasswordHash()!.value);
if (!isValid) {
throw new Error('Invalid credentials');
}
return user;
} }
} }

View File

@@ -1,8 +1,15 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { LoginWithEmailUseCase, type LoginCommandDTO } from './LoginWithEmailUseCase'; import {
LoginWithEmailUseCase,
type LoginWithEmailInput,
type LoginWithEmailResult,
type LoginWithEmailErrorCode,
} from './LoginWithEmailUseCase';
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 { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
describe('LoginWithEmailUseCase', () => { describe('LoginWithEmailUseCase', () => {
let userRepository: { let userRepository: {
@@ -13,6 +20,8 @@ describe('LoginWithEmailUseCase', () => {
getCurrentSession: Mock; getCurrentSession: Mock;
clearSession: Mock; clearSession: Mock;
}; };
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LoginWithEmailResult> & { present: Mock };
let useCase: LoginWithEmailUseCase; let useCase: LoginWithEmailUseCase;
beforeEach(() => { beforeEach(() => {
@@ -24,14 +33,26 @@ describe('LoginWithEmailUseCase', () => {
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 & { error: Mock };
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<LoginWithEmailResult> & { present: Mock };
useCase = new LoginWithEmailUseCase( useCase = new LoginWithEmailUseCase(
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 session for valid credentials', async () => { it('returns ok and presents session result for valid credentials', async () => {
const command: LoginCommandDTO = { const input: LoginWithEmailInput = {
email: 'Test@Example.com', email: 'Test@Example.com',
password: 'password123', password: 'password123',
}; };
@@ -45,7 +66,7 @@ describe('LoginWithEmailUseCase', () => {
createdAt: new Date(), createdAt: new Date(),
}; };
const session: AuthSessionDTO = { const session = {
user: { user: {
id: storedUser.id, id: storedUser.id,
email: storedUser.email, email: storedUser.email,
@@ -59,35 +80,59 @@ describe('LoginWithEmailUseCase', () => {
userRepository.findByEmail.mockResolvedValue(storedUser); userRepository.findByEmail.mockResolvedValue(storedUser);
sessionPort.createSession.mockResolvedValue(session); sessionPort.createSession.mockResolvedValue(session);
const result = await useCase.execute(command); const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com'); expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
expect(sessionPort.createSession).toHaveBeenCalledWith({ expect(sessionPort.createSession).toHaveBeenCalledWith({
id: storedUser.id, id: storedUser.id,
email: storedUser.email, email: storedUser.email,
displayName: storedUser.displayName, displayName: storedUser.displayName,
primaryDriverId: undefined,
}); });
expect(result).toEqual(session);
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as LoginWithEmailResult;
expect(presented.sessionToken).toBe('token-123');
expect(presented.userId).toBe(storedUser.id);
expect(presented.displayName).toBe(storedUser.displayName);
expect(presented.email).toBe(storedUser.email);
}); });
it('throws when email or password is missing', async () => { it('returns INVALID_INPUT when email or password is missing', async () => {
await expect(useCase.execute({ email: '', password: 'x' })).rejects.toThrow('Email and password are required'); const result1 = await useCase.execute({ email: '', password: 'x' });
await expect(useCase.execute({ email: 'a@example.com', password: '' })).rejects.toThrow('Email and password are required'); const result2 = await useCase.execute({ email: 'a@example.com', password: '' });
expect(result1.isErr()).toBe(true);
expect(result1.unwrapErr().code).toBe('INVALID_INPUT');
expect(result2.isErr()).toBe(true);
expect(result2.unwrapErr().code).toBe('INVALID_INPUT');
expect(output.present).not.toHaveBeenCalled();
}); });
it('throws when user does not exist', async () => { it('returns INVALID_CREDENTIALS when user does not exist', async () => {
const command: LoginCommandDTO = { const input: LoginWithEmailInput = {
email: 'missing@example.com', email: 'missing@example.com',
password: 'password', password: 'password',
}; };
userRepository.findByEmail.mockResolvedValue(null); userRepository.findByEmail.mockResolvedValue(null);
await expect(useCase.execute(command)).rejects.toThrow('Invalid email or password'); const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details.message).toBe('Invalid email or password');
expect(output.present).not.toHaveBeenCalled();
}); });
it('throws when password is invalid', async () => { it('returns INVALID_CREDENTIALS when password is invalid', async () => {
const command: LoginCommandDTO = { const input: LoginWithEmailInput = {
email: 'test@example.com', email: 'test@example.com',
password: 'wrong', password: 'wrong',
}; };
@@ -103,6 +148,33 @@ describe('LoginWithEmailUseCase', () => {
userRepository.findByEmail.mockResolvedValue(storedUser); userRepository.findByEmail.mockResolvedValue(storedUser);
await expect(useCase.execute(command)).rejects.toThrow('Invalid email or password'); const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_CREDENTIALS');
expect(error.details.message).toBe('Invalid email or password');
expect(output.present).not.toHaveBeenCalled();
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const input: LoginWithEmailInput = {
email: 'test@example.com',
password: 'password123',
};
userRepository.findByEmail.mockRejectedValue(new Error('DB failure'));
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('DB failure');
expect(output.present).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,56 +1,112 @@
/** /**
* Login with Email Use Case * Login with Email Use Case
* *
* Authenticates a user with email and password. * Authenticates a user with email and password.
*/ */
import type { IUserRepository } from '../../domain/repositories/IUserRepository'; import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export interface LoginCommandDTO { export type LoginWithEmailInput = {
email: string; email: string;
password: string; password: string;
} };
export type LoginWithEmailResult = {
sessionToken: string;
userId: string;
displayName: string;
email?: string;
primaryDriverId?: string;
issuedAt: number;
expiresAt: number;
};
export type LoginWithEmailErrorCode =
| 'INVALID_INPUT'
| 'INVALID_CREDENTIALS'
| 'REPOSITORY_ERROR';
export type LoginWithEmailApplicationError = ApplicationErrorCode<
LoginWithEmailErrorCode,
{ message: string }
>;
export class LoginWithEmailUseCase { export class LoginWithEmailUseCase {
constructor( constructor(
private readonly userRepository: IUserRepository, private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort, private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LoginWithEmailResult>,
) {} ) {}
async execute(command: LoginCommandDTO): Promise<AuthSessionDTO> { async execute(input: LoginWithEmailInput): Promise<Result<void, LoginWithEmailApplicationError>> {
// Validate inputs try {
if (!command.email || !command.password) { if (!input.email || !input.password) {
throw new Error('Email and password are required'); return Result.err({
code: 'INVALID_INPUT',
details: { message: 'Email and password are required' },
} as LoginWithEmailApplicationError);
}
const normalizedEmail = input.email.toLowerCase().trim();
const user = await this.userRepository.findByEmail(normalizedEmail);
if (!user) {
return Result.err({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid email or password' },
} as LoginWithEmailApplicationError);
}
const passwordHash = await this.hashPassword(input.password, user.salt);
if (passwordHash !== user.passwordHash) {
return Result.err({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid email or password' },
} as LoginWithEmailApplicationError);
}
const session = await this.sessionPort.createSession({
id: user.id,
displayName: user.displayName,
email: user.email,
primaryDriverId: user.primaryDriverId,
} as any);
const result: LoginWithEmailResult = {
sessionToken: (session as any).token,
userId: (session as any).user.id,
displayName: (session as any).user.displayName,
email: (session as any).user.email,
primaryDriverId: (session as any).user.primaryDriverId,
issuedAt: (session as any).issuedAt,
expiresAt: (session as any).expiresAt,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute LoginWithEmailUseCase';
this.logger.error(
'LoginWithEmailUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as LoginWithEmailApplicationError);
} }
// Find user by email
const user = await this.userRepository.findByEmail(command.email.toLowerCase().trim());
if (!user) {
throw new Error('Invalid email or password');
}
// Verify password
const passwordHash = await this.hashPassword(command.password, user.salt);
if (passwordHash !== user.passwordHash) {
throw new Error('Invalid email or password');
}
// Create session
const authenticatedUserBase: AuthenticatedUserDTO = {
id: user.id,
displayName: user.displayName,
email: user.email,
};
const authenticatedUser: AuthenticatedUserDTO =
user.primaryDriverId !== undefined
? { ...authenticatedUserBase, primaryDriverId: user.primaryDriverId }
: authenticatedUserBase;
return this.sessionPort.createSession(authenticatedUser);
} }
private async hashPassword(password: string, salt: string): Promise<string> { private async hashPassword(password: string, salt: string): Promise<string> {

View File

@@ -1,6 +1,9 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { LogoutUseCase } from './LogoutUseCase'; import { LogoutUseCase, type LogoutResult, type LogoutErrorCode } from './LogoutUseCase';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
describe('LogoutUseCase', () => { describe('LogoutUseCase', () => {
let sessionPort: { let sessionPort: {
@@ -8,6 +11,8 @@ describe('LogoutUseCase', () => {
getCurrentSession: Mock; getCurrentSession: Mock;
createSession: Mock; createSession: Mock;
}; };
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LogoutResult> & { present: Mock };
let useCase: LogoutUseCase; let useCase: LogoutUseCase;
beforeEach(() => { beforeEach(() => {
@@ -17,12 +22,49 @@ describe('LogoutUseCase', () => {
createSession: vi.fn(), createSession: vi.fn(),
}; };
useCase = new LogoutUseCase(sessionPort as unknown as IdentitySessionPort); logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger & { error: Mock };
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<LogoutResult> & { present: Mock };
useCase = new LogoutUseCase(
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
}); });
it('clears the current session', async () => { it('clears the current session and presents success', async () => {
await useCase.execute(); const result: Result<void, ApplicationErrorCode<LogoutErrorCode, { message: string }>> =
await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(sessionPort.clearSession).toHaveBeenCalledTimes(1); expect(sessionPort.clearSession).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ success: true });
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const error = new Error('Session clear failed');
sessionPort.clearSession.mockRejectedValue(error);
const result: Result<void, ApplicationErrorCode<LogoutErrorCode, { message: string }>> =
await useCase.execute();
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Session clear failed');
expect(output.present).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,13 +1,53 @@
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type LogoutInput = {};
export type LogoutResult = {
success: boolean;
};
export type LogoutErrorCode = 'REPOSITORY_ERROR';
export type LogoutApplicationError = ApplicationErrorCode<LogoutErrorCode, { message: string }>;
export class LogoutUseCase { export class LogoutUseCase {
private readonly sessionPort: IdentitySessionPort; private readonly sessionPort: IdentitySessionPort;
constructor(sessionPort: IdentitySessionPort) { constructor(
sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LogoutResult>,
) {
this.sessionPort = sessionPort; this.sessionPort = sessionPort;
} }
async execute(): Promise<void> { async execute(): Promise<Result<void, LogoutApplicationError>> {
await this.sessionPort.clearSession(); try {
await this.sessionPort.clearSession();
const result: LogoutResult = { success: true };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute LogoutUseCase';
this.logger.error(
'LogoutUseCase.execute failed',
error instanceof Error ? error : undefined,
{},
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as LogoutApplicationError);
}
} }
} }

View File

@@ -3,6 +3,23 @@ import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User'; import { User } from '../../domain/entities/User';
import { IAuthRepository } from '../../domain/repositories/IAuthRepository'; import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService'; import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type SignupInput = {
email: string;
password: string;
displayName: string;
};
export type SignupResult = {
user: User;
};
export type SignupErrorCode = 'USER_ALREADY_EXISTS' | 'REPOSITORY_ERROR';
export type SignupApplicationError = ApplicationErrorCode<SignupErrorCode, { message: string }>;
/** /**
* Application Use Case: SignupUseCase * Application Use Case: SignupUseCase
@@ -11,31 +28,56 @@ import { IPasswordHashingService } from '../../domain/services/PasswordHashingSe
*/ */
export class SignupUseCase { export class SignupUseCase {
constructor( constructor(
private authRepo: IAuthRepository, private readonly authRepo: IAuthRepository,
private passwordService: IPasswordHashingService private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<SignupResult>,
) {} ) {}
async execute(email: string, password: string, displayName: string): Promise<User> { async execute(input: SignupInput): Promise<Result<void, SignupApplicationError>> {
const emailVO = EmailAddress.create(email); try {
const emailVO = EmailAddress.create(input.email);
// Check if user already exists const existingUser = await this.authRepo.findByEmail(emailVO);
const existingUser = await this.authRepo.findByEmail(emailVO); if (existingUser) {
if (existingUser) { return Result.err({
throw new Error('User already exists'); code: 'USER_ALREADY_EXISTS',
details: { message: 'User already exists' },
} as SignupApplicationError);
}
const hashedPassword = await this.passwordService.hash(input.password);
const passwordHashModule = await import('../../domain/value-objects/PasswordHash');
const passwordHash = passwordHashModule.PasswordHash.fromHash(hashedPassword);
const userId = UserId.create();
const user = User.create({
id: userId,
displayName: input.displayName,
email: emailVO.value,
passwordHash,
});
await this.authRepo.save(user);
const result: SignupResult = { user };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute SignupUseCase';
this.logger.error('SignupUseCase.execute failed', error instanceof Error ? error : undefined, {
input,
});
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as SignupApplicationError);
} }
const hashedPassword = await this.passwordService.hash(password);
const passwordHash = await import('../../domain/value-objects/PasswordHash').then(m => m.PasswordHash.fromHash(hashedPassword));
const userId = UserId.create();
const user = User.create({
id: userId,
displayName,
email: emailVO.value,
passwordHash,
});
await this.authRepo.save(user);
return user;
} }
} }

View File

@@ -1,84 +1,145 @@
/** /**
* Signup with Email Use Case * Signup with Email Use Case
* *
* Creates a new user account with email and password. * Creates a new user account with email and password.
*/ */
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository'; import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO'; import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort'; import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export interface SignupCommandDTO { export type SignupWithEmailInput = {
email: string; email: string;
password: string; password: string;
displayName: string; displayName: string;
} };
export interface SignupResultDTO { export type SignupWithEmailResult = {
session: AuthSessionDTO; sessionToken: string;
userId: string;
displayName: string;
email: string;
createdAt: Date;
isNewUser: boolean; isNewUser: boolean;
} };
export type SignupWithEmailErrorCode =
| 'INVALID_EMAIL_FORMAT'
| 'WEAK_PASSWORD'
| 'INVALID_DISPLAY_NAME'
| 'EMAIL_ALREADY_EXISTS'
| 'REPOSITORY_ERROR';
export type SignupWithEmailApplicationError = ApplicationErrorCode<
SignupWithEmailErrorCode,
{ message: string }
>;
export class SignupWithEmailUseCase { export class SignupWithEmailUseCase {
constructor( constructor(
private readonly userRepository: IUserRepository, private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort, private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<SignupWithEmailResult>,
) {} ) {}
async execute(command: SignupCommandDTO): Promise<SignupResultDTO> { async execute(input: SignupWithEmailInput): Promise<
Result<void, SignupWithEmailApplicationError>
> {
// Validate email format // Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(command.email)) { if (!emailRegex.test(input.email)) {
throw new Error('Invalid email format'); return Result.err({
code: 'INVALID_EMAIL_FORMAT',
details: { message: 'Invalid email format' },
} as SignupWithEmailApplicationError);
} }
// Validate password strength // Validate password strength
if (command.password.length < 8) { if (input.password.length < 8) {
throw new Error('Password must be at least 8 characters'); return Result.err({
code: 'WEAK_PASSWORD',
details: { message: 'Password must be at least 8 characters' },
} as SignupWithEmailApplicationError);
} }
// Validate display name // Validate display name
if (!command.displayName || command.displayName.trim().length < 2) { if (!input.displayName || input.displayName.trim().length < 2) {
throw new Error('Display name must be at least 2 characters'); return Result.err({
code: 'INVALID_DISPLAY_NAME',
details: { message: 'Display name must be at least 2 characters' },
} as SignupWithEmailApplicationError);
} }
// Check if email already exists // Check if email already exists
const existingUser = await this.userRepository.findByEmail(command.email); const existingUser = await this.userRepository.findByEmail(input.email);
if (existingUser) { if (existingUser) {
throw new Error('An account with this email already exists'); return Result.err({
code: 'EMAIL_ALREADY_EXISTS',
details: { message: 'An account with this email already exists' },
} as SignupWithEmailApplicationError);
} }
// Hash password (simple hash for demo - in production use bcrypt) try {
const salt = this.generateSalt(); // Hash password (simple hash for demo - in production use bcrypt)
const passwordHash = await this.hashPassword(command.password, salt); const salt = this.generateSalt();
const passwordHash = await this.hashPassword(input.password, salt);
// Create user // Create user
const userId = this.generateUserId(); const userId = this.generateUserId();
const newUser: StoredUser = { const createdAt = new Date();
id: userId, const newUser: StoredUser = {
email: command.email.toLowerCase().trim(), id: userId,
displayName: command.displayName.trim(), email: input.email.toLowerCase().trim(),
passwordHash, displayName: input.displayName.trim(),
salt, passwordHash,
createdAt: new Date(), salt,
}; createdAt,
};
await this.userRepository.create(newUser); await this.userRepository.create(newUser);
// Create session // Create session
const authenticatedUser: AuthenticatedUserDTO = { const authenticatedUser: AuthenticatedUserDTO = {
id: newUser.id, id: newUser.id,
displayName: newUser.displayName, displayName: newUser.displayName,
email: newUser.email, email: newUser.email,
}; };
const session = await this.sessionPort.createSession(authenticatedUser); const session = await this.sessionPort.createSession(authenticatedUser);
return { const result: SignupWithEmailResult = {
session, sessionToken: session.token,
isNewUser: true, userId: session.user.id,
}; displayName: session.user.displayName,
email: session.user.email ?? newUser.email,
createdAt,
isNewUser: true,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute SignupWithEmailUseCase';
this.logger.error(
'SignupWithEmailUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as SignupWithEmailApplicationError);
}
} }
private generateSalt(): string { private generateSalt(): string {

View File

@@ -1,12 +1,21 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { StartAuthUseCase } from './StartAuthUseCase'; import {
StartAuthUseCase,
type StartAuthInput,
type StartAuthResult,
type StartAuthErrorCode,
} from './StartAuthUseCase';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO'; import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
describe('StartAuthUseCase', () => { describe('StartAuthUseCase', () => {
let provider: { let provider: {
startAuth: Mock; startAuth: Mock;
}; };
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<StartAuthResult> & { present: Mock };
let useCase: StartAuthUseCase; let useCase: StartAuthUseCase;
beforeEach(() => { beforeEach(() => {
@@ -14,22 +23,67 @@ describe('StartAuthUseCase', () => {
startAuth: vi.fn(), startAuth: vi.fn(),
}; };
useCase = new StartAuthUseCase(provider as unknown as IdentityProviderPort); logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger & { error: Mock };
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<StartAuthResult> & { present: Mock };
useCase = new StartAuthUseCase(
provider as unknown as IdentityProviderPort,
logger,
output,
);
}); });
it('delegates to the identity provider to start auth', async () => { it('returns ok and presents redirect when provider call succeeds', async () => {
const command: StartAuthCommandDTO = { const input: StartAuthInput = {
redirectUri: 'https://app/callback', provider: 'IRACING_DEMO' as any,
provider: 'demo', returnTo: 'https://app/callback',
}; };
const expected = { redirectUrl: 'https://auth/redirect', state: 'state-123' }; const expected = { redirectUrl: 'https://auth/redirect', state: 'state-123' };
provider.startAuth.mockResolvedValue(expected); provider.startAuth.mockResolvedValue(expected);
const result = await useCase.execute(command); const result: Result<void, ApplicationErrorCode<StartAuthErrorCode, { message: string }>> =
await useCase.execute(input);
expect(provider.startAuth).toHaveBeenCalledWith(command); expect(result.isOk()).toBe(true);
expect(result).toEqual(expected); expect(result.unwrap()).toBeUndefined();
expect(provider.startAuth).toHaveBeenCalledWith({
provider: input.provider,
returnTo: input.returnTo,
});
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as StartAuthResult;
expect(presented).toEqual(expected);
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const input: StartAuthInput = {
provider: 'IRACING_DEMO' as any,
returnTo: 'https://app/callback',
};
provider.startAuth.mockRejectedValue(new Error('Provider failure'));
const result: Result<void, ApplicationErrorCode<StartAuthErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Provider failure');
expect(output.present).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
}); });
}); });

View File

@@ -1,14 +1,63 @@
import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO'; import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort'; import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export type StartAuthInput = {
provider: StartAuthCommandDTO['provider'];
returnTo?: StartAuthCommandDTO['returnTo'];
};
export type StartAuthResult = {
redirectUrl: string;
state: string;
};
export type StartAuthErrorCode = 'REPOSITORY_ERROR';
export type StartAuthApplicationError = ApplicationErrorCode<StartAuthErrorCode, { message: string }>;
export class StartAuthUseCase { export class StartAuthUseCase {
private readonly provider: IdentityProviderPort; constructor(
private readonly provider: IdentityProviderPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<StartAuthResult>,
) {}
constructor(provider: IdentityProviderPort) { async execute(input: StartAuthInput): Promise<Result<void, StartAuthApplicationError>> {
this.provider = provider; try {
} const command: StartAuthCommandDTO = input.returnTo
? {
provider: input.provider,
returnTo: input.returnTo,
}
: {
provider: input.provider,
};
async execute(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }> { const { redirectUrl, state } = await this.provider.startAuth(command);
return this.provider.startAuth(command);
const result: StartAuthResult = { redirectUrl, state };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute StartAuthUseCase';
this.logger.error(
'StartAuthUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as StartAuthApplicationError);
}
} }
} }

View File

@@ -1,16 +1,60 @@
import { Achievement, AchievementProps } from '@core/identity/domain/entities/Achievement'; import { Achievement, AchievementProps } from '@core/identity/domain/entities/Achievement';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
export interface IAchievementRepository { export interface IAchievementRepository {
save(achievement: Achievement): Promise<void>; save(achievement: Achievement): Promise<void>;
findById(id: string): Promise<Achievement | null>; findById(id: string): Promise<Achievement | null>;
} }
export class CreateAchievementUseCase { export type CreateAchievementInput = Omit<AchievementProps, 'createdAt'>;
constructor(private readonly achievementRepository: IAchievementRepository) {}
async execute(props: Omit<AchievementProps, 'createdAt'>): Promise<Achievement> { export type CreateAchievementResult = {
const achievement = Achievement.create(props); achievement: Achievement;
await this.achievementRepository.save(achievement); };
return achievement;
export type CreateAchievementErrorCode = 'REPOSITORY_ERROR';
export type CreateAchievementApplicationError = ApplicationErrorCode<
CreateAchievementErrorCode,
{ message: string }
>;
export class CreateAchievementUseCase {
constructor(
private readonly achievementRepository: IAchievementRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<CreateAchievementResult>,
) {}
async execute(input: CreateAchievementInput): Promise<
Result<void, CreateAchievementApplicationError>
> {
try {
const achievement = Achievement.create(input);
await this.achievementRepository.save(achievement);
const result: CreateAchievementResult = { achievement };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute CreateAchievementUseCase';
this.logger.error(
'CreateAchievementUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as CreateAchievementApplicationError);
}
} }
} }

View File

@@ -1,3 +1,5 @@
// TODO is this even used? either remove or it must be within racing domain
export interface GetLeagueStandingsUseCase { export interface GetLeagueStandingsUseCase {
execute(leagueId: string): Promise<LeagueStandingsViewModel>; execute(leagueId: string): Promise<LeagueStandingsViewModel>;
} }

View File

@@ -1,5 +1,7 @@
import { GetLeagueStandingsUseCase, LeagueStandingsViewModel, StandingItemViewModel } from './GetLeagueStandingsUseCase';
import { ILeagueStandingsRepository, RawStanding } from '../ports/ILeagueStandingsRepository'; import { ILeagueStandingsRepository, RawStanding } from '../ports/ILeagueStandingsRepository';
import { GetLeagueStandingsUseCase, LeagueStandingsViewModel, StandingItemViewModel } from './GetLeagueStandingsUseCase';
// TODO is this even used? either remove or it must be within racing domain
export class GetLeagueStandingsUseCaseImpl implements GetLeagueStandingsUseCase { export class GetLeagueStandingsUseCaseImpl implements GetLeagueStandingsUseCase {
constructor(private repository: ILeagueStandingsRepository) {} constructor(private repository: ILeagueStandingsRepository) {}

View File

@@ -1,12 +1,23 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { DeleteMediaUseCase } from './DeleteMediaUseCase'; import {
DeleteMediaUseCase,
type DeleteMediaInput,
type DeleteMediaResult,
type DeleteMediaErrorCode,
} from './DeleteMediaUseCase';
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort'; import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { IDeleteMediaPresenter } from '../presenters/IDeleteMediaPresenter'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result';
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'; import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestOutputPort extends UseCaseOutputPort<DeleteMediaResult> {
present: Mock;
result?: DeleteMediaResult;
}
describe('DeleteMediaUseCase', () => { describe('DeleteMediaUseCase', () => {
let mediaRepo: { let mediaRepo: {
findById: Mock; findById: Mock;
@@ -16,7 +27,7 @@ describe('DeleteMediaUseCase', () => {
deleteMedia: Mock; deleteMedia: Mock;
}; };
let logger: Logger; let logger: Logger;
let presenter: IDeleteMediaPresenter & { result?: any }; let output: TestOutputPort;
let useCase: DeleteMediaUseCase; let useCase: DeleteMediaUseCase;
beforeEach(() => { beforeEach(() => {
@@ -36,29 +47,35 @@ describe('DeleteMediaUseCase', () => {
error: vi.fn(), error: vi.fn(),
} as unknown as Logger; } as unknown as Logger;
presenter = { output = {
present: vi.fn((result) => { present: vi.fn((result: DeleteMediaResult) => {
(presenter as any).result = result; output.result = result;
}), }),
} as unknown as IDeleteMediaPresenter & { result?: any }; } as unknown as TestOutputPort;
useCase = new DeleteMediaUseCase( useCase = new DeleteMediaUseCase(
mediaRepo as unknown as IMediaRepository, mediaRepo as unknown as IMediaRepository,
mediaStorage as unknown as MediaStoragePort, mediaStorage as unknown as MediaStoragePort,
output,
logger, logger,
); );
}); });
it('returns error result when media is not found', async () => { it('returns MEDIA_NOT_FOUND when media is not found', async () => {
mediaRepo.findById.mockResolvedValue(null); mediaRepo.findById.mockResolvedValue(null);
await useCase.execute({ mediaId: 'missing' }, presenter); const input: DeleteMediaInput = { mediaId: 'missing' };
const result = await useCase.execute(input);
expect(mediaRepo.findById).toHaveBeenCalledWith('missing'); expect(mediaRepo.findById).toHaveBeenCalledWith('missing');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ expect(result).toBeInstanceOf(Result);
success: false, expect(result.isErr()).toBe(true);
errorMessage: 'Media not found', const err = result.unwrapErr() as ApplicationErrorCode<
}); DeleteMediaErrorCode,
{ message: string }
>;
expect(err.code).toBe('MEDIA_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
}); });
it('deletes media from storage and repository on success', async () => { it('deletes media from storage and repository on success', async () => {
@@ -68,30 +85,39 @@ describe('DeleteMediaUseCase', () => {
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',
}); });
mediaRepo.findById.mockResolvedValue(media); mediaRepo.findById.mockResolvedValue(media);
await useCase.execute({ mediaId: 'media-1' }, presenter); const input: DeleteMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1'); expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
expect(mediaStorage.deleteMedia).toHaveBeenCalledWith(media.url.value); expect(mediaStorage.deleteMedia).toHaveBeenCalledWith(media.url.value);
expect(mediaRepo.delete).toHaveBeenCalledWith('media-1'); expect(mediaRepo.delete).toHaveBeenCalledWith('media-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ success: true }); expect(output.present).toHaveBeenCalledWith({
}); mediaId: 'media-1',
deleted: true,
it('handles errors and presents failure result', async () => {
mediaRepo.findById.mockRejectedValue(new Error('DB error'));
await useCase.execute({ mediaId: 'media-1' }, presenter);
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({
success: false,
errorMessage: 'Internal error occurred while deleting media',
}); });
}); });
it('handles repository errors by returning REPOSITORY_ERROR', async () => {
mediaRepo.findById.mockRejectedValue(new Error('DB error'));
const input: DeleteMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
DeleteMediaErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(output.present).not.toHaveBeenCalled();
});
}); });

View File

@@ -6,71 +6,73 @@
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort'; import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IDeleteMediaPresenter } from '../presenters/IDeleteMediaPresenter'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface DeleteMediaInput { export interface DeleteMediaInput {
mediaId: string; mediaId: string;
} }
export interface DeleteMediaResult { export interface DeleteMediaResult {
success: boolean; mediaId: string;
errorMessage?: string; deleted: boolean;
} }
export interface IDeleteMediaPresenter { export type DeleteMediaErrorCode = 'MEDIA_NOT_FOUND' | 'REPOSITORY_ERROR';
present(result: DeleteMediaResult): void;
} export type DeleteMediaApplicationError = ApplicationErrorCode<
DeleteMediaErrorCode,
{ message: string }
>;
export class DeleteMediaUseCase { export class DeleteMediaUseCase {
constructor( constructor(
private readonly mediaRepo: IMediaRepository, private readonly mediaRepo: IMediaRepository,
private readonly mediaStorage: MediaStoragePort, private readonly mediaStorage: MediaStoragePort,
private readonly output: UseCaseOutputPort<DeleteMediaResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute( async execute(input: DeleteMediaInput): Promise<Result<void, DeleteMediaApplicationError>> {
input: DeleteMediaInput, this.logger.info('[DeleteMediaUseCase] Deleting media', {
presenter: IDeleteMediaPresenter, mediaId: input.mediaId,
): Promise<void> { });
try {
this.logger.info('[DeleteMediaUseCase] Deleting media', {
mediaId: input.mediaId,
});
try {
const media = await this.mediaRepo.findById(input.mediaId); const media = await this.mediaRepo.findById(input.mediaId);
if (!media) { if (!media) {
presenter.present({ return Result.err({
success: false, code: 'MEDIA_NOT_FOUND',
errorMessage: 'Media not found', details: { message: 'Media not found' },
}); });
return;
} }
// Delete from storage
await this.mediaStorage.deleteMedia(media.url.value); await this.mediaStorage.deleteMedia(media.url.value);
// Delete from repository
await this.mediaRepo.delete(input.mediaId); await this.mediaRepo.delete(input.mediaId);
presenter.present({ this.output.present({
success: true, mediaId: input.mediaId,
deleted: true,
}); });
this.logger.info('[DeleteMediaUseCase] Media deleted successfully', { this.logger.info('[DeleteMediaUseCase] Media deleted successfully', {
mediaId: input.mediaId, mediaId: input.mediaId,
}); });
return Result.ok(undefined);
} catch (error) { } catch (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', {
error: error instanceof Error ? error.message : 'Unknown error', error: err.message,
mediaId: input.mediaId, mediaId: input.mediaId,
}); });
presenter.present({ return Result.err({
success: false, code: 'REPOSITORY_ERROR',
errorMessage: 'Internal error occurred while deleting media', details: { message: err.message ?? 'Unexpected repository error' },
}); });
} }
} }

View File

@@ -1,13 +1,19 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetAvatarUseCase } from './GetAvatarUseCase'; import {
GetAvatarUseCase,
type GetAvatarInput,
type GetAvatarResult,
type GetAvatarErrorCode,
} from './GetAvatarUseCase';
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository'; import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
import type { IGetAvatarPresenter } from '../presenters/IGetAvatarPresenter'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Avatar } from '../../domain/entities/Avatar'; import { Avatar } from '../../domain/entities/Avatar';
import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestPresenter extends IGetAvatarPresenter { interface TestOutputPort extends UseCaseOutputPort<GetAvatarResult> {
result?: any; present: Mock;
result?: GetAvatarResult;
} }
describe('GetAvatarUseCase', () => { describe('GetAvatarUseCase', () => {
@@ -16,7 +22,7 @@ describe('GetAvatarUseCase', () => {
save: Mock; save: Mock;
}; };
let logger: Logger; let logger: Logger;
let presenter: TestPresenter; let output: TestOutputPort;
let useCase: GetAvatarUseCase; let useCase: GetAvatarUseCase;
beforeEach(() => { beforeEach(() => {
@@ -32,44 +38,51 @@ describe('GetAvatarUseCase', () => {
error: vi.fn(), error: vi.fn(),
} as unknown as Logger; } as unknown as Logger;
presenter = { output = {
present: vi.fn((result) => { present: vi.fn((result: GetAvatarResult) => {
presenter.result = result; output.result = result;
}), }),
} as unknown as TestPresenter; } as unknown as TestOutputPort;
useCase = new GetAvatarUseCase( useCase = new GetAvatarUseCase(
avatarRepo as unknown as IAvatarRepository, avatarRepo as unknown as IAvatarRepository,
output,
logger, logger,
); );
}); });
it('presents error when no avatar exists for driver', async () => { it('returns AVATAR_NOT_FOUND when no avatar exists for driver', async () => {
avatarRepo.findActiveByDriverId.mockResolvedValue(null); avatarRepo.findActiveByDriverId.mockResolvedValue(null);
await useCase.execute({ driverId: 'driver-1' }, presenter); const input: GetAvatarInput = { driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1'); expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ expect(result).toBeInstanceOf(Result);
success: false, expect(result.isErr()).toBe(true);
errorMessage: 'Avatar not found', const err = result.unwrapErr() as ApplicationErrorCode<
}); GetAvatarErrorCode,
{ message: string }
>;
expect(err.code).toBe('AVATAR_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
}); });
it('presents avatar details when avatar exists', async () => { it('presents avatar details when avatar exists', async () => {
const avatar = Avatar.create({ const avatar = Avatar.create({
id: 'avatar-1', id: 'avatar-1',
driverId: 'driver-1', driverId: 'driver-1',
mediaUrl: MediaUrl.create('https://example.com/avatar.png'), mediaUrl: 'https://example.com/avatar.png',
}); });
avatarRepo.findActiveByDriverId.mockResolvedValue(avatar); avatarRepo.findActiveByDriverId.mockResolvedValue(avatar);
await useCase.execute({ driverId: 'driver-1' }, presenter); const input: GetAvatarInput = { driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1'); expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ expect(output.present).toHaveBeenCalledWith({
success: true,
avatar: { avatar: {
id: avatar.id, id: avatar.id,
driverId: avatar.driverId, driverId: avatar.driverId,
@@ -79,15 +92,19 @@ describe('GetAvatarUseCase', () => {
}); });
}); });
it('handles errors by logging and presenting failure', async () => { it('handles repository errors by returning REPOSITORY_ERROR', async () => {
avatarRepo.findActiveByDriverId.mockRejectedValue(new Error('DB error')); avatarRepo.findActiveByDriverId.mockRejectedValue(new Error('DB error'));
await useCase.execute({ driverId: 'driver-1' }, presenter); const input: GetAvatarInput = { driverId: 'driver-1' };
const result = await useCase.execute(input);
expect((logger.error as unknown as Mock)).toHaveBeenCalled(); expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ expect(result.isErr()).toBe(true);
success: false, const err = result.unwrapErr() as ApplicationErrorCode<
errorMessage: 'Internal error occurred while retrieving avatar', GetAvatarErrorCode,
}); { message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(output.present).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -5,55 +5,53 @@
*/ */
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository'; import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IGetAvatarPresenter } from '../presenters/IGetAvatarPresenter'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetAvatarInput { export interface GetAvatarInput {
driverId: string; driverId: string;
} }
export interface GetAvatarResult { export interface GetAvatarResult {
success: boolean; avatar: {
avatar?: {
id: string; id: string;
driverId: string; driverId: string;
mediaUrl: string; mediaUrl: string;
selectedAt: Date; selectedAt: Date;
}; };
errorMessage?: string;
} }
export interface IGetAvatarPresenter { export type GetAvatarErrorCode = 'AVATAR_NOT_FOUND' | 'REPOSITORY_ERROR';
present(result: GetAvatarResult): void;
} export type GetAvatarApplicationError = ApplicationErrorCode<
GetAvatarErrorCode,
{ message: string }
>;
export class GetAvatarUseCase { export class GetAvatarUseCase {
constructor( constructor(
private readonly avatarRepo: IAvatarRepository, private readonly avatarRepo: IAvatarRepository,
private readonly output: UseCaseOutputPort<GetAvatarResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute( async execute(input: GetAvatarInput): Promise<Result<void, GetAvatarApplicationError>> {
input: GetAvatarInput, this.logger.info('[GetAvatarUseCase] Getting avatar', {
presenter: IGetAvatarPresenter, driverId: input.driverId,
): Promise<void> { });
try {
this.logger.info('[GetAvatarUseCase] Getting avatar', {
driverId: input.driverId,
});
try {
const avatar = await this.avatarRepo.findActiveByDriverId(input.driverId); const avatar = await this.avatarRepo.findActiveByDriverId(input.driverId);
if (!avatar) { if (!avatar) {
presenter.present({ return Result.err({
success: false, code: 'AVATAR_NOT_FOUND',
errorMessage: 'Avatar not found', details: { message: 'Avatar not found' },
}); });
return;
} }
presenter.present({ this.output.present({
success: true,
avatar: { avatar: {
id: avatar.id, id: avatar.id,
driverId: avatar.driverId, driverId: avatar.driverId,
@@ -62,15 +60,17 @@ export class GetAvatarUseCase {
}, },
}); });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error('[GetAvatarUseCase] Error getting avatar', { const err = error instanceof Error ? error : new Error(String(error));
error: error instanceof Error ? error.message : 'Unknown error',
this.logger.error('[GetAvatarUseCase] Error getting avatar', err, {
driverId: input.driverId, driverId: input.driverId,
}); });
presenter.present({ return Result.err({
success: false, code: 'REPOSITORY_ERROR',
errorMessage: 'Internal error occurred while retrieving avatar', details: { message: err.message ?? 'Unexpected repository error' },
}); });
} }
} }

View File

@@ -1,13 +1,20 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetMediaUseCase } from './GetMediaUseCase'; import {
GetMediaUseCase,
type GetMediaInput,
type GetMediaResult,
type GetMediaErrorCode,
} from './GetMediaUseCase';
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { IGetMediaPresenter } from '../presenters/IGetMediaPresenter'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result';
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'; import { MediaUrl } from '../../domain/value-objects/MediaUrl';
interface TestPresenter extends IGetMediaPresenter { interface TestOutputPort extends UseCaseOutputPort<GetMediaResult> {
result?: any; present: Mock;
result?: GetMediaResult;
} }
describe('GetMediaUseCase', () => { describe('GetMediaUseCase', () => {
@@ -15,7 +22,7 @@ describe('GetMediaUseCase', () => {
findById: Mock; findById: Mock;
}; };
let logger: Logger; let logger: Logger;
let presenter: TestPresenter; let output: TestOutputPort;
let useCase: GetMediaUseCase; let useCase: GetMediaUseCase;
beforeEach(() => { beforeEach(() => {
@@ -30,28 +37,31 @@ describe('GetMediaUseCase', () => {
error: vi.fn(), error: vi.fn(),
} as unknown as Logger; } as unknown as Logger;
presenter = { output = {
present: vi.fn((result) => { present: vi.fn((result: GetMediaResult) => {
presenter.result = result; output.result = result;
}), }),
} as unknown as TestPresenter; } as unknown as TestOutputPort;
useCase = new GetMediaUseCase( useCase = new GetMediaUseCase(
mediaRepo as unknown as IMediaRepository, mediaRepo as unknown as IMediaRepository,
output,
logger, logger,
); );
}); });
it('presents error when media is not found', async () => { it('returns MEDIA_NOT_FOUND when media is not found', async () => {
mediaRepo.findById.mockResolvedValue(null); mediaRepo.findById.mockResolvedValue(null);
await useCase.execute({ mediaId: 'missing' }, presenter); const input: GetMediaInput = { mediaId: 'missing' };
const result = await useCase.execute(input);
expect(mediaRepo.findById).toHaveBeenCalledWith('missing'); expect(mediaRepo.findById).toHaveBeenCalledWith('missing');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ expect(result).toBeInstanceOf(Result);
success: false, expect(result.isErr()).toBe(true);
errorMessage: 'Media not found', const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
}); expect(err.code).toBe('MEDIA_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
}); });
it('presents media details when media exists', async () => { it('presents media details when media exists', async () => {
@@ -68,11 +78,12 @@ describe('GetMediaUseCase', () => {
mediaRepo.findById.mockResolvedValue(media); mediaRepo.findById.mockResolvedValue(media);
await useCase.execute({ mediaId: 'media-1' }, presenter); const input: GetMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1'); expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ expect(output.present).toHaveBeenCalledWith({
success: true,
media: { media: {
id: media.id, id: media.id,
filename: media.filename, filename: media.filename,
@@ -88,15 +99,15 @@ describe('GetMediaUseCase', () => {
}); });
}); });
it('handles errors by logging and presenting failure', async () => { it('handles repository errors by returning REPOSITORY_ERROR', async () => {
mediaRepo.findById.mockRejectedValue(new Error('DB error')); mediaRepo.findById.mockRejectedValue(new Error('DB error'));
await useCase.execute({ mediaId: 'media-1' }, presenter); const input: GetMediaInput = { mediaId: 'media-1' };
const result = await useCase.execute(input);
expect((logger.error as unknown as Mock)).toHaveBeenCalled(); expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect((presenter.present as unknown as Mock)).toHaveBeenCalledWith({ expect(result.isErr()).toBe(true);
success: false, const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
errorMessage: 'Internal error occurred while retrieving media', expect(err.code).toBe('REPOSITORY_ERROR');
});
}); });
}); });

View File

@@ -5,16 +5,16 @@
*/ */
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IGetMediaPresenter } from '../presenters/IGetMediaPresenter'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetMediaInput { export interface GetMediaInput {
mediaId: string; mediaId: string;
} }
export interface GetMediaResult { export interface GetMediaResult {
success: boolean; media: {
media?: {
id: string; id: string;
filename: string; filename: string;
originalName: string; originalName: string;
@@ -26,40 +26,35 @@ export interface GetMediaResult {
uploadedAt: Date; uploadedAt: Date;
metadata?: Record<string, any>; metadata?: Record<string, any>;
}; };
errorMessage?: string;
} }
export interface IGetMediaPresenter { export type GetMediaErrorCode = 'MEDIA_NOT_FOUND' | 'REPOSITORY_ERROR';
present(result: GetMediaResult): void;
}
export class GetMediaUseCase { export class GetMediaUseCase {
constructor( constructor(
private readonly mediaRepo: IMediaRepository, private readonly mediaRepo: IMediaRepository,
private readonly output: UseCaseOutputPort<GetMediaResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute( async execute(
input: GetMediaInput, input: GetMediaInput,
presenter: IGetMediaPresenter, ): Promise<Result<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>> {
): Promise<void> { this.logger.info('[GetMediaUseCase] Getting media', {
try { mediaId: input.mediaId,
this.logger.info('[GetMediaUseCase] Getting media', { });
mediaId: input.mediaId,
});
try {
const media = await this.mediaRepo.findById(input.mediaId); const media = await this.mediaRepo.findById(input.mediaId);
if (!media) { if (!media) {
presenter.present({ return Result.err({
success: false, code: 'MEDIA_NOT_FOUND',
errorMessage: 'Media not found', details: { message: 'Media not found' },
}); });
return;
} }
presenter.present({ this.output.present({
success: true,
media: { media: {
id: media.id, id: media.id,
filename: media.filename, filename: media.filename,
@@ -74,15 +69,16 @@ export class GetMediaUseCase {
}, },
}); });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error('[GetMediaUseCase] Error getting media', { const err = error instanceof Error ? error : new Error(String(error));
error: error instanceof Error ? error.message : 'Unknown error', this.logger.error('[GetMediaUseCase] Error getting media', err, {
mediaId: input.mediaId, mediaId: input.mediaId,
}); });
presenter.present({ return Result.err({
success: false, code: 'REPOSITORY_ERROR',
errorMessage: 'Internal error occurred while retrieving media', details: { message: err.message },
}); });
} }
} }

View File

@@ -8,10 +8,11 @@ import { v4 as uuidv4 } from 'uuid';
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { FaceValidationPort } from '../ports/FaceValidationPort'; import type { FaceValidationPort } from '../ports/FaceValidationPort';
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort'; import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest'; import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
import type { IRequestAvatarGenerationPresenter } from '../presenters/IRequestAvatarGenerationPresenter';
import type { RacingSuitColor } from '../../domain/types/AvatarGenerationRequest'; import type { RacingSuitColor } from '../../domain/types/AvatarGenerationRequest';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface RequestAvatarGenerationInput { export interface RequestAvatarGenerationInput {
userId: string; userId: string;
@@ -20,63 +21,68 @@ export interface RequestAvatarGenerationInput {
style?: 'realistic' | 'cartoon' | 'pixel-art'; style?: 'realistic' | 'cartoon' | 'pixel-art';
} }
export interface RequestAvatarGenerationResult {
requestId: string;
status: 'validating' | 'generating' | 'completed';
avatarUrls?: string[];
}
export type RequestAvatarGenerationErrorCode =
| 'FACE_VALIDATION_FAILED'
| 'GENERATION_FAILED'
| 'REPOSITORY_ERROR';
export type RequestAvatarGenerationApplicationError = ApplicationErrorCode<
RequestAvatarGenerationErrorCode,
{ message: string }
>;
export class RequestAvatarGenerationUseCase { export class RequestAvatarGenerationUseCase {
constructor( constructor(
private readonly avatarRepo: IAvatarGenerationRepository, private readonly avatarRepo: IAvatarGenerationRepository,
private readonly faceValidation: FaceValidationPort, private readonly faceValidation: FaceValidationPort,
private readonly avatarGeneration: AvatarGenerationPort, private readonly avatarGeneration: AvatarGenerationPort,
private readonly output: UseCaseOutputPort<RequestAvatarGenerationResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute( async execute(
input: RequestAvatarGenerationInput, input: RequestAvatarGenerationInput,
presenter: IRequestAvatarGenerationPresenter, ): Promise<Result<void, RequestAvatarGenerationApplicationError>> {
): Promise<void> { this.logger.info('[RequestAvatarGenerationUseCase] Starting avatar generation request', {
try { userId: input.userId,
this.logger.info('[RequestAvatarGenerationUseCase] Starting avatar generation request', { suitColor: input.suitColor,
userId: input.userId, });
suitColor: input.suitColor,
});
// Create the avatar generation request entity try {
const requestId = uuidv4(); const requestId = uuidv4();
const request = AvatarGenerationRequest.create({ const request = AvatarGenerationRequest.create({
id: requestId, id: requestId,
userId: input.userId, userId: input.userId,
facePhotoUrl: input.facePhotoData, // Assuming facePhotoData is a URL or base64 facePhotoUrl: input.facePhotoData,
suitColor: input.suitColor, suitColor: input.suitColor,
style: input.style, style: input.style,
}); });
// Save initial request
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
// Present initial status
presenter.present({
requestId,
status: 'validating',
});
// Validate face photo
request.markAsValidating(); request.markAsValidating();
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
const validationResult = await this.faceValidation.validateFacePhoto(input.facePhotoData); const validationResult = await this.faceValidation.validateFacePhoto(input.facePhotoData);
if (!validationResult.isValid || !validationResult.hasFace || validationResult.faceCount !== 1) { if (!validationResult.isValid || !validationResult.hasFace || validationResult.faceCount !== 1) {
const errorMessage = validationResult.errorMessage || 'Invalid face photo: must contain exactly one face'; const errorMessage =
validationResult.errorMessage || 'Invalid face photo: must contain exactly one face';
request.fail(errorMessage); request.fail(errorMessage);
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
presenter.present({ return Result.err({
requestId, code: 'FACE_VALIDATION_FAILED',
status: 'failed', details: { message: errorMessage },
errorMessage,
}); });
return;
} }
// Generate avatars
request.markAsGenerating(); request.markAsGenerating();
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
@@ -85,7 +91,7 @@ export class RequestAvatarGenerationUseCase {
prompt: request.buildPrompt(), prompt: request.buildPrompt(),
suitColor: input.suitColor, suitColor: input.suitColor,
style: input.style || 'realistic', style: input.style || 'realistic',
count: 3, // Generate 3 avatar options count: 3,
}; };
const generationResult = await this.avatarGeneration.generateAvatars(generationOptions); const generationResult = await this.avatarGeneration.generateAvatars(generationOptions);
@@ -95,20 +101,17 @@ export class RequestAvatarGenerationUseCase {
request.fail(errorMessage); request.fail(errorMessage);
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
presenter.present({ return Result.err({
requestId, code: 'GENERATION_FAILED',
status: 'failed', details: { message: errorMessage },
errorMessage,
}); });
return;
} }
// Complete the request
const avatarUrls = generationResult.avatars.map(avatar => avatar.url); const avatarUrls = generationResult.avatars.map(avatar => avatar.url);
request.completeWithAvatars(avatarUrls); request.completeWithAvatars(avatarUrls);
await this.avatarRepo.save(request); await this.avatarRepo.save(request);
presenter.present({ this.output.present({
requestId, requestId,
status: 'completed', status: 'completed',
avatarUrls, avatarUrls,
@@ -120,16 +123,17 @@ export class RequestAvatarGenerationUseCase {
avatarCount: avatarUrls.length, avatarCount: avatarUrls.length,
}); });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error('[RequestAvatarGenerationUseCase] Error during avatar generation', { const err = error instanceof Error ? error : new Error(String(error));
error: error instanceof Error ? error.message : 'Unknown error',
this.logger.error('[RequestAvatarGenerationUseCase] Error during avatar generation', err, {
userId: input.userId, userId: input.userId,
}); });
presenter.present({ return Result.err({
requestId: uuidv4(), // Fallback ID code: 'REPOSITORY_ERROR',
status: 'failed', details: { message: err.message ?? 'Internal error occurred during avatar generation' },
errorMessage: 'Internal error occurred during avatar generation',
}); });
} }
} }

View File

@@ -5,8 +5,9 @@
*/ */
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository'; import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { ISelectAvatarPresenter } from '../presenters/ISelectAvatarPresenter'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface SelectAvatarInput { export interface SelectAvatarInput {
requestId: string; requestId: string;
@@ -14,47 +15,48 @@ export interface SelectAvatarInput {
} }
export interface SelectAvatarResult { export interface SelectAvatarResult {
success: boolean; requestId: string;
selectedAvatarUrl?: string; selectedAvatarUrl: string;
errorMessage?: string;
} }
export interface ISelectAvatarPresenter { export type SelectAvatarErrorCode =
present(result: SelectAvatarResult): void; | 'REQUEST_NOT_FOUND'
} | 'REQUEST_NOT_COMPLETED'
| 'REPOSITORY_ERROR';
export type SelectAvatarApplicationError = ApplicationErrorCode<
SelectAvatarErrorCode,
{ message: string }
>;
export class SelectAvatarUseCase { export class SelectAvatarUseCase {
constructor( constructor(
private readonly avatarRepo: IAvatarGenerationRepository, private readonly avatarRepo: IAvatarGenerationRepository,
private readonly output: UseCaseOutputPort<SelectAvatarResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute( async execute(input: SelectAvatarInput): Promise<Result<void, SelectAvatarApplicationError>> {
input: SelectAvatarInput, this.logger.info('[SelectAvatarUseCase] Selecting avatar', {
presenter: ISelectAvatarPresenter, requestId: input.requestId,
): Promise<void> { selectedIndex: input.selectedIndex,
try { });
this.logger.info('[SelectAvatarUseCase] Selecting avatar', {
requestId: input.requestId,
selectedIndex: input.selectedIndex,
});
try {
const request = await this.avatarRepo.findById(input.requestId); const request = await this.avatarRepo.findById(input.requestId);
if (!request) { if (!request) {
presenter.present({ return Result.err({
success: false, code: 'REQUEST_NOT_FOUND',
errorMessage: 'Avatar generation request not found', details: { message: 'Avatar generation request not found' },
}); });
return;
} }
if (request.status !== 'completed') { if (request.status !== 'completed') {
presenter.present({ return Result.err({
success: false, code: 'REQUEST_NOT_COMPLETED',
errorMessage: 'Avatar generation is not completed yet', details: { message: 'Avatar generation is not completed yet' },
}); });
return;
} }
request.selectAvatar(input.selectedIndex); request.selectAvatar(input.selectedIndex);
@@ -62,8 +64,8 @@ export class SelectAvatarUseCase {
const selectedAvatarUrl = request.selectedAvatarUrl; const selectedAvatarUrl = request.selectedAvatarUrl;
presenter.present({ this.output.present({
success: true, requestId: input.requestId,
selectedAvatarUrl, selectedAvatarUrl,
}); });
@@ -72,15 +74,17 @@ export class SelectAvatarUseCase {
selectedAvatarUrl, selectedAvatarUrl,
}); });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error('[SelectAvatarUseCase] Error selecting avatar', { const err = error instanceof Error ? error : new Error(String(error));
error: error instanceof Error ? error.message : 'Unknown error',
this.logger.error('[SelectAvatarUseCase] Error selecting avatar', err, {
requestId: input.requestId, requestId: input.requestId,
}); });
presenter.present({ return Result.err({
success: false, code: 'REPOSITORY_ERROR',
errorMessage: 'Internal error occurred while selecting avatar', details: { message: err.message ?? 'Unexpected repository error' },
}); });
} }
} }

View File

@@ -4,11 +4,12 @@
* Handles the business logic for updating a driver's avatar. * Handles the business logic for updating a driver's avatar.
*/ */
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result';
import { Avatar } from '../../domain/entities/Avatar'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { IUpdateAvatarPresenter } from '../presenters/IUpdateAvatarPresenter';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Avatar } from '../../domain/entities/Avatar';
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
export interface UpdateAvatarInput { export interface UpdateAvatarInput {
driverId: string; driverId: string;
@@ -16,39 +17,38 @@ export interface UpdateAvatarInput {
} }
export interface UpdateAvatarResult { export interface UpdateAvatarResult {
success: boolean; avatarId: string;
errorMessage?: string; driverId: string;
} }
export interface IUpdateAvatarPresenter { export type UpdateAvatarErrorCode = 'REPOSITORY_ERROR';
present(result: UpdateAvatarResult): void;
} export type UpdateAvatarApplicationError = ApplicationErrorCode<
UpdateAvatarErrorCode,
{ message: string }
>;
export class UpdateAvatarUseCase { export class UpdateAvatarUseCase {
constructor( constructor(
private readonly avatarRepo: IAvatarRepository, private readonly avatarRepo: IAvatarRepository,
private readonly output: UseCaseOutputPort<UpdateAvatarResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute( async execute(input: UpdateAvatarInput): Promise<Result<void, UpdateAvatarApplicationError>> {
input: UpdateAvatarInput, this.logger.info('[UpdateAvatarUseCase] Updating avatar', {
presenter: IUpdateAvatarPresenter, driverId: input.driverId,
): Promise<void> { mediaUrl: input.mediaUrl,
try { });
this.logger.info('[UpdateAvatarUseCase] Updating avatar', {
driverId: input.driverId,
mediaUrl: input.mediaUrl,
});
// Deactivate current active avatar try {
const currentAvatar = await this.avatarRepo.findActiveByDriverId(input.driverId); const currentAvatar = await this.avatarRepo.findActiveByDriverId(input.driverId);
if (currentAvatar) { if (currentAvatar) {
currentAvatar.deactivate(); currentAvatar.deactivate();
await this.avatarRepo.save(currentAvatar); await this.avatarRepo.save(currentAvatar);
} }
// Create new avatar const avatarId = uuidv4(); // TODO this ID should be a value object
const avatarId = uuidv4();
const newAvatar = Avatar.create({ const newAvatar = Avatar.create({
id: avatarId, id: avatarId,
driverId: input.driverId, driverId: input.driverId,
@@ -57,8 +57,9 @@ export class UpdateAvatarUseCase {
await this.avatarRepo.save(newAvatar); await this.avatarRepo.save(newAvatar);
presenter.present({ this.output.present({
success: true, avatarId,
driverId: input.driverId,
}); });
this.logger.info('[UpdateAvatarUseCase] Avatar updated successfully', { this.logger.info('[UpdateAvatarUseCase] Avatar updated successfully', {
@@ -66,15 +67,17 @@ export class UpdateAvatarUseCase {
avatarId, avatarId,
}); });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error('[UpdateAvatarUseCase] Error updating avatar', { const err = error instanceof Error ? error : new Error(String(error));
error: error instanceof Error ? error.message : 'Unknown error',
this.logger.error('[UpdateAvatarUseCase] Error updating avatar', err, {
driverId: input.driverId, driverId: input.driverId,
}); });
presenter.present({ return Result.err({
success: false, code: 'REPOSITORY_ERROR',
errorMessage: 'Internal error occurred while updating avatar', details: { message: err.message ?? 'Internal error occurred while updating avatar' },
}); });
} }
} }

View File

@@ -6,9 +6,10 @@
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository'; import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
import type { MediaStoragePort } from '../ports/MediaStoragePort'; import type { MediaStoragePort } from '../ports/MediaStoragePort';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Media } from '../../domain/entities/Media'; import { Media } from '../../domain/entities/Media';
import type { IUploadMediaPresenter } from '../presenters/IUploadMediaPresenter';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export interface UploadMediaInput { export interface UploadMediaInput {
@@ -18,34 +19,32 @@ export interface UploadMediaInput {
} }
export interface UploadMediaResult { export interface UploadMediaResult {
success: boolean; mediaId: string;
mediaId?: string; url: string | undefined;
url?: string;
errorMessage?: string;
} }
export interface IUploadMediaPresenter { export type UploadMediaErrorCode =
present(result: UploadMediaResult): void; | 'UPLOAD_FAILED'
} | 'REPOSITORY_ERROR';
export class UploadMediaUseCase { export class UploadMediaUseCase {
constructor( constructor(
private readonly mediaRepo: IMediaRepository, private readonly mediaRepo: IMediaRepository,
private readonly mediaStorage: MediaStoragePort, private readonly mediaStorage: MediaStoragePort,
private readonly output: UseCaseOutputPort<UploadMediaResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute( async execute(
input: UploadMediaInput, input: UploadMediaInput,
presenter: IUploadMediaPresenter, ): Promise<Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>> {
): Promise<void> { this.logger.info('[UploadMediaUseCase] Starting media upload', {
try { filename: input.file.originalname,
this.logger.info('[UploadMediaUseCase] Starting media upload', { size: input.file.size,
filename: input.file.originalname, uploadedBy: input.uploadedBy,
size: input.file.size, });
uploadedBy: input.uploadedBy,
});
try {
// Upload file to storage service // Upload file to storage service
const uploadResult = await this.mediaStorage.uploadMedia(input.file.buffer, { const uploadResult = await this.mediaStorage.uploadMedia(input.file.buffer, {
filename: input.file.originalname, filename: input.file.originalname,
@@ -54,11 +53,13 @@ export class UploadMediaUseCase {
}); });
if (!uploadResult.success) { if (!uploadResult.success) {
presenter.present({ return Result.err({
success: false, code: 'UPLOAD_FAILED',
errorMessage: uploadResult.errorMessage || 'Failed to upload media', details: {
message:
uploadResult.errorMessage ?? 'Failed to upload media',
},
}); });
return;
} }
// Determine media type // Determine media type
@@ -85,8 +86,7 @@ export class UploadMediaUseCase {
// Save to repository // Save to repository
await this.mediaRepo.save(media); await this.mediaRepo.save(media);
presenter.present({ this.output.present({
success: true,
mediaId, mediaId,
url: uploadResult.url, url: uploadResult.url,
}); });
@@ -96,15 +96,17 @@ export class UploadMediaUseCase {
url: uploadResult.url, url: uploadResult.url,
}); });
return Result.ok(undefined);
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('[UploadMediaUseCase] Error uploading media', { this.logger.error('[UploadMediaUseCase] Error uploading media', {
error: error instanceof Error ? error.message : 'Unknown error', error: err.message,
filename: input.file.originalname, filename: input.file.originalname,
}); });
presenter.present({ return Result.err({
success: false, code: 'REPOSITORY_ERROR',
errorMessage: 'Internal error occurred during media upload', details: { message: err.message },
}); });
} }
} }

View File

@@ -1,16 +1,27 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetUnreadNotificationsUseCase } from './GetUnreadNotificationsUseCase'; import {
GetUnreadNotificationsUseCase,
type GetUnreadNotificationsInput,
type GetUnreadNotificationsResult,
} from './GetUnreadNotificationsUseCase';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Notification } from '../../domain/entities/Notification'; import { Notification } from '../../domain/entities/Notification';
interface NotificationRepositoryMock { interface NotificationRepositoryMock {
findUnreadByRecipientId: Mock; findUnreadByRecipientId: Mock;
} }
interface OutputPortMock extends UseCaseOutputPort<GetUnreadNotificationsResult> {
present: Mock;
}
describe('GetUnreadNotificationsUseCase', () => { describe('GetUnreadNotificationsUseCase', () => {
let notificationRepository: NotificationRepositoryMock; let notificationRepository: NotificationRepositoryMock;
let logger: Logger; let logger: Logger;
let output: OutputPortMock;
let useCase: GetUnreadNotificationsUseCase; let useCase: GetUnreadNotificationsUseCase;
beforeEach(() => { beforeEach(() => {
@@ -25,8 +36,13 @@ describe('GetUnreadNotificationsUseCase', () => {
error: vi.fn(), error: vi.fn(),
} as unknown as Logger; } as unknown as Logger;
output = {
present: vi.fn(),
} as unknown as OutputPortMock;
useCase = new GetUnreadNotificationsUseCase( useCase = new GetUnreadNotificationsUseCase(
notificationRepository as unknown as INotificationRepository, notificationRepository as unknown as INotificationRepository,
output,
logger, logger,
); );
}); });
@@ -37,7 +53,7 @@ describe('GetUnreadNotificationsUseCase', () => {
Notification.create({ Notification.create({
id: 'n1', id: 'n1',
recipientId, recipientId,
type: 'info', type: 'system_announcement',
title: 'Test', title: 'Test',
body: 'Body', body: 'Body',
channel: 'in_app', channel: 'in_app',
@@ -46,19 +62,33 @@ describe('GetUnreadNotificationsUseCase', () => {
notificationRepository.findUnreadByRecipientId.mockResolvedValue(notifications); notificationRepository.findUnreadByRecipientId.mockResolvedValue(notifications);
const result = await useCase.execute(recipientId); const input: GetUnreadNotificationsInput = { recipientId };
const result = await useCase.execute(input);
expect(notificationRepository.findUnreadByRecipientId).toHaveBeenCalledWith(recipientId); expect(notificationRepository.findUnreadByRecipientId).toHaveBeenCalledWith(recipientId);
expect(result.notifications).toEqual(notifications); expect(result).toBeInstanceOf(Result);
expect(result.totalCount).toBe(1); expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({
notifications,
totalCount: 1,
});
}); });
it('handles repository errors by logging and rethrowing', async () => { it('handles repository errors by logging and returning error result', async () => {
const recipientId = 'driver-1'; const recipientId = 'driver-1';
const error = new Error('DB error'); const error = new Error('DB error');
notificationRepository.findUnreadByRecipientId.mockRejectedValue(error); notificationRepository.findUnreadByRecipientId.mockRejectedValue(error);
await expect(useCase.execute(recipientId)).rejects.toThrow('DB error'); const input: GetUnreadNotificationsInput = { recipientId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('DB error');
expect((logger.error as unknown as Mock)).toHaveBeenCalled(); expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,41 +1,72 @@
/** /**
* Application Use Case: GetUnreadNotificationsUseCase * Application Use Case: GetUnreadNotificationsUseCase
* *
* Retrieves unread notifications for a recipient. * Retrieves unread notifications for a recipient.
*/ */
import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Notification } from '../../domain/entities/Notification'; import type { Notification } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
export interface UnreadNotificationsResult { export type GetUnreadNotificationsInput = {
recipientId: string;
};
export interface GetUnreadNotificationsResult {
notifications: Notification[]; notifications: Notification[];
totalCount: number; totalCount: number;
} }
export class GetUnreadNotificationsUseCase implements AsyncUseCase<string, UnreadNotificationsResult> { export type GetUnreadNotificationsErrorCode = 'REPOSITORY_ERROR';
export class GetUnreadNotificationsUseCase {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<GetUnreadNotificationsResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(recipientId: string): Promise<UnreadNotificationsResult> { async execute(
this.logger.debug(`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`); input: GetUnreadNotificationsInput,
): Promise<Result<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>> {
const { recipientId } = input;
this.logger.debug(
`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`,
);
try { try {
const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId); const notifications = await this.notificationRepository.findUnreadByRecipientId(
this.logger.info(`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`); recipientId,
);
this.logger.info(
`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`,
);
if (notifications.length === 0) { if (notifications.length === 0) {
this.logger.warn(`No unread notifications found for recipient ID: ${recipientId}`); this.logger.warn(`No unread notifications found for recipient ID: ${recipientId}`);
} }
return { this.output.present({
notifications, notifications,
totalCount: notifications.length, totalCount: notifications.length,
}; });
return Result.ok<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>(
undefined,
);
} catch (error) { } catch (error) {
this.logger.error(`Failed to retrieve unread notifications for recipient ID: ${recipientId}`, error instanceof Error ? error : new Error(String(error))); const err = error instanceof Error ? error : new Error(String(error));
throw error; this.logger.error(
`Failed to retrieve unread notifications for recipient ID: ${recipientId}`,
err,
);
return Result.err<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
} }
} }
} }

View File

@@ -1,9 +1,14 @@
import { describe, it, expect, vi, type Mock } from 'vitest'; import { describe, it, expect, vi, type Mock } from 'vitest';
import { MarkNotificationReadUseCase } from './MarkNotificationReadUseCase'; import {
MarkNotificationReadUseCase,
type MarkNotificationReadCommand,
type MarkNotificationReadResult,
} from './MarkNotificationReadUseCase';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Notification } from '../../domain/entities/Notification'; import { Notification } from '../../domain/entities/Notification';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
interface NotificationRepositoryMock { interface NotificationRepositoryMock {
findById: Mock; findById: Mock;
@@ -11,9 +16,14 @@ interface NotificationRepositoryMock {
markAllAsReadByRecipientId: Mock; markAllAsReadByRecipientId: Mock;
} }
interface OutputPortMock extends UseCaseOutputPort<MarkNotificationReadResult> {
present: Mock;
}
describe('MarkNotificationReadUseCase', () => { describe('MarkNotificationReadUseCase', () => {
let notificationRepository: NotificationRepositoryMock; let notificationRepository: NotificationRepositoryMock;
let logger: Logger; let logger: Logger;
let output: OutputPortMock;
let useCase: MarkNotificationReadUseCase; let useCase: MarkNotificationReadUseCase;
beforeEach(() => { beforeEach(() => {
@@ -30,27 +40,39 @@ describe('MarkNotificationReadUseCase', () => {
error: vi.fn(), error: vi.fn(),
} as unknown as Logger; } as unknown as Logger;
output = {
present: vi.fn(),
} as unknown as OutputPortMock;
useCase = new MarkNotificationReadUseCase( useCase = new MarkNotificationReadUseCase(
notificationRepository as unknown as INotificationRepository, notificationRepository as unknown as INotificationRepository,
output,
logger, logger,
); );
}); });
it('throws when notification is not found', async () => { it('returns NOTIFICATION_NOT_FOUND when notification is not found', async () => {
notificationRepository.findById.mockResolvedValue(null); notificationRepository.findById.mockResolvedValue(null);
await expect( const command: MarkNotificationReadCommand = {
useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }), notificationId: 'n1',
).rejects.toThrow(NotificationDomainError); recipientId: 'driver-1',
};
expect((logger.warn as unknown as Mock)).toHaveBeenCalled(); const result = await useCase.execute(command);
expect(result).toBeInstanceOf(Result);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<'NOTIFICATION_NOT_FOUND', { message: string }>;
expect(err.code).toBe('NOTIFICATION_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
}); });
it('throws when recipientId does not match', async () => { it('returns RECIPIENT_MISMATCH when recipientId does not match', async () => {
const notification = Notification.create({ const notification = Notification.create({
id: 'n1', id: 'n1',
recipientId: 'driver-2', recipientId: 'driver-2',
type: 'info', type: 'system_announcement',
title: 'Test', title: 'Test',
body: 'Body', body: 'Body',
channel: 'in_app', channel: 'in_app',
@@ -58,16 +80,24 @@ describe('MarkNotificationReadUseCase', () => {
notificationRepository.findById.mockResolvedValue(notification); notificationRepository.findById.mockResolvedValue(notification);
await expect( const command: MarkNotificationReadCommand = {
useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }), notificationId: 'n1',
).rejects.toThrow(NotificationDomainError); recipientId: 'driver-1',
};
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<'RECIPIENT_MISMATCH', { message: string }>;
expect(err.code).toBe('RECIPIENT_MISMATCH');
expect(output.present).not.toHaveBeenCalled();
}); });
it('marks notification as read when unread', async () => { it('marks notification as read when unread and presents result', async () => {
const notification = Notification.create({ const notification = Notification.create({
id: 'n1', id: 'n1',
recipientId: 'driver-1', recipientId: 'driver-1',
type: 'info', type: 'system_announcement',
title: 'Test', title: 'Test',
body: 'Body', body: 'Body',
channel: 'in_app', channel: 'in_app',
@@ -75,9 +105,19 @@ describe('MarkNotificationReadUseCase', () => {
notificationRepository.findById.mockResolvedValue(notification); notificationRepository.findById.mockResolvedValue(notification);
await useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }); const command: MarkNotificationReadCommand = {
notificationId: 'n1',
recipientId: 'driver-1',
};
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(notificationRepository.update).toHaveBeenCalled(); expect(notificationRepository.update).toHaveBeenCalled();
expect((logger.info as unknown as Mock)).toHaveBeenCalled(); expect(output.present).toHaveBeenCalledWith({
notificationId: 'n1',
recipientId: 'driver-1',
wasAlreadyRead: false,
});
}); });
}); });

View File

@@ -4,7 +4,9 @@
* Marks a notification as read. * Marks a notification as read.
*/ */
import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
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';
@@ -13,38 +15,86 @@ export interface MarkNotificationReadCommand {
recipientId: string; // For validation recipientId: string; // For validation
} }
export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificationReadCommand, void> { export interface MarkNotificationReadResult {
notificationId: string;
recipientId: string;
wasAlreadyRead: boolean;
}
export type MarkNotificationReadErrorCode =
| 'NOTIFICATION_NOT_FOUND'
| 'RECIPIENT_MISMATCH'
| 'REPOSITORY_ERROR';
export class MarkNotificationReadUseCase {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<MarkNotificationReadResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(command: MarkNotificationReadCommand): Promise<void> { async execute(
this.logger.debug(`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`); command: MarkNotificationReadCommand,
): Promise<Result<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>> {
this.logger.debug(
`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`,
);
try { try {
const notification = await this.notificationRepository.findById(command.notificationId); const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) { if (!notification) {
this.logger.warn(`Notification not found for ID: ${command.notificationId}`); this.logger.warn(`Notification not found for ID: ${command.notificationId}`);
throw new NotificationDomainError('Notification not found'); return Result.err({
code: 'NOTIFICATION_NOT_FOUND',
details: { message: 'Notification not found' },
});
} }
if (notification.recipientId !== command.recipientId) { if (notification.recipientId !== command.recipientId) {
this.logger.warn(`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`); this.logger.warn(
throw new NotificationDomainError('Cannot mark another user\'s notification as read'); `Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`,
);
return Result.err({
code: 'RECIPIENT_MISMATCH',
details: { message: "Cannot mark another user's notification as read" },
});
} }
if (!notification.isUnread()) { if (!notification.isUnread()) {
this.logger.info(`Notification ${command.notificationId} is already read. Skipping update.`); this.logger.info(
return; // Already read, nothing to do `Notification ${command.notificationId} is already read. Skipping update.`,
);
this.output.present({
notificationId: command.notificationId,
recipientId: command.recipientId,
wasAlreadyRead: true,
});
return Result.ok(undefined);
} }
const updatedNotification = notification.markAsRead(); const updatedNotification = notification.markAsRead();
await this.notificationRepository.update(updatedNotification); await this.notificationRepository.update(updatedNotification);
this.logger.info(`Notification ${command.notificationId} successfully marked as read.`); this.logger.info(
`Notification ${command.notificationId} successfully marked as read.`,
);
this.output.present({
notificationId: command.notificationId,
recipientId: command.recipientId,
wasAlreadyRead: false,
});
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error(`Failed to mark notification ${command.notificationId} as read: ${error instanceof Error ? error.message : String(error)}`); const err = error instanceof Error ? error : new Error(String(error));
throw error; this.logger.error(
`Failed to mark notification ${command.notificationId} as read: ${err.message}`,
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
} }
} }
} }
@@ -54,13 +104,36 @@ export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificatio
* *
* Marks all notifications as read for a recipient. * Marks all notifications as read for a recipient.
*/ */
export class MarkAllNotificationsReadUseCase implements AsyncUseCase<string, void> { export interface MarkAllNotificationsReadInput {
recipientId: string;
}
export interface MarkAllNotificationsReadResult {
recipientId: string;
}
export type MarkAllNotificationsReadErrorCode = 'REPOSITORY_ERROR';
export class MarkAllNotificationsReadUseCase {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<MarkAllNotificationsReadResult>,
) {} ) {}
async execute(recipientId: string): Promise<void> { async execute(
await this.notificationRepository.markAllAsReadByRecipientId(recipientId); input: MarkAllNotificationsReadInput,
): Promise<Result<void, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>> {
try {
await this.notificationRepository.markAllAsReadByRecipientId(input.recipientId);
this.output.present({ recipientId: input.recipientId });
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
} }
} }
@@ -74,27 +147,78 @@ export interface DismissNotificationCommand {
recipientId: string; recipientId: string;
} }
export class DismissNotificationUseCase implements AsyncUseCase<DismissNotificationCommand, void> { export interface DismissNotificationResult {
notificationId: string;
recipientId: string;
wasAlreadyDismissed: boolean;
}
export type DismissNotificationErrorCode =
| 'NOTIFICATION_NOT_FOUND'
| 'RECIPIENT_MISMATCH'
| 'CANNOT_DISMISS_REQUIRING_RESPONSE'
| 'REPOSITORY_ERROR';
export class DismissNotificationUseCase {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<DismissNotificationResult>,
) {} ) {}
async execute(command: DismissNotificationCommand): Promise<void> { async execute(
const notification = await this.notificationRepository.findById(command.notificationId); command: DismissNotificationCommand,
): Promise<Result<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>> {
if (!notification) { try {
throw new NotificationDomainError('Notification not found'); const notification = await this.notificationRepository.findById(
} command.notificationId,
);
if (notification.recipientId !== command.recipientId) { if (!notification) {
throw new NotificationDomainError('Cannot dismiss another user\'s notification'); return Result.err({
} code: 'NOTIFICATION_NOT_FOUND',
details: { message: 'Notification not found' },
});
}
if (notification.isDismissed()) { if (notification.recipientId !== command.recipientId) {
return; // Already dismissed return Result.err({
} code: 'RECIPIENT_MISMATCH',
details: { message: "Cannot dismiss another user's notification" },
});
}
const updatedNotification = notification.dismiss(); if (notification.isDismissed()) {
await this.notificationRepository.update(updatedNotification); this.output.present({
notificationId: command.notificationId,
recipientId: command.recipientId,
wasAlreadyDismissed: true,
});
return Result.ok(undefined);
}
if (!notification.canDismiss()) {
return Result.err({
code: 'CANNOT_DISMISS_REQUIRING_RESPONSE',
details: { message: 'Cannot dismiss notification that requires response' },
});
}
const updatedNotification = notification.dismiss();
await this.notificationRepository.update(updatedNotification);
this.output.present({
notificationId: command.notificationId,
recipientId: command.recipientId,
wasAlreadyDismissed: false,
});
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
} }
} }

View File

@@ -5,10 +5,22 @@ import {
UpdateTypePreferenceUseCase, UpdateTypePreferenceUseCase,
UpdateQuietHoursUseCase, UpdateQuietHoursUseCase,
SetDigestModeUseCase, SetDigestModeUseCase,
type GetNotificationPreferencesInput,
type GetNotificationPreferencesResult,
type UpdateChannelPreferenceCommand,
type UpdateChannelPreferenceResult,
type UpdateTypePreferenceCommand,
type UpdateTypePreferenceResult,
type UpdateQuietHoursCommand,
type UpdateQuietHoursResult,
type SetDigestModeCommand,
type SetDigestModeResult,
} from './NotificationPreferencesUseCases'; } from './NotificationPreferencesUseCases';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { NotificationPreference , ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference'; import type { NotificationPreference , ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
import type { Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError'; import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
@@ -19,38 +31,46 @@ describe('NotificationPreferencesUseCases', () => {
}; };
let logger: Logger; let logger: Logger;
beforeEach(() => {
preferenceRepository = { beforeEach(() => {
getOrCreateDefault: vi.fn(), preferenceRepository = {
save: vi.fn(), getOrCreateDefault: vi.fn(),
} as unknown as INotificationPreferenceRepository as any; save: vi.fn(),
} as unknown as INotificationPreferenceRepository as any;
logger = {
debug: vi.fn(), logger = {
info: vi.fn(), debug: vi.fn(),
warn: vi.fn(), info: vi.fn(),
error: vi.fn(), warn: vi.fn(),
} as unknown as Logger; error: vi.fn(),
}); } as unknown as Logger;
});
it('GetNotificationPreferencesQuery returns preferences from repository', async () => {
const preference = { it('GetNotificationPreferencesQuery returns preferences from repository', async () => {
id: 'pref-1', const preference = {
} as unknown as NotificationPreference; id: 'pref-1',
} as unknown as NotificationPreference;
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const useCase = new GetNotificationPreferencesQuery(
preferenceRepository as unknown as INotificationPreferenceRepository, const output: UseCaseOutputPort<GetNotificationPreferencesResult> & { present: Mock } = {
logger, present: vi.fn(),
); } as any;
const result = await useCase.execute('driver-1'); const useCase = new GetNotificationPreferencesQuery(
preferenceRepository as unknown as INotificationPreferenceRepository,
expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1'); output,
expect(result).toBe(preference); logger,
}); );
const input: GetNotificationPreferencesInput = { driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
expect(result).toBeInstanceOf(Result);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({ preference });
});
it('UpdateChannelPreferenceUseCase updates channel preference', async () => { it('UpdateChannelPreferenceUseCase updates channel preference', async () => {
const preference = { const preference = {
updateChannel: vi.fn().mockReturnThis(), updateChannel: vi.fn().mockReturnThis(),
@@ -58,19 +78,28 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<UpdateChannelPreferenceResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateChannelPreferenceUseCase( const useCase = new UpdateChannelPreferenceUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository, preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger, logger,
); );
await useCase.execute({ const command: UpdateChannelPreferenceCommand = {
driverId: 'driver-1', driverId: 'driver-1',
channel: 'email' as NotificationChannel, channel: 'email' as NotificationChannel,
preference: 'enabled' as ChannelPreference, preference: { enabled: true } as ChannelPreference,
}); };
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(preference.updateChannel).toHaveBeenCalled(); expect(preference.updateChannel).toHaveBeenCalled();
expect(preferenceRepository.save).toHaveBeenCalledWith(preference); expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', channel: 'email' });
}); });
it('UpdateTypePreferenceUseCase updates type preference', async () => { it('UpdateTypePreferenceUseCase updates type preference', async () => {
@@ -80,19 +109,28 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<UpdateTypePreferenceResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateTypePreferenceUseCase( const useCase = new UpdateTypePreferenceUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository, preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger, logger,
); );
await useCase.execute({ const command: UpdateTypePreferenceCommand = {
driverId: 'driver-1', driverId: 'driver-1',
type: 'info' as NotificationType, type: 'system_announcement' as NotificationType,
preference: 'enabled' as TypePreference, preference: { enabled: true } as TypePreference,
}); };
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(preference.updateTypePreference).toHaveBeenCalled(); expect(preference.updateTypePreference).toHaveBeenCalled();
expect(preferenceRepository.save).toHaveBeenCalledWith(preference); expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', type: 'system_announcement' });
}); });
it('UpdateQuietHoursUseCase validates hours and updates preferences', async () => { it('UpdateQuietHoursUseCase validates hours and updates preferences', async () => {
@@ -102,34 +140,56 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateQuietHoursUseCase( const useCase = new UpdateQuietHoursUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository, preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger, logger,
); );
await useCase.execute({ const command: UpdateQuietHoursCommand = {
driverId: 'driver-1',
startHour: 22,
endHour: 7,
};
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7);
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
expect(output.present).toHaveBeenCalledWith({
driverId: 'driver-1', driverId: 'driver-1',
startHour: 22, startHour: 22,
endHour: 7, endHour: 7,
}); });
expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7);
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
}); });
it('UpdateQuietHoursUseCase throws on invalid hours', async () => { it('UpdateQuietHoursUseCase returns error on invalid hours', async () => {
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateQuietHoursUseCase( const useCase = new UpdateQuietHoursUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository, preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger, logger,
); );
await expect( const badStart: UpdateQuietHoursCommand = { driverId: 'd1', startHour: -1, endHour: 10 };
useCase.execute({ driverId: 'd1', startHour: -1, endHour: 10 }), const result1 = await useCase.execute(badStart);
).rejects.toThrow(NotificationDomainError); expect(result1.isErr()).toBe(true);
const err1 = result1.unwrapErr() as ApplicationErrorCode<'INVALID_START_HOUR', { message: string }>;
expect(err1.code).toBe('INVALID_START_HOUR');
await expect( const badEnd: UpdateQuietHoursCommand = { driverId: 'd1', startHour: 10, endHour: 24 };
useCase.execute({ driverId: 'd1', startHour: 10, endHour: 24 }), const result2 = await useCase.execute(badEnd);
).rejects.toThrow(NotificationDomainError); expect(result2.isErr()).toBe(true);
const err2 = result2.unwrapErr() as ApplicationErrorCode<'INVALID_END_HOUR', { message: string }>;
expect(err2.code).toBe('INVALID_END_HOUR');
}); });
it('SetDigestModeUseCase sets digest mode with valid frequency', async () => { it('SetDigestModeUseCase sets digest mode with valid frequency', async () => {
@@ -139,27 +199,52 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference); preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new SetDigestModeUseCase( const useCase = new SetDigestModeUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository, preferenceRepository as unknown as INotificationPreferenceRepository,
output,
); );
await useCase.execute({ const command: SetDigestModeCommand = {
driverId: 'driver-1',
enabled: true,
frequencyHours: 4,
};
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4);
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
expect(output.present).toHaveBeenCalledWith({
driverId: 'driver-1', driverId: 'driver-1',
enabled: true, enabled: true,
frequencyHours: 4, frequencyHours: 4,
}); });
expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4);
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
}); });
it('SetDigestModeUseCase throws on invalid frequency', async () => { it('SetDigestModeUseCase returns error on invalid frequency', async () => {
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new SetDigestModeUseCase( const useCase = new SetDigestModeUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository, preferenceRepository as unknown as INotificationPreferenceRepository,
output,
); );
await expect( const command: SetDigestModeCommand = {
useCase.execute({ driverId: 'driver-1', enabled: true, frequencyHours: 0 }), driverId: 'driver-1',
).rejects.toThrow(NotificationDomainError); enabled: true,
frequencyHours: 0,
};
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<'INVALID_FREQUENCY', { message: string }>;
expect(err.code).toBe('INVALID_FREQUENCY');
}); });
}); });

View File

@@ -4,7 +4,9 @@
* Manages user notification preferences. * Manages user notification preferences.
*/ */
import type { AsyncUseCase , Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { NotificationPreference } from '../../domain/entities/NotificationPreference'; import { NotificationPreference } from '../../domain/entities/NotificationPreference';
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';
@@ -14,21 +16,40 @@ import { NotificationDomainError } from '../../domain/errors/NotificationDomainE
/** /**
* Query: GetNotificationPreferencesQuery * Query: GetNotificationPreferencesQuery
*/ */
export class GetNotificationPreferencesQuery implements AsyncUseCase<string, NotificationPreference> { export interface GetNotificationPreferencesInput {
driverId: string;
}
export interface GetNotificationPreferencesResult {
preference: NotificationPreference;
}
export type GetNotificationPreferencesErrorCode = 'REPOSITORY_ERROR';
export class GetNotificationPreferencesQuery {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<GetNotificationPreferencesResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(driverId: string): Promise<NotificationPreference> { async execute(
input: GetNotificationPreferencesInput,
): Promise<Result<void, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>> {
const { driverId } = input;
this.logger.debug(`Fetching notification preferences for driver: ${driverId}`); this.logger.debug(`Fetching notification preferences for driver: ${driverId}`);
try { try {
const preferences = await this.preferenceRepository.getOrCreateDefault(driverId); const preferences = await this.preferenceRepository.getOrCreateDefault(driverId);
this.logger.info(`Successfully fetched preferences for driver: ${driverId}`); this.logger.info(`Successfully fetched preferences for driver: ${driverId}`);
return preferences; this.output.present({ preference: preferences });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, error); const err = error instanceof Error ? error : new Error(String(error));
throw error; this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, err);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
} }
} }
} }
@@ -42,22 +63,48 @@ export interface UpdateChannelPreferenceCommand {
preference: ChannelPreference; preference: ChannelPreference;
} }
export class UpdateChannelPreferenceUseCase implements AsyncUseCase<UpdateChannelPreferenceCommand, void> { export interface UpdateChannelPreferenceResult {
driverId: string;
channel: NotificationChannel;
}
export type UpdateChannelPreferenceErrorCode =
| 'REPOSITORY_ERROR';
export class UpdateChannelPreferenceUseCase {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<UpdateChannelPreferenceResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(command: UpdateChannelPreferenceCommand): Promise<void> { async execute(
this.logger.debug(`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${command.preference}`); command: UpdateChannelPreferenceCommand,
): Promise<Result<void, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>> {
this.logger.debug(
`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${JSON.stringify(command.preference)}`,
);
try { try {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); const preferences = await this.preferenceRepository.getOrCreateDefault(
command.driverId,
);
const updated = preferences.updateChannel(command.channel, command.preference); const updated = preferences.updateChannel(command.channel, command.preference);
await this.preferenceRepository.save(updated); await this.preferenceRepository.save(updated);
this.logger.info(`Successfully updated channel preference for driver: ${command.driverId}`); this.logger.info(
`Successfully updated channel preference for driver: ${command.driverId}`,
);
this.output.present({ driverId: command.driverId, channel: command.channel });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error(`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`, error); const err = error instanceof Error ? error : new Error(String(error));
throw error; this.logger.error(
`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`,
err,
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
} }
} }
} }
@@ -71,22 +118,47 @@ export interface UpdateTypePreferenceCommand {
preference: TypePreference; preference: TypePreference;
} }
export class UpdateTypePreferenceUseCase implements AsyncUseCase<UpdateTypePreferenceCommand, void> { export interface UpdateTypePreferenceResult {
driverId: string;
type: NotificationType;
}
export type UpdateTypePreferenceErrorCode = 'REPOSITORY_ERROR';
export class UpdateTypePreferenceUseCase {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<UpdateTypePreferenceResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(command: UpdateTypePreferenceCommand): Promise<void> { async execute(
this.logger.debug(`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${command.preference}`); command: UpdateTypePreferenceCommand,
): Promise<Result<void, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>> {
this.logger.debug(
`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${JSON.stringify(command.preference)}`,
);
try { try {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); const preferences = await this.preferenceRepository.getOrCreateDefault(
command.driverId,
);
const updated = preferences.updateTypePreference(command.type, command.preference); const updated = preferences.updateTypePreference(command.type, command.preference);
await this.preferenceRepository.save(updated); await this.preferenceRepository.save(updated);
this.logger.info(`Successfully updated type preference for driver: ${command.driverId}`); this.logger.info(
`Successfully updated type preference for driver: ${command.driverId}`,
);
this.output.present({ driverId: command.driverId, type: command.type });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error(`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`, error); const err = error instanceof Error ? error : new Error(String(error));
throw error; this.logger.error(
`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`,
err,
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
} }
} }
} }
@@ -100,32 +172,73 @@ export interface UpdateQuietHoursCommand {
endHour: number | undefined; endHour: number | undefined;
} }
export class UpdateQuietHoursUseCase implements AsyncUseCase<UpdateQuietHoursCommand, void> { export interface UpdateQuietHoursResult {
driverId: string;
startHour: number | undefined;
endHour: number | undefined;
}
export type UpdateQuietHoursErrorCode =
| 'INVALID_START_HOUR'
| 'INVALID_END_HOUR'
| 'REPOSITORY_ERROR';
export class UpdateQuietHoursUseCase {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<UpdateQuietHoursResult>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(command: UpdateQuietHoursCommand): Promise<void> { async execute(
this.logger.debug(`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`); command: UpdateQuietHoursCommand,
): Promise<Result<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>> {
this.logger.debug(
`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`,
);
try { try {
// Validate hours if provided // Validate hours if provided
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) { if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
this.logger.warn(`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`); this.logger.warn(
throw new NotificationDomainError('Start hour must be between 0 and 23'); `Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`,
);
return Result.err({
code: 'INVALID_START_HOUR',
details: { message: 'Start hour must be between 0 and 23' },
});
} }
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) { if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
this.logger.warn(`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`); this.logger.warn(
throw new NotificationDomainError('End hour must be between 0 and 23'); `Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`,
);
return Result.err({
code: 'INVALID_END_HOUR',
details: { message: 'End hour must be between 0 and 23' },
});
} }
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); const preferences = await this.preferenceRepository.getOrCreateDefault(
const updated = preferences.updateQuietHours(command.startHour, command.endHour); command.driverId,
);
const updated = preferences.updateQuietHours(
command.startHour,
command.endHour,
);
await this.preferenceRepository.save(updated); await this.preferenceRepository.save(updated);
this.logger.info(`Successfully updated quiet hours for driver: ${command.driverId}`); this.logger.info(`Successfully updated quiet hours for driver: ${command.driverId}`);
this.output.present({
driverId: command.driverId,
startHour: command.startHour,
endHour: command.endHour,
});
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, error); const err = error instanceof Error ? error : new Error(String(error));
throw error; this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, err);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
} }
} }
} }
@@ -139,18 +252,53 @@ export interface SetDigestModeCommand {
frequencyHours?: number; frequencyHours?: number;
} }
export class SetDigestModeUseCase implements AsyncUseCase<SetDigestModeCommand, void> { export interface SetDigestModeResult {
driverId: string;
enabled: boolean;
frequencyHours?: number;
}
export type SetDigestModeErrorCode =
| 'INVALID_FREQUENCY'
| 'REPOSITORY_ERROR';
export class SetDigestModeUseCase {
constructor( constructor(
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<SetDigestModeResult>,
) {} ) {}
async execute(command: SetDigestModeCommand): Promise<void> { async execute(
command: SetDigestModeCommand,
): Promise<Result<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>> {
if (command.frequencyHours !== undefined && command.frequencyHours < 1) { if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
throw new NotificationDomainError('Digest frequency must be at least 1 hour'); return Result.err({
code: 'INVALID_FREQUENCY',
details: { message: 'Digest frequency must be at least 1 hour' },
});
} }
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId); try {
const updated = preferences.setDigestMode(command.enabled, command.frequencyHours); const preferences = await this.preferenceRepository.getOrCreateDefault(
await this.preferenceRepository.save(updated); command.driverId,
);
const updated = preferences.setDigestMode(
command.enabled,
command.frequencyHours,
);
await this.preferenceRepository.save(updated);
this.output.present({
driverId: command.driverId,
enabled: command.enabled,
frequencyHours: command.frequencyHours,
});
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
} }
} }

View File

@@ -5,7 +5,9 @@
* based on their preferences. * based on their preferences.
*/ */
import type { AsyncUseCase, Logger } from '@core/shared/application'; import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { NotificationData } from '../../domain/entities/Notification'; import type { NotificationData } from '../../domain/entities/Notification';
import { Notification } from '../../domain/entities/Notification'; import { Notification } from '../../domain/entities/Notification';
@@ -43,17 +45,22 @@ export interface SendNotificationResult {
deliveryResults: NotificationDeliveryResult[]; deliveryResults: NotificationDeliveryResult[];
} }
export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCommand, SendNotificationResult> { export type SendNotificationErrorCode = 'REPOSITORY_ERROR';
export class SendNotificationUseCase {
constructor( constructor(
private readonly notificationRepository: INotificationRepository, private readonly notificationRepository: INotificationRepository,
private readonly preferenceRepository: INotificationPreferenceRepository, private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly gatewayRegistry: NotificationGatewayRegistry, private readonly gatewayRegistry: NotificationGatewayRegistry,
private readonly output: UseCaseOutputPort<SendNotificationResult>,
private readonly logger: Logger, private readonly logger: Logger,
) { ) {
this.logger.debug('SendNotificationUseCase initialized.'); this.logger.debug('SendNotificationUseCase initialized.');
} }
async execute(command: SendNotificationCommand): Promise<SendNotificationResult> { async execute(
command: SendNotificationCommand,
): Promise<Result<void, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>> {
this.logger.debug('Executing SendNotificationUseCase', { command }); this.logger.debug('Executing SendNotificationUseCase', { command });
try { try {
// Get recipient's preferences // Get recipient's preferences
@@ -84,7 +91,8 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
} }
// Determine which channels to use // Determine which channels to use
const channels = command.forceChannels ?? preferences.getEnabledChannelsForType(command.type); const channels =
command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
// Check quiet hours (skip external channels during quiet hours) // Check quiet hours (skip external channels during quiet hours)
const effectiveChannels = preferences.isInQuietHours() const effectiveChannels = preferences.isInQuietHours()
@@ -133,13 +141,19 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
} }
} }
return { this.output.present({
notification: primaryNotification!, notification: primaryNotification!,
deliveryResults, deliveryResults,
}; });
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error('Error sending notification', error as Error); const err = error instanceof Error ? error : new Error(String(error));
throw error; this.logger.error('Error sending notification', err);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
} }
} }
} }

View File

@@ -1,18 +1,20 @@
import { v4 as uuidv4 } from 'uuid';
import { Season } from '../../domain/entities/Season'; import { Season } from '../../domain/entities/Season';
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 { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { Weekday } from '../../domain/types/Weekday';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
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 { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig'; import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet'; import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern'; import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
import type { Weekday } from '../../domain/types/Weekday';
import { v4 as uuidv4 } from 'uuid'; // TODO The whole file mixes a lot of concerns...
export interface CreateSeasonForLeagueCommand { export interface CreateSeasonForLeagueCommand {
leagueId: string; leagueId: string;

View File

@@ -1,9 +1,15 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CompleteDriverOnboardingUseCase, type CompleteDriverOnboardingResult } from './CompleteDriverOnboardingUseCase'; import {
CompleteDriverOnboardingUseCase,
type CompleteDriverOnboardingInput,
type CompleteDriverOnboardingResult,
type CompleteDriverOnboardingApplicationError,
} 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 { CompleteDriverOnboardingInput } from './CompleteDriverOnboardingUseCase';
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 { Result } from '@core/shared/application/Result';
describe('CompleteDriverOnboardingUseCase', () => { describe('CompleteDriverOnboardingUseCase', () => {
let useCase: CompleteDriverOnboardingUseCase; let useCase: CompleteDriverOnboardingUseCase;
@@ -11,17 +17,25 @@ describe('CompleteDriverOnboardingUseCase', () => {
findById: Mock; findById: Mock;
create: Mock; create: Mock;
}; };
let output: { present: Mock }; let logger: Logger & { error: Mock };
let output: { present: Mock } & UseCaseOutputPort<CompleteDriverOnboardingResult>;
beforeEach(() => { beforeEach(() => {
driverRepository = { driverRepository = {
findById: vi.fn(), findById: vi.fn(),
create: vi.fn(), create: vi.fn(),
}; };
output = { present: vi.fn() }; logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger & { error: Mock };
output = { present: vi.fn() } as unknown as typeof output;
useCase = new CompleteDriverOnboardingUseCase( useCase = new CompleteDriverOnboardingUseCase(
driverRepository as unknown as IDriverRepository, driverRepository as unknown as IDriverRepository,
output as unknown as UseCaseOutputPort<CompleteDriverOnboardingResult>, logger,
output,
); );
}); });

View File

@@ -3,6 +3,7 @@ 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 { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Logger } from '@core/shared/application/Logger';
export interface CompleteDriverOnboardingInput { export interface CompleteDriverOnboardingInput {
userId: string; userId: string;
@@ -17,28 +18,43 @@ export type CompleteDriverOnboardingResult = {
driver: Driver; driver: Driver;
}; };
export type CompleteDriverOnboardingErrorCode =
| 'DRIVER_ALREADY_EXISTS'
| 'REPOSITORY_ERROR';
export type CompleteDriverOnboardingApplicationError = ApplicationErrorCode<
CompleteDriverOnboardingErrorCode,
{ message: string }
>;
/** /**
* Use Case for completing driver onboarding. * Use Case for completing driver onboarding.
*/ */
export class CompleteDriverOnboardingUseCase { export class CompleteDriverOnboardingUseCase {
constructor( constructor(
private readonly driverRepository: IDriverRepository, private readonly driverRepository: IDriverRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<CompleteDriverOnboardingResult>, private readonly output: UseCaseOutputPort<CompleteDriverOnboardingResult>,
) {} ) {}
async execute(command: CompleteDriverOnboardingInput): Promise<Result<void, ApplicationErrorCode<'DRIVER_ALREADY_EXISTS' | 'REPOSITORY_ERROR'>>> { async execute(
input: CompleteDriverOnboardingInput,
): Promise<Result<void, CompleteDriverOnboardingApplicationError>> {
try { try {
const existing = await this.driverRepository.findById(command.userId); const existing = await this.driverRepository.findById(input.userId);
if (existing) { if (existing) {
return Result.err({ code: 'DRIVER_ALREADY_EXISTS' }); return Result.err({
code: 'DRIVER_ALREADY_EXISTS',
details: { message: 'Driver already exists' },
});
} }
const driver = Driver.create({ const driver = Driver.create({
id: command.userId, id: input.userId,
iracingId: command.userId, iracingId: input.userId,
name: command.displayName, name: input.displayName,
country: command.country, country: input.country,
...(command.bio !== undefined ? { bio: command.bio } : {}), ...(input.bio !== undefined ? { bio: input.bio } : {}),
}); });
await this.driverRepository.create(driver); await this.driverRepository.create(driver);
@@ -47,10 +63,16 @@ export class CompleteDriverOnboardingUseCase {
return Result.ok(undefined); return Result.ok(undefined);
} catch (error) { } catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
this.logger.error('CompleteDriverOnboardingUseCase.execute failed', err, {
input,
});
return Result.err({ return Result.err({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',
details: { details: {
message: error instanceof Error ? error.message : 'Unknown error', message: err.message ?? 'Unexpected repository error',
}, },
}); });
} }

View File

@@ -0,0 +1,264 @@
Domain Objects Design Guide (Clean Architecture)
This document defines all domain object types used in the Core and assigns strict responsibilities and boundaries.
Its goal is to remove ambiguity between:
• Entities
• Value Objects
• Aggregate Roots
• Domain Services
• Domain Events
The rules in this document are non-negotiable.
Core Principle
Domain objects represent business truth.
They:
• outlive APIs and UIs
• must remain stable over time
• must not depend on technical details
If a class answers a business question, it belongs here.
1. Entities
Definition
An Entity is a domain object that:
• has a stable identity
• changes over time
• represents a business concept
Identity matters more than attributes.
Responsibilities
Entities MUST:
• own their identity
• enforce invariants on state changes
• expose behavior, not setters
Entities MUST NOT:
• depend on DTOs or transport models
• access repositories or services
• perform IO
• know about frameworks
Creation Rules
• New entities are created via create()
• Existing entities are reconstructed via rehydrate()
core/<context>/domain/entities/
Example
• League
• Season
• Race
• Driver
2. Value Objects
Definition
A Value Object is a domain object that:
• has no identity
• is immutable
• is defined by its value
Responsibilities
Value Objects MUST:
• validate their own invariants
• be immutable
• be comparable by value
Value Objects MUST NOT:
• contain business workflows
• reference entities
• perform IO
Creation Rules
• create() for new domain meaning
• fromX() for interpreting external formats
core/<context>/domain/value-objects/
Example
• Money
• LeagueName
• RaceTimeOfDay
• SeasonSchedule
3. Aggregate Roots
Definition
An Aggregate Root is an entity that:
• acts as the consistency boundary
• protects invariants across related entities
All access to the aggregate happens through the root.
Responsibilities
Aggregate Roots MUST:
• enforce consistency rules
• control modifications of child entities
Aggregate Roots MUST NOT:
• expose internal collections directly
• allow partial updates bypassing rules
Example
• League (root)
• Season (root)
4. Domain Services
Definition
A Domain Service encapsulates domain logic that:
• does not naturally belong to a single entity
• involves multiple domain objects
Responsibilities
Domain Services MAY:
• coordinate entities
• calculate derived domain values
Domain Services MUST:
• operate only on domain types
• remain stateless
Domain Services MUST NOT:
• access repositories
• orchestrate use cases
• perform IO
core/<context>/domain/services/
Example
• SeasonConfigurationFactory
• ChampionshipAggregator
• StrengthOfFieldCalculator
5. Domain Events
Definition
A Domain Event represents something that:
• has already happened
• is important to the business
Responsibilities
Domain Events MUST:
• be immutable
• carry minimal information
Domain Events MUST NOT:
• contain behavior
• perform side effects
core/<context>/domain/events/
Example
• RaceCompleted
• SeasonActivated
6. What Does NOT Belong in Domain Objects
❌ DTOs
❌ API Models
❌ View Models
❌ Repositories
❌ Framework Types
❌ Logging
❌ Configuration
If it depends on infrastructure, it does not belong here.
7. Dependency Rules
Entities → Value Objects
Entities → Domain Services
Domain Services → Entities
Reverse dependencies are forbidden.
8. Testing Requirements
Domain Objects MUST:
• have unit tests for invariants
• be tested without mocks
Domain Services MUST:
• have deterministic tests
Mental Model (Final)
Entities protect state.
Value Objects protect meaning.
Aggregate Roots protect consistency.
Domain Services protect cross-entity rules.
Domain Events describe facts.
Final Summary
• Domain objects represent business truth
• They are pure and framework-free
• They form the most stable part of the system
If domain objects are clean, everything else becomes easier.

View File

@@ -0,0 +1,252 @@
Services Design Guide (Clean Architecture)
This document defines all service types used across the system and assigns clear, non-overlapping responsibilities.
It exists to remove ambiguity around the word “service”, which is heavily overloaded.
The rules below are strict.
Overview
The system contains four distinct service categories, each in a different layer:
1. Frontend Services
2. API Services
3. Core Application Services
4. Core Domain Services
They must never be mixed.
1. Frontend Services
Purpose
Frontend services orchestrate UI-driven workflows.
They answer the question:
“How does the UI obtain and submit data?”
Responsibilities
Frontend services MAY:
• call API clients
• apply client-side guards (blockers, throttles)
• create View Models
• orchestrate multiple API calls
• handle recoverable UI errors
Frontend services MUST NOT:
• contain business rules
• validate domain invariants
• modify domain state
• know about core domain objects
Placement
apps/website/lib/services/
Example
• LeagueService
• RaceService
• AuthService
Each service is UI-facing, not business-facing.
2. API Services (Application Services)
Purpose
API services adapt HTTP-level concerns to core use cases.
They answer the question:
“How does an external client interact with the core?”
Responsibilities
API services MAY:
• orchestrate multiple use cases
• perform authorization checks
• map transport input to use-case input
• coordinate transactions
API services MUST NOT:
• contain domain logic
• enforce business invariants
• build domain entities
• return domain objects
Placement
apps/api/**/ApplicationService.ts
Example
• LeagueApplicationService
• SeasonApplicationService
API services are delivery-layer coordinators.
3. Core Application Services (Use Cases)
Purpose
Core application services implement business use cases.
They answer the question:
“What does the system do?”
Responsibilities
Use Cases MUST:
• accept primitive input only
• create Value Objects
• create or modify Entities
• enforce business rules
• call repositories via ports
• communicate results via output ports
Use Cases MUST NOT:
• know about HTTP, UI, or frameworks
• return DTOs
• perform persistence directly
Placement
core/<context>/application/commands/
core/<context>/application/queries/
Example
• CreateLeagueUseCase
• ApplyPenaltyUseCase
• GetLeagueStandingsQuery
Use Cases define system behavior.
4. Core Domain Services
Purpose
Domain services encapsulate domain logic that does not belong to a single entity.
They answer the question:
“What rules exist that span multiple domain objects?”
Responsibilities
Domain services MAY:
• coordinate multiple entities
• compute derived domain values
• enforce cross-aggregate rules
Domain services MUST:
• use only domain concepts
• return domain objects or primitives
Domain services MUST NOT:
• access repositories
• depend on application services
• perform IO
Placement
core/<context>/domain/services/
Example
• SeasonConfigurationFactory
• ChampionshipAggregator
• StrengthOfFieldCalculator
Domain services protect business integrity.
Dependency Rules (Non-Negotiable)
Frontend Service
→ API Client
→ API Service
→ Core Use Case
→ Domain Service / Entity
Reverse dependencies are forbidden.
Anti-Patterns (Forbidden)
❌ Frontend calling core directly
❌ API service constructing domain entities
❌ Use case returning DTOs
❌ Domain service accessing repositories
❌ Single class acting as multiple service types
Naming Conventions
Layer Naming
Frontend *Service
API *ApplicationService
Core Application *UseCase, *Query
Core Domain *Service, *Factory, *Calculator
Mental Model (Final)
Services coordinate.
Use Cases decide.
Domain enforces truth.
Adapters translate.
If a class violates this mental model, it is in the wrong layer.
Final Summary
• “Service” means different things in different layers
• Mixing service types causes architectural decay
• Clean Architecture remains simple when roles stay pure
This document defines the only allowed service roles in the system.