refactor use cases
This commit is contained in:
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
264
docs/architecture/DOMAIN_OBJECTS.md
Normal file
264
docs/architecture/DOMAIN_OBJECTS.md
Normal 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.
|
||||||
252
docs/architecture/SERVICES.md
Normal file
252
docs/architecture/SERVICES.md
Normal 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.
|
||||||
Reference in New Issue
Block a user