refactor use cases

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

View File

@@ -1,6 +1,23 @@
import { User } from '../../domain/entities/User';
import { 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
@@ -8,13 +25,45 @@ import { IUserRepository } from '../../domain/repositories/IUserRepository';
* Retrieves the current user session information.
*/
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> {
const stored = await this.userRepo.findById(userId);
if (!stored) {
return null;
async execute(input: GetCurrentSessionInput): Promise<
Result<void, GetCurrentSessionApplicationError>
> {
try {
const stored = await this.userRepo.findById(input.userId);
if (!stored) {
return Result.err({
code: 'USER_NOT_FOUND',
details: { message: 'User not found' },
} as GetCurrentSessionApplicationError);
}
const user = User.fromStored(stored);
const result: GetCurrentSessionResult = { user };
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to execute GetCurrentSessionUseCase';
this.logger.error(
'GetCurrentSessionUseCase.execute failed',
error instanceof Error ? error : undefined,
{ input },
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
} as GetCurrentSessionApplicationError);
}
return User.fromStored(stored);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,56 +1,112 @@
/**
* Login with Email Use Case
*
*
* Authenticates a user with email and password.
*/
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 { 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;
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 {
constructor(
private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LoginWithEmailResult>,
) {}
async execute(command: LoginCommandDTO): Promise<AuthSessionDTO> {
// Validate inputs
if (!command.email || !command.password) {
throw new Error('Email and password are required');
async execute(input: LoginWithEmailInput): Promise<Result<void, LoginWithEmailApplicationError>> {
try {
if (!input.email || !input.password) {
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> {

View File

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

View File

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

View File

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

View File

@@ -1,84 +1,145 @@
/**
* Signup with Email Use Case
*
*
* Creates a new user account with email and password.
*/
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
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;
password: string;
displayName: string;
}
};
export interface SignupResultDTO {
session: AuthSessionDTO;
export type SignupWithEmailResult = {
sessionToken: string;
userId: string;
displayName: string;
email: string;
createdAt: Date;
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 {
constructor(
private readonly userRepository: IUserRepository,
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
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(command.email)) {
throw new Error('Invalid email format');
if (!emailRegex.test(input.email)) {
return Result.err({
code: 'INVALID_EMAIL_FORMAT',
details: { message: 'Invalid email format' },
} as SignupWithEmailApplicationError);
}
// Validate password strength
if (command.password.length < 8) {
throw new Error('Password must be at least 8 characters');
if (input.password.length < 8) {
return Result.err({
code: 'WEAK_PASSWORD',
details: { message: 'Password must be at least 8 characters' },
} as SignupWithEmailApplicationError);
}
// Validate display name
if (!command.displayName || command.displayName.trim().length < 2) {
throw new Error('Display name must be at least 2 characters');
if (!input.displayName || input.displayName.trim().length < 2) {
return Result.err({
code: 'INVALID_DISPLAY_NAME',
details: { message: 'Display name must be at least 2 characters' },
} as SignupWithEmailApplicationError);
}
// Check if email already exists
const existingUser = await this.userRepository.findByEmail(command.email);
const existingUser = await this.userRepository.findByEmail(input.email);
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)
const salt = this.generateSalt();
const passwordHash = await this.hashPassword(command.password, salt);
try {
// Hash password (simple hash for demo - in production use bcrypt)
const salt = this.generateSalt();
const passwordHash = await this.hashPassword(input.password, salt);
// Create user
const userId = this.generateUserId();
const newUser: StoredUser = {
id: userId,
email: command.email.toLowerCase().trim(),
displayName: command.displayName.trim(),
passwordHash,
salt,
createdAt: new Date(),
};
// Create user
const userId = this.generateUserId();
const createdAt = new Date();
const newUser: StoredUser = {
id: userId,
email: input.email.toLowerCase().trim(),
displayName: input.displayName.trim(),
passwordHash,
salt,
createdAt,
};
await this.userRepository.create(newUser);
await this.userRepository.create(newUser);
// Create session
const authenticatedUser: AuthenticatedUserDTO = {
id: newUser.id,
displayName: newUser.displayName,
email: newUser.email,
};
// Create session
const authenticatedUser: AuthenticatedUserDTO = {
id: newUser.id,
displayName: newUser.displayName,
email: newUser.email,
};
const session = await this.sessionPort.createSession(authenticatedUser);
const session = await this.sessionPort.createSession(authenticatedUser);
return {
session,
isNewUser: true,
};
const result: SignupWithEmailResult = {
sessionToken: session.token,
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 {

View File

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

View File

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

View File

@@ -1,16 +1,60 @@
import { Achievement, AchievementProps } from '@core/identity/domain/entities/Achievement';
import { 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 {
save(achievement: Achievement): Promise<void>;
findById(id: string): Promise<Achievement | null>;
}
export class CreateAchievementUseCase {
constructor(private readonly achievementRepository: IAchievementRepository) {}
export type CreateAchievementInput = Omit<AchievementProps, 'createdAt'>;
async execute(props: Omit<AchievementProps, 'createdAt'>): Promise<Achievement> {
const achievement = Achievement.create(props);
await this.achievementRepository.save(achievement);
return achievement;
export type CreateAchievementResult = {
achievement: 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);
}
}
}