refactor use cases
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user