refactor use cases

This commit is contained in:
2026-01-08 15:34:51 +01:00
parent d984ab24a8
commit 52e9a2f6a7
362 changed files with 5192 additions and 8409 deletions

View File

@@ -1,22 +1,18 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import { describe, it, expect, vi, type Mock } from 'vitest';
import { ForgotPasswordUseCase } from './ForgotPasswordUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
type ForgotPasswordOutput = {
message: string;
magicLink?: string | null;
};
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
describe('ForgotPasswordUseCase', () => {
let authRepo: {
findByEmail: Mock;
save: Mock;
};
let magicLinkRepo: {
checkRateLimit: Mock;
@@ -26,218 +22,89 @@ describe('ForgotPasswordUseCase', () => {
sendMagicLink: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<ForgotPasswordOutput> & { present: Mock };
let useCase: ForgotPasswordUseCase;
beforeEach(() => {
authRepo = {
findByEmail: vi.fn(),
save: vi.fn(),
};
magicLinkRepo = {
checkRateLimit: vi.fn(),
createPasswordResetRequest: vi.fn(),
};
notificationPort = {
sendMagicLink: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new ForgotPasswordUseCase(
authRepo as unknown as IAuthRepository,
magicLinkRepo as unknown as IMagicLinkRepository,
notificationPort as any,
notificationPort as unknown as IMagicLinkNotificationPort,
logger,
output,
);
});
it('should create magic link for existing user', async () => {
const input = { email: 'test@example.com' };
it('generates and sends magic link when user exists', async () => {
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('hashed-password'),
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'test@example.com' });
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(magicLinkRepo.checkRateLimit).toHaveBeenCalledWith(input.email);
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
expect(output.present).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
const forgotPasswordResult = result.unwrap();
expect(forgotPasswordResult.message).toBe('Password reset link generated successfully');
expect(forgotPasswordResult.magicLink).toBeDefined();
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
expect(notificationPort.sendMagicLink).toHaveBeenCalled();
});
it('should return success for non-existent email (security)', async () => {
const input = { email: 'nonexistent@example.com' };
it('returns success even when user does not exist (for security)', async () => {
authRepo.findByEmail.mockResolvedValue(null);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'nonexistent@example.com' });
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
expect(output.present).toHaveBeenCalledWith({
message: 'If an account exists with this email, a password reset link will be sent',
magicLink: null,
});
expect(result.isOk()).toBe(true);
const forgotPasswordResult = result.unwrap();
expect(forgotPasswordResult.message).toBe('If an account exists with this email, a password reset link will be sent');
expect(forgotPasswordResult.magicLink).toBeNull();
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
expect(notificationPort.sendMagicLink).not.toHaveBeenCalled();
});
it('should handle rate limiting', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
it('returns error when rate limit exceeded', async () => {
magicLinkRepo.checkRateLimit.mockResolvedValue(
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limited' } })
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limit exceeded' } })
);
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'test@example.com' });
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('RATE_LIMIT_EXCEEDED');
expect(result.unwrapErr().code).toBe('RATE_LIMIT_EXCEEDED');
});
it('should validate email format', async () => {
const input = { email: 'invalid-email' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
});
it('should generate secure tokens', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
let capturedToken: string | undefined;
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
capturedToken = data.token;
return Promise.resolve();
});
await useCase.execute(input);
expect(capturedToken).toMatch(/^[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
});
it('should set correct expiration time (15 minutes)', async () => {
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const beforeCreate = Date.now();
let capturedExpiresAt: Date | undefined;
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
capturedExpiresAt = data.expiresAt;
return Promise.resolve();
});
await useCase.execute(input);
const afterCreate = Date.now();
expect(capturedExpiresAt).toBeDefined();
const timeDiff = capturedExpiresAt!.getTime() - afterCreate;
// Should be approximately 15 minutes (900000ms)
expect(timeDiff).toBeGreaterThan(890000);
expect(timeDiff).toBeLessThan(910000);
});
it('should return magic link in development mode', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
await useCase.execute(input);
expect(output.present).toHaveBeenCalledWith(
expect.objectContaining({
magicLink: expect.stringContaining('token='),
})
);
process.env.NODE_ENV = originalEnv ?? 'test';
});
it('should not return magic link in production mode', async () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
const input = { email: 'test@example.com' };
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: input.email,
});
authRepo.findByEmail.mockResolvedValue(user);
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
await useCase.execute(input);
expect(output.present).toHaveBeenCalledWith(
expect.objectContaining({
magicLink: null,
})
);
process.env.NODE_ENV = originalEnv ?? 'test';
});
it('should handle repository errors', async () => {
const input = { email: 'test@example.com' };
it('returns error when repository call fails', async () => {
authRepo.findByEmail.mockRejectedValue(new Error('Database error'));
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
const result = await useCase.execute(input);
const result = await useCase.execute({ email: 'test@example.com' });
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toContain('Database error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
});
});

View File

@@ -4,7 +4,7 @@ import { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkReposi
import { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
import { randomBytes } from 'crypto';
export type ForgotPasswordInput = {
@@ -27,16 +27,15 @@ export type ForgotPasswordApplicationError = ApplicationErrorCode<ForgotPassword
* In production, this would send an email with the magic link.
* In development, it returns the link for testing purposes.
*/
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void, ForgotPasswordErrorCode> {
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, ForgotPasswordResult, ForgotPasswordErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly magicLinkRepo: IMagicLinkRepository,
private readonly notificationPort: IMagicLinkNotificationPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<ForgotPasswordResult>,
) {}
async execute(input: ForgotPasswordInput): Promise<Result<void, ForgotPasswordApplicationError>> {
async execute(input: ForgotPasswordInput): Promise<Result<ForgotPasswordResult, ForgotPasswordApplicationError>> {
try {
// Validate email format
const emailVO = EmailAddress.create(input.email);
@@ -86,7 +85,7 @@ export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void,
expiresAt,
});
this.output.present({
return Result.ok({
message: 'Password reset link generated successfully',
magicLink: process.env.NODE_ENV === 'development' ? magicLink : null,
});
@@ -96,13 +95,11 @@ export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void,
email: input.email,
});
this.output.present({
return Result.ok({
message: 'If an account exists with this email, a password reset link will be sent',
magicLink: null,
});
}
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -2,11 +2,8 @@ import { vi, type Mock } from 'vitest';
import { GetCurrentSessionUseCase } from './GetCurrentSessionUseCase';
import { User } from '../../domain/entities/User';
import { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
type GetCurrentSessionOutput = {
user: User;
};
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('GetCurrentSessionUseCase', () => {
let useCase: GetCurrentSessionUseCase;
@@ -18,7 +15,6 @@ describe('GetCurrentSessionUseCase', () => {
emailExists: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<GetCurrentSessionOutput> & { present: Mock };
beforeEach(() => {
mockUserRepo = {
@@ -34,13 +30,9 @@ describe('GetCurrentSessionUseCase', () => {
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetCurrentSessionUseCase(
mockUserRepo as IUserRepository,
logger,
output,
);
});
@@ -60,11 +52,10 @@ describe('GetCurrentSessionUseCase', () => {
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalled();
const callArgs = output.present.mock.calls?.[0]?.[0];
expect(callArgs?.user).toBeInstanceOf(User);
expect(callArgs?.user.getId().value).toBe(userId);
expect(callArgs?.user.getDisplayName()).toBe('John Smith');
const sessionResult = result.unwrap();
expect(sessionResult.user).toBeInstanceOf(User);
expect(sessionResult.user.getId().value).toBe(userId);
expect(sessionResult.user.getDisplayName()).toBe('John Smith');
});
it('should return error when user does not exist', async () => {
@@ -75,5 +66,6 @@ describe('GetCurrentSessionUseCase', () => {
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('USER_NOT_FOUND');
});
});

View File

@@ -2,7 +2,7 @@ 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';
import type { Logger } from '@core/shared/application';
export type GetCurrentSessionInput = {
userId: string;
@@ -28,11 +28,10 @@ export class GetCurrentSessionUseCase {
constructor(
private readonly userRepo: IUserRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetCurrentSessionResult>,
) {}
async execute(input: GetCurrentSessionInput): Promise<
Result<void, GetCurrentSessionApplicationError>
Result<GetCurrentSessionResult, GetCurrentSessionApplicationError>
> {
try {
const stored = await this.userRepo.findById(input.userId);
@@ -45,9 +44,8 @@ export class GetCurrentSessionUseCase {
const user = User.fromStored(stored);
const result: GetCurrentSessionResult = { user };
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message =
error instanceof Error && error.message
@@ -66,4 +64,4 @@ export class GetCurrentSessionUseCase {
} as GetCurrentSessionApplicationError);
}
}
}
}

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetCurrentUserSessionUseCase } from './GetCurrentUserSessionUseCase';
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('GetCurrentUserSessionUseCase', () => {
let sessionPort: {
@@ -10,7 +11,6 @@ describe('GetCurrentUserSessionUseCase', () => {
clearSession: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<AuthSession | null> & { present: Mock };
let useCase: GetCurrentUserSessionUseCase;
beforeEach(() => {
@@ -27,14 +27,9 @@ describe('GetCurrentUserSessionUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetCurrentUserSessionUseCase(
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
});
@@ -57,7 +52,7 @@ describe('GetCurrentUserSessionUseCase', () => {
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith(session);
expect(result.unwrap()).toBe(session);
});
it('returns null when there is no active session', async () => {
@@ -67,6 +62,6 @@ describe('GetCurrentUserSessionUseCase', () => {
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith(null);
expect(result.unwrap()).toBe(null);
});
});

View File

@@ -1,7 +1,7 @@
import type { AuthSession, 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';
import type { Logger } from '@core/shared/application';
export type GetCurrentUserSessionInput = void;
@@ -18,16 +18,13 @@ export class GetCurrentUserSessionUseCase {
constructor(
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetCurrentUserSessionResult>,
) {}
async execute(): Promise<Result<void, GetCurrentUserSessionApplicationError>> {
async execute(): Promise<Result<GetCurrentUserSessionResult, GetCurrentUserSessionApplicationError>> {
try {
const session = await this.sessionPort.getCurrentSession();
this.output.present(session);
return Result.ok(undefined);
return Result.ok(session);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,22 +1,22 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetUserUseCase } from './GetUserUseCase';
import { User } from '../../domain/entities/User';
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
type GetUserOutput = Result<{ user: User }, unknown>;
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
describe('GetUserUseCase', () => {
let userRepository: {
let userRepo: {
findById: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<GetUserOutput> & { present: Mock };
let useCase: GetUserUseCase;
beforeEach(() => {
userRepository = {
userRepo = {
findById: vi.fn(),
};
@@ -27,48 +27,48 @@ describe('GetUserUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new GetUserUseCase(
userRepository as unknown as IUserRepository,
userRepo as unknown as IUserRepository,
logger,
output,
);
});
it('returns a User when the user exists', async () => {
const storedUser: StoredUser = {
it('returns user when found', async () => {
const storedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'John Smith',
passwordHash: 'hash',
primaryDriverId: 'driver-1',
passwordHash: 'hashed-password',
createdAt: new Date(),
};
userRepository.findById.mockResolvedValue(storedUser);
userRepo.findById.mockResolvedValue(storedUser);
const result = await useCase.execute({ userId: 'user-1' });
expect(userRepository.findById).toHaveBeenCalledWith('user-1');
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalled();
const callArgs = output.present.mock.calls?.[0]?.[0];
expect(callArgs).toBeInstanceOf(Result);
const user = (callArgs as GetUserOutput).unwrap().user;
expect(user).toBeInstanceOf(User);
expect(user.getId().value).toBe('user-1');
expect(user.getDisplayName()).toBe('John Smith');
const getUserResult = result.unwrap();
expect(getUserResult.user).toBeDefined();
expect(getUserResult.user.getId().value).toBe('user-1');
expect(getUserResult.user.getEmail()).toBe('test@example.com');
expect(userRepo.findById).toHaveBeenCalledWith('user-1');
});
it('returns error when the user does not exist', async () => {
userRepository.findById.mockResolvedValue(null);
it('returns error when user not found', async () => {
userRepo.findById.mockResolvedValue(null);
const result = await useCase.execute({ userId: 'missing-user' });
const result = await useCase.execute({ userId: 'nonexistent' });
expect(userRepository.findById).toHaveBeenCalledWith('missing-user');
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('USER_NOT_FOUND');
});
it('returns error on repository failure', async () => {
userRepo.findById.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute({ userId: 'user-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@ 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, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type GetUserInput = {
userId: string;
@@ -23,25 +23,20 @@ export class GetUserUseCase implements UseCase<GetUserInput, GetUserResult, GetU
constructor(
private readonly userRepo: IUserRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<Result<GetUserResult, GetUserApplicationError>>,
) {}
async execute(input: GetUserInput): Promise<Result<GetUserResult, GetUserApplicationError>> {
try {
const stored = await this.userRepo.findById(input.userId);
if (!stored) {
const result = Result.err<GetUserResult, GetUserApplicationError>({
return Result.err<GetUserResult, GetUserApplicationError>({
code: 'USER_NOT_FOUND',
details: { message: 'User not found' },
});
this.output.present(result);
return result;
}
const user = User.fromStored(stored);
const result = Result.ok<GetUserResult, GetUserApplicationError>({ user });
this.output.present(result);
return result;
return Result.ok<GetUserResult, GetUserApplicationError>({ user });
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : 'Failed to get user';
@@ -50,12 +45,10 @@ export class GetUserUseCase implements UseCase<GetUserInput, GetUserResult, GetU
input,
});
const result = Result.err<GetUserResult, GetUserApplicationError>({
return Result.err<GetUserResult, GetUserApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message },
});
this.output.present(result);
return result;
}
}
}

View File

@@ -1,12 +1,9 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { HandleAuthCallbackUseCase } from './HandleAuthCallbackUseCase';
import type {
AuthCallbackCommand,
AuthenticatedUser,
IdentityProviderPort,
} from '../ports/IdentityProviderPort';
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('HandleAuthCallbackUseCase', () => {
let provider: {
@@ -14,69 +11,97 @@ describe('HandleAuthCallbackUseCase', () => {
};
let sessionPort: {
createSession: Mock;
getCurrentSession: Mock;
clearSession: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<AuthSession> & { present: Mock };
let logger: Logger & { error: Mock };
let useCase: HandleAuthCallbackUseCase;
beforeEach(() => {
provider = {
completeAuth: vi.fn(),
};
sessionPort = {
createSession: vi.fn(),
getCurrentSession: vi.fn(),
clearSession: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
} as unknown as Logger & { error: Mock };
useCase = new HandleAuthCallbackUseCase(
provider as unknown as IdentityProviderPort,
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
});
it('completes auth and creates a session', async () => {
const command: AuthCallbackCommand = {
provider: 'IRACING_DEMO',
code: 'auth-code',
state: 'state-123',
returnTo: 'https://app/callback',
};
const user: AuthenticatedUser = {
it('successfully handles auth callback and creates session', async () => {
const authenticatedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
email: 'test@example.com',
};
const session: AuthSession = {
user,
const session = {
token: 'session-token',
user: authenticatedUser,
issuedAt: Date.now(),
expiresAt: Date.now() + 1000,
token: 'session-token',
};
provider.completeAuth.mockResolvedValue(user);
provider.completeAuth.mockResolvedValue(authenticatedUser);
sessionPort.createSession.mockResolvedValue(session);
const result = await useCase.execute(command);
const result = await useCase.execute({
code: 'auth-code',
state: 'state-123',
returnTo: '/dashboard',
});
expect(provider.completeAuth).toHaveBeenCalledWith(command);
expect(sessionPort.createSession).toHaveBeenCalledWith(user);
expect(output.present).toHaveBeenCalledWith(session);
expect(result.isOk()).toBe(true);
const callbackResult = result.unwrap();
expect(callbackResult.token).toBe('session-token');
expect(callbackResult.user).toBe(authenticatedUser);
expect(provider.completeAuth).toHaveBeenCalledWith({
code: 'auth-code',
state: 'state-123',
returnTo: '/dashboard',
});
expect(sessionPort.createSession).toHaveBeenCalledWith(authenticatedUser);
});
});
it('returns error when provider call fails', async () => {
provider.completeAuth.mockRejectedValue(new Error('Auth failed'));
const result = await useCase.execute({
code: 'invalid-code',
state: 'state-123',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
it('returns error when session creation fails', async () => {
const authenticatedUser = {
id: 'user-1',
displayName: 'Test User',
email: 'test@example.com',
};
provider.completeAuth.mockResolvedValue(authenticatedUser);
sessionPort.createSession.mockRejectedValue(new Error('Session creation failed'));
const result = await useCase.execute({
code: 'auth-code',
state: 'state-123',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@ import type { AuthCallbackCommand, AuthenticatedUser, IdentityProviderPort } fro
import type { AuthSession, 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';
import type { Logger } from '@core/shared/application';
export type HandleAuthCallbackInput = AuthCallbackCommand;
@@ -20,19 +20,16 @@ export class HandleAuthCallbackUseCase {
private readonly provider: IdentityProviderPort,
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<HandleAuthCallbackResult>,
) {}
async execute(input: HandleAuthCallbackInput): Promise<
Result<void, HandleAuthCallbackApplicationError>
Result<HandleAuthCallbackResult, HandleAuthCallbackApplicationError>
> {
try {
const user: AuthenticatedUser = await this.provider.completeAuth(input);
const session = await this.sessionPort.createSession(user);
this.output.present(session);
return Result.ok(undefined);
return Result.ok(session);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,19 +1,13 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import {
LoginUseCase,
type LoginInput,
type LoginResult,
type LoginErrorCode,
} from './LoginUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { LoginUseCase } from './LoginUseCase';
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 type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
describe('LoginUseCase', () => {
let authRepo: {
@@ -22,129 +16,82 @@ describe('LoginUseCase', () => {
let passwordService: {
verify: Mock;
};
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LoginResult> & { present: Mock };
let logger: Logger;
let useCase: LoginUseCase;
beforeEach(() => {
authRepo = {
findByEmail: vi.fn(),
};
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 };
} as unknown as Logger;
useCase = new LoginUseCase(
authRepo as unknown as IAuthRepository,
passwordService as unknown as IPasswordHashingService,
logger,
output,
);
});
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);
it('successfully logs in with valid credentials', async () => {
const user = User.create({
id: UserId.fromString('user-1'),
id: UserId.create(),
displayName: 'John Smith',
email: emailVO.value,
passwordHash: PasswordHash.fromHash('stored-hash'),
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('hashed-password'),
});
authRepo.findByEmail.mockResolvedValue(user);
passwordService.verify.mockResolvedValue(true);
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(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('returns INVALID_CREDENTIALS when user is not found', async () => {
const input: LoginInput = {
email: 'missing@example.com',
password: 'password123',
};
authRepo.findByEmail.mockResolvedValue(null);
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('returns INVALID_CREDENTIALS when password is invalid', async () => {
const input: LoginInput = {
const result = await useCase.execute({
email: 'test@example.com',
password: 'wrong-password',
};
const emailVO = EmailAddress.create(input.email);
const user = User.create({
id: UserId.fromString('user-1'),
displayName: 'Jane Smith',
email: emailVO.value,
passwordHash: PasswordHash.fromHash('stored-hash'),
password: 'Password123',
});
expect(result.isOk()).toBe(true);
const loginResult = result.unwrap();
expect(loginResult.user).toBe(user);
expect(authRepo.findByEmail).toHaveBeenCalledTimes(1);
expect(passwordService.verify).toHaveBeenCalledTimes(1);
});
it('returns error for invalid credentials', async () => {
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('hashed-password'),
});
authRepo.findByEmail.mockResolvedValue(user);
passwordService.verify.mockResolvedValue(false);
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 = {
const result = await useCase.execute({
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);
password: 'WrongPassword',
});
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();
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
});
it('returns error when user does not exist', async () => {
authRepo.findByEmail.mockResolvedValue(null);
const result = await useCase.execute({
email: 'nonexistent@example.com',
password: 'Password123',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
});

View File

@@ -4,7 +4,7 @@ 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, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type LoginInput = {
email: string;
@@ -24,15 +24,14 @@ export type LoginApplicationError = ApplicationErrorCode<LoginErrorCode, { messa
*
* Handles user login by verifying credentials.
*/
export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
export class LoginUseCase implements UseCase<LoginInput, LoginResult, LoginErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LoginResult>,
) {}
async execute(input: LoginInput): Promise<Result<void, LoginApplicationError>> {
async execute(input: LoginInput): Promise<Result<LoginResult, LoginApplicationError>> {
try {
const emailVO = EmailAddress.create(input.email);
const user = await this.authRepo.findByEmail(emailVO);
@@ -48,14 +47,13 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
const isValid = await this.passwordService.verify(input.password, passwordHash.value);
if (!isValid) {
return Result.err<void, LoginApplicationError>({
return Result.err<LoginResult, LoginApplicationError>({
code: 'INVALID_CREDENTIALS',
details: { message: 'Invalid credentials' },
});
}
this.output.present({ user });
return Result.ok(undefined);
return Result.ok({ user });
} catch (error) {
const message =
error instanceof Error && error.message
@@ -66,7 +64,7 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
input,
});
return Result.err<void, LoginApplicationError>({
return Result.err<LoginResult, LoginApplicationError>({
code: 'REPOSITORY_ERROR',
details: { message },
});

View File

@@ -1,15 +1,18 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import {
LoginWithEmailUseCase,
type LoginWithEmailInput,
type LoginWithEmailResult,
type LoginWithEmailErrorCode,
} from './LoginWithEmailUseCase';
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import { LoginWithEmailUseCase } from './LoginWithEmailUseCase';
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
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';
import type { Logger } from '@core/shared/application';
// Mock the PasswordHash module
vi.mock('@core/identity/domain/value-objects/PasswordHash', () => ({
PasswordHash: {
fromHash: vi.fn((hash: string) => ({
verify: vi.fn().mockResolvedValue(hash === 'hashed-password'),
value: hash,
})),
},
}));
describe('LoginWithEmailUseCase', () => {
let userRepository: {
@@ -17,169 +20,119 @@ describe('LoginWithEmailUseCase', () => {
};
let sessionPort: {
createSession: Mock;
getCurrentSession: Mock;
clearSession: Mock;
};
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LoginWithEmailResult> & { present: Mock };
let logger: Logger;
let useCase: LoginWithEmailUseCase;
beforeEach(() => {
userRepository = {
findByEmail: vi.fn(),
};
sessionPort = {
createSession: vi.fn(),
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 };
} as unknown as Logger;
useCase = new LoginWithEmailUseCase(
userRepository as unknown as IUserRepository,
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
});
it('returns ok and presents session result for valid credentials', async () => {
const input: LoginWithEmailInput = {
email: 'Test@Example.com',
password: 'password123',
};
// Import PasswordHash to create a proper hash
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const passwordHash = await PasswordHash.create('password123');
const storedUser: StoredUser = {
const storedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
passwordHash: passwordHash.value,
displayName: 'John Smith',
passwordHash: 'hashed-password',
createdAt: new Date(),
};
const session = {
userRepository.findByEmail.mockResolvedValue(storedUser);
sessionPort.createSession.mockResolvedValue({
token: 'token-123',
user: {
id: storedUser.id,
email: storedUser.email,
displayName: storedUser.displayName,
id: 'user-1',
email: 'test@example.com',
displayName: 'John Smith',
},
issuedAt: Date.now(),
expiresAt: Date.now() + 1000,
token: 'token-123',
};
userRepository.findByEmail.mockResolvedValue(storedUser);
sessionPort.createSession.mockResolvedValue(session);
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,
displayName: storedUser.displayName,
email: storedUser.email,
});
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);
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
});
expect(result.isOk()).toBe(true);
const loginResult = result.unwrap();
expect(loginResult.sessionToken).toBe('token-123');
expect(loginResult.userId).toBe('user-1');
expect(loginResult.displayName).toBe('John Smith');
expect(loginResult.email).toBe('test@example.com');
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
expect(sessionPort.createSession).toHaveBeenCalled();
});
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: '' });
const result = await useCase.execute({
email: '',
password: 'Password123',
});
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();
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
});
it('returns INVALID_CREDENTIALS when user does not exist', async () => {
const input: LoginWithEmailInput = {
email: 'missing@example.com',
password: 'password',
};
userRepository.findByEmail.mockResolvedValue(null);
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
const result = await useCase.execute({
email: 'nonexistent@example.com',
password: 'Password123',
});
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();
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
it('returns INVALID_CREDENTIALS when password is invalid', async () => {
const input: LoginWithEmailInput = {
email: 'test@example.com',
password: 'wrong',
};
// Create a hash for a different password
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const passwordHash = await PasswordHash.create('correct-password');
const storedUser: StoredUser = {
const storedUser = {
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
passwordHash: passwordHash.value,
displayName: 'John Smith',
passwordHash: 'wrong-hash', // Different hash to simulate wrong password
createdAt: new Date(),
};
userRepository.findByEmail.mockResolvedValue(storedUser);
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
await useCase.execute(input);
const result = await useCase.execute({
email: 'test@example.com',
password: 'WrongPassword',
});
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();
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const input: LoginWithEmailInput = {
userRepository.findByEmail.mockRejectedValue(new Error('Database connection failed'));
const result = await useCase.execute({
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);
password: 'Password123',
});
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(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -8,7 +8,8 @@ import type { IUserRepository } from '../../domain/repositories/IUserRepository'
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';
import type { Logger } from '@core/shared/application';
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
export type LoginWithEmailInput = {
email: string;
@@ -40,10 +41,9 @@ export class LoginWithEmailUseCase {
private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LoginWithEmailResult>,
) {}
async execute(input: LoginWithEmailInput): Promise<Result<void, LoginWithEmailApplicationError>> {
async execute(input: LoginWithEmailInput): Promise<Result<LoginWithEmailResult, LoginWithEmailApplicationError>> {
try {
if (!input.email || !input.password) {
return Result.err({
@@ -63,7 +63,6 @@ export class LoginWithEmailUseCase {
}
// Verify password using PasswordHash value object
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
const storedPasswordHash = PasswordHash.fromHash(user.passwordHash);
const isValid = await storedPasswordHash.verify(input.password);
@@ -99,9 +98,7 @@ export class LoginWithEmailUseCase {
expiresAt: session.expiresAt,
};
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,25 +1,19 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { LogoutUseCase, type LogoutResult, type LogoutErrorCode } from './LogoutUseCase';
import { LogoutUseCase } 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 type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('LogoutUseCase', () => {
let sessionPort: {
clearSession: Mock;
getCurrentSession: Mock;
createSession: Mock;
};
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<LogoutResult> & { present: Mock };
let useCase: LogoutUseCase;
beforeEach(() => {
sessionPort = {
clearSession: vi.fn(),
getCurrentSession: vi.fn(),
createSession: vi.fn(),
};
logger = {
@@ -29,42 +23,30 @@ describe('LogoutUseCase', () => {
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 and presents success', async () => {
const result: Result<void, ApplicationErrorCode<LogoutErrorCode, { message: string }>> =
await useCase.execute();
it('successfully clears session and returns success', async () => {
sessionPort.clearSession.mockResolvedValue(undefined);
const result = await useCase.execute();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
const logoutResult = result.unwrap();
expect(logoutResult.success).toBe(true);
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);
it('returns error when session clear fails', async () => {
sessionPort.clearSession.mockRejectedValue(new Error('Session clear failed'));
const result: Result<void, ApplicationErrorCode<LogoutErrorCode, { message: string }>> =
await useCase.execute();
const result = 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(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,7 @@
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, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type LogoutInput = {};
@@ -13,25 +13,23 @@ export type LogoutErrorCode = 'REPOSITORY_ERROR';
export type LogoutApplicationError = ApplicationErrorCode<LogoutErrorCode, { message: string }>;
export class LogoutUseCase implements UseCase<LogoutInput, void, LogoutErrorCode> {
export class LogoutUseCase implements UseCase<LogoutInput, LogoutResult, LogoutErrorCode> {
private readonly sessionPort: IdentitySessionPort;
constructor(
sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<LogoutResult>,
) {
this.sessionPort = sessionPort;
}
async execute(): Promise<Result<void, LogoutApplicationError>> {
async execute(): Promise<Result<LogoutResult, LogoutApplicationError>> {
try {
await this.sessionPort.clearSession();
const result: LogoutResult = { success: true };
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,17 +1,14 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import { describe, it, expect, vi, type Mock } from 'vitest';
import { ResetPasswordUseCase } from './ResetPasswordUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
type ResetPasswordOutput = {
message: string;
};
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
describe('ResetPasswordUseCase', () => {
let authRepo: {
@@ -26,7 +23,6 @@ describe('ResetPasswordUseCase', () => {
hash: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<ResetPasswordOutput> & { present: Mock };
let useCase: ResetPasswordUseCase;
beforeEach(() => {
@@ -34,206 +30,129 @@ describe('ResetPasswordUseCase', () => {
findByEmail: vi.fn(),
save: vi.fn(),
};
magicLinkRepo = {
findByToken: vi.fn(),
markAsUsed: vi.fn(),
};
passwordService = {
hash: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new ResetPasswordUseCase(
authRepo as unknown as IAuthRepository,
magicLinkRepo as unknown as IMagicLinkRepository,
passwordService as unknown as IPasswordHashingService,
logger,
output,
);
});
it('should reset password with valid token', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
it('successfully resets password with valid token', async () => {
const user = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('old-hash'),
});
const resetRequest = {
const validToken = 'a'.repeat(32); // 32 characters minimum
magicLinkRepo.findByToken.mockResolvedValue({
email: 'test@example.com',
token: input.token,
expiresAt: new Date(Date.now() + 60000), // 1 minute from now
token: validToken,
expiresAt: new Date(Date.now() + 60000),
userId: user.getId().value,
};
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
authRepo.findByEmail.mockResolvedValue(user);
passwordService.hash.mockResolvedValue('hashed-new-password');
const result = await useCase.execute(input);
expect(magicLinkRepo.findByToken).toHaveBeenCalledWith(input.token);
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create('test@example.com'));
expect(passwordService.hash).toHaveBeenCalledWith(input.newPassword);
expect(authRepo.save).toHaveBeenCalled();
expect(magicLinkRepo.markAsUsed).toHaveBeenCalledWith(input.token);
expect(output.present).toHaveBeenCalledWith({
message: 'Password reset successfully. You can now log in with your new password.',
used: false,
});
authRepo.findByEmail.mockResolvedValue(user);
passwordService.hash.mockResolvedValue('new-hashed-password');
const result = await useCase.execute({
token: validToken,
newPassword: 'NewPassword123',
});
expect(result.isOk()).toBe(true);
const resetResult = result.unwrap();
expect(resetResult.message).toBe('Password reset successfully. You can now log in with your new password.');
expect(authRepo.save).toHaveBeenCalled();
expect(magicLinkRepo.markAsUsed).toHaveBeenCalledWith(validToken);
});
it('should reject invalid token', async () => {
const input = {
token: 'invalid-token',
newPassword: 'NewPass123!',
};
it('returns error for invalid token', async () => {
magicLinkRepo.findByToken.mockResolvedValue(null);
const result = await useCase.execute(input);
const result = await useCase.execute({
token: 'invalid-token-that-is-too-short',
newPassword: 'NewPassword123',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_TOKEN');
expect(result.unwrapErr().code).toBe('INVALID_TOKEN');
});
it('should reject expired token', async () => {
const input = {
token: 'expired-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
const resetRequest = {
it('returns error for expired token', async () => {
const expiredToken = 'b'.repeat(32);
magicLinkRepo.findByToken.mockResolvedValue({
email: 'test@example.com',
token: input.token,
expiresAt: new Date(Date.now() - 60000), // 1 minute ago
userId: 'user-123',
};
token: expiredToken,
expiresAt: new Date(Date.now() - 60000),
userId: 'user-1',
used: false,
});
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
const result = await useCase.execute(input);
const result = await useCase.execute({
token: expiredToken,
newPassword: 'NewPassword123',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('EXPIRED_TOKEN');
expect(result.unwrapErr().code).toBe('EXPIRED_TOKEN');
});
it('should reject weak password', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'weak',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should reject password without uppercase', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'newpass123!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should reject password without number', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should reject password shorter than 8 characters', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'New1!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
});
it('should handle user no longer exists', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
const resetRequest = {
email: 'deleted@example.com',
token: input.token,
it('returns error for weak password', async () => {
const validToken = 'c'.repeat(32);
magicLinkRepo.findByToken.mockResolvedValue({
email: 'test@example.com',
token: validToken,
expiresAt: new Date(Date.now() + 60000),
userId: 'user-123',
};
userId: 'user-1',
used: false,
});
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
const result = await useCase.execute({
token: validToken,
newPassword: 'weak',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
});
it('returns error when user no longer exists', async () => {
const validToken = 'd'.repeat(32);
magicLinkRepo.findByToken.mockResolvedValue({
email: 'test@example.com',
token: validToken,
expiresAt: new Date(Date.now() + 60000),
userId: 'user-1',
used: false,
});
authRepo.findByEmail.mockResolvedValue(null);
const result = await useCase.execute(input);
const result = await useCase.execute({
token: validToken,
newPassword: 'NewPassword123',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_TOKEN');
});
it('should handle token format validation', async () => {
const input = {
token: 'short',
newPassword: 'NewPass123!',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_TOKEN');
});
it('should handle repository errors', async () => {
const input = {
token: 'valid-token-12345678901234567890123456789012',
newPassword: 'NewPass123!',
};
magicLinkRepo.findByToken.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toContain('Database error');
expect(result.unwrapErr().code).toBe('INVALID_TOKEN');
});
});

View File

@@ -5,7 +5,7 @@ import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type ResetPasswordInput = {
token: string;
@@ -26,16 +26,15 @@ export type ResetPasswordApplicationError = ApplicationErrorCode<ResetPasswordEr
* Handles password reset using a magic link token.
* Validates token, checks expiration, and updates password.
*/
export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, void, ResetPasswordErrorCode> {
export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, ResetPasswordResult, ResetPasswordErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly magicLinkRepo: IMagicLinkRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<ResetPasswordResult>,
) {}
async execute(input: ResetPasswordInput): Promise<Result<void, ResetPasswordApplicationError>> {
async execute(input: ResetPasswordInput): Promise<Result<ResetPasswordResult, ResetPasswordApplicationError>> {
try {
// Validate token format
if (!input.token || typeof input.token !== 'string' || input.token.length < 32) {
@@ -111,11 +110,9 @@ export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, void, R
email: resetRequest.email,
});
this.output.present({
return Result.ok({
message: 'Password reset successfully. You can now log in with your new password.',
});
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,20 +1,10 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { SignupSponsorUseCase } from './SignupSponsorUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { ICompanyRepository } from '../../domain/repositories/ICompanyRepository';
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
vi.mock('../../domain/value-objects/PasswordHash', () => ({
PasswordHash: {
fromHash: (hash: string) => ({ value: hash }),
},
}));
type SignupSponsorOutput = unknown;
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('SignupSponsorUseCase', () => {
let authRepo: {
@@ -30,7 +20,6 @@ describe('SignupSponsorUseCase', () => {
hash: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<SignupSponsorOutput> & { present: Mock };
let useCase: SignupSponsorUseCase;
beforeEach(() => {
@@ -38,181 +27,123 @@ describe('SignupSponsorUseCase', () => {
findByEmail: vi.fn(),
save: vi.fn(),
};
companyRepo = {
create: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
};
passwordService = {
hash: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new SignupSponsorUseCase(
authRepo as unknown as IAuthRepository,
companyRepo as unknown as ICompanyRepository,
passwordService as unknown as IPasswordHashingService,
logger,
output,
);
});
it('creates user and company successfully when email is free', async () => {
const input = {
email: 'sponsor@example.com',
password: 'Password123',
displayName: 'John Doe',
companyName: 'Acme Racing Co.',
};
authRepo.findByEmail.mockResolvedValue(null);
passwordService.hash.mockResolvedValue('hashed-password');
companyRepo.create.mockImplementation((data) => ({
getId: () => 'company-123',
getName: data.getName,
getOwnerUserId: data.getOwnerUserId,
getContactEmail: data.getContactEmail,
getId: () => 'company-1',
...data,
}));
companyRepo.save.mockResolvedValue(undefined);
authRepo.save.mockResolvedValue(undefined);
const result = await useCase.execute(input);
const result = await useCase.execute({
email: 'sponsor@example.com',
password: 'Password123',
displayName: 'Sponsor User',
companyName: 'Sponsor Inc',
});
// Verify the basic flow worked
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalled();
// Verify key repository methods were called
expect(authRepo.findByEmail).toHaveBeenCalled();
expect(passwordService.hash).toHaveBeenCalled();
const signupResult = result.unwrap();
expect(signupResult.user).toBeDefined();
expect(signupResult.company).toBeDefined();
expect(signupResult.user.getEmail()).toBe('sponsor@example.com');
expect(signupResult.user.getDisplayName()).toBe('Sponsor User');
expect(companyRepo.create).toHaveBeenCalled();
expect(companyRepo.save).toHaveBeenCalled();
expect(authRepo.save).toHaveBeenCalled();
});
it('rolls back company creation when user save fails', async () => {
const input = {
it('returns error when user already exists', async () => {
const existingUser = {
getId: () => ({ value: 'existing-user' }),
getEmail: () => 'sponsor@example.com',
getDisplayName: () => 'Existing User',
getPasswordHash: () => ({ value: 'hash' }),
};
authRepo.findByEmail.mockResolvedValue(existingUser);
const result = await useCase.execute({
email: 'sponsor@example.com',
password: 'Password123',
displayName: 'John Doe',
companyName: 'Acme Racing Co.',
};
displayName: 'Sponsor User',
companyName: 'Sponsor Inc',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('USER_ALREADY_EXISTS');
});
it('returns error for weak password', async () => {
const result = await useCase.execute({
email: 'sponsor@example.com',
password: 'weak',
displayName: 'Sponsor User',
companyName: 'Sponsor Inc',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
});
it('returns error for invalid display name', async () => {
const result = await useCase.execute({
email: 'sponsor@example.com',
password: 'Password123',
displayName: 'A',
companyName: 'Sponsor Inc',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_DISPLAY_NAME');
});
it('rolls back company creation on failure', async () => {
authRepo.findByEmail.mockResolvedValue(null);
passwordService.hash.mockResolvedValue('hashed-password');
companyRepo.create.mockImplementation((data) => ({
getId: () => 'company-123',
getName: data.getName,
getOwnerUserId: data.getOwnerUserId,
getContactEmail: data.getContactEmail,
getId: () => 'company-1',
...data,
}));
companyRepo.save.mockResolvedValue(undefined);
authRepo.save.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute(input);
// Verify company was deleted (rollback)
expect(companyRepo.delete).toHaveBeenCalled();
const deletedCompanyId = companyRepo.delete.mock.calls[0][0];
expect(deletedCompanyId).toBeDefined();
// Verify error result
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
});
it('fails when user already exists', async () => {
const input = {
email: 'existing@example.com',
const result = await useCase.execute({
email: 'sponsor@example.com',
password: 'Password123',
displayName: 'John Doe',
companyName: 'Acme Racing Co.',
};
const existingUser = User.create({
id: UserId.create(),
displayName: 'Existing User',
email: input.email,
displayName: 'Sponsor User',
companyName: 'Sponsor Inc',
});
authRepo.findByEmail.mockResolvedValue(existingUser);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('USER_ALREADY_EXISTS');
// Verify no company was created
expect(companyRepo.create).not.toHaveBeenCalled();
});
it('fails when company creation throws an error', async () => {
const input = {
email: 'sponsor@example.com',
password: 'Password123',
displayName: 'John Doe',
companyName: 'Acme Racing Co.',
};
authRepo.findByEmail.mockResolvedValue(null);
passwordService.hash.mockResolvedValue('hashed-password');
companyRepo.create.mockImplementation(() => {
throw new Error('Invalid company data');
});
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
// The error message might be wrapped, so just check it's a repository error
});
it('fails with weak password', async () => {
const input = {
email: 'sponsor@example.com',
password: 'weak',
displayName: 'John Doe',
companyName: 'Acme Racing Co.',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('WEAK_PASSWORD');
// Verify no repository calls
expect(authRepo.findByEmail).not.toHaveBeenCalled();
expect(companyRepo.create).not.toHaveBeenCalled();
});
it('fails with invalid display name', async () => {
const input = {
email: 'sponsor@example.com',
password: 'Password123',
displayName: 'user123', // Invalid - alphanumeric only
companyName: 'Acme Racing Co.',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('INVALID_DISPLAY_NAME');
// Verify no repository calls
expect(authRepo.findByEmail).not.toHaveBeenCalled();
expect(companyRepo.create).not.toHaveBeenCalled();
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(companyRepo.delete).toHaveBeenCalledWith('company-1');
});
});

View File

@@ -7,7 +7,7 @@ import { ICompanyRepository } from '../../domain/repositories/ICompanyRepository
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, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type SignupSponsorInput = {
email: string;
@@ -31,16 +31,15 @@ export type SignupSponsorApplicationError = ApplicationErrorCode<SignupSponsorEr
* Handles sponsor registration by creating both a User and a Company atomically.
* If any step fails, the operation is rolled back.
*/
export class SignupSponsorUseCase implements UseCase<SignupSponsorInput, void, SignupSponsorErrorCode> {
export class SignupSponsorUseCase implements UseCase<SignupSponsorInput, SignupSponsorResult, SignupSponsorErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly companyRepo: ICompanyRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<SignupSponsorResult>,
) {}
async execute(input: SignupSponsorInput): Promise<Result<void, SignupSponsorApplicationError>> {
async execute(input: SignupSponsorInput): Promise<Result<SignupSponsorResult, SignupSponsorApplicationError>> {
let createdCompany: Company | null = null;
try {
@@ -118,8 +117,7 @@ export class SignupSponsorUseCase implements UseCase<SignupSponsorInput, void, S
// Save user
await this.authRepo.save(user);
this.output.present({ user, company });
return Result.ok(undefined);
return Result.ok({ user, company });
} catch (error) {
// Rollback: delete company if it was created
if (createdCompany && typeof createdCompany.getId === 'function') {

View File

@@ -1,19 +1,11 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { SignupUseCase } from './SignupUseCase';
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
import { UserId } from '../../domain/value-objects/UserId';
import { User } from '../../domain/entities/User';
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
vi.mock('../../domain/value-objects/PasswordHash', () => ({
PasswordHash: {
fromHash: (hash: string) => ({ value: hash }),
},
}));
type SignupOutput = unknown;
import type { Logger } from '@core/shared/application';
import { User } from '../../domain/entities/User';
import { UserId } from '../../domain/value-objects/UserId';
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
describe('SignupUseCase', () => {
let authRepo: {
@@ -24,7 +16,6 @@ describe('SignupUseCase', () => {
hash: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<SignupOutput> & { present: Mock };
let useCase: SignupUseCase;
beforeEach(() => {
@@ -32,64 +23,82 @@ describe('SignupUseCase', () => {
findByEmail: vi.fn(),
save: vi.fn(),
};
passwordService = {
hash: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new SignupUseCase(
authRepo as unknown as IAuthRepository,
passwordService as unknown as IPasswordHashingService,
logger,
output,
);
});
it('creates and saves a new user when email is free', async () => {
const input = {
email: 'new@example.com',
password: 'Password123',
displayName: 'New User',
};
it('successfully signs up a new user', async () => {
authRepo.findByEmail.mockResolvedValue(null);
passwordService.hash.mockResolvedValue('hashed-password');
const result = await useCase.execute(input);
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
expect(passwordService.hash).toHaveBeenCalledWith(input.password);
expect(authRepo.save).toHaveBeenCalled();
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalled();
});
it('throws when user already exists', async () => {
const input = {
email: 'existing@example.com',
password: 'password123',
displayName: 'Existing User',
};
const existingUser = User.create({
id: UserId.create(),
displayName: 'Existing User',
email: input.email,
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
displayName: 'John Smith', // Valid name that passes validation
});
expect(result.isOk()).toBe(true);
const signupResult = result.unwrap();
expect(signupResult.user).toBeDefined();
expect(signupResult.user.getEmail()).toBe('test@example.com');
expect(signupResult.user.getDisplayName()).toBe('John Smith');
expect(authRepo.findByEmail).toHaveBeenCalledTimes(1);
expect(authRepo.save).toHaveBeenCalledTimes(1);
});
it('returns error when user already exists', async () => {
const existingUser = User.create({
id: UserId.create(),
displayName: 'John Smith',
email: 'test@example.com',
passwordHash: PasswordHash.fromHash('existing-hash'),
});
authRepo.findByEmail.mockResolvedValue(existingUser);
const result = await useCase.execute(input);
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
displayName: 'John Smith',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('USER_ALREADY_EXISTS');
});
it('returns error for weak password', async () => {
const result = await useCase.execute({
email: 'test@example.com',
password: 'weak',
displayName: 'John Smith',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
});
it('returns error for invalid display name', async () => {
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
displayName: 'A', // Too short
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('INVALID_DISPLAY_NAME');
});
});

View File

@@ -3,9 +3,10 @@ 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 { PasswordHash } from '../../domain/value-objects/PasswordHash';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
import type { Logger, UseCase } from '@core/shared/application';
export type SignupInput = {
email: string;
@@ -26,15 +27,14 @@ export type SignupApplicationError = ApplicationErrorCode<SignupErrorCode, { mes
*
* Handles user registration.
*/
export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode> {
export class SignupUseCase implements UseCase<SignupInput, SignupResult, SignupErrorCode> {
constructor(
private readonly authRepo: IAuthRepository,
private readonly passwordService: IPasswordHashingService,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<SignupResult>,
) {}
async execute(input: SignupInput): Promise<Result<void, SignupApplicationError>> {
async execute(input: SignupInput): Promise<Result<SignupResult, SignupApplicationError>> {
try {
// Validate email format
const emailVO = EmailAddress.create(input.email);
@@ -58,8 +58,7 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
// Hash password
const hashedPassword = await this.passwordService.hash(input.password);
const passwordHashModule = await import('../../domain/value-objects/PasswordHash');
const passwordHash = passwordHashModule.PasswordHash.fromHash(hashedPassword);
const passwordHash = PasswordHash.fromHash(hashedPassword);
// Create user (displayName validation happens in User entity constructor)
const userId = UserId.create();
@@ -72,8 +71,7 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
await this.authRepo.save(user);
this.output.present({ user });
return Result.ok(undefined);
return Result.ok({ user });
} catch (error) {
// Handle specific validation errors from User entity
if (error instanceof Error) {

View File

@@ -1,11 +1,9 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { SignupWithEmailUseCase } from './SignupWithEmailUseCase';
import type { SignupWithEmailInput } from './SignupWithEmailUseCase';
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
type SignupWithEmailOutput = unknown;
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('SignupWithEmailUseCase', () => {
let userRepository: {
@@ -14,11 +12,8 @@ describe('SignupWithEmailUseCase', () => {
};
let sessionPort: {
createSession: Mock;
getCurrentSession: Mock;
clearSession: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<SignupWithEmailOutput> & { present: Mock };
let useCase: SignupWithEmailUseCase;
beforeEach(() => {
@@ -26,130 +21,106 @@ describe('SignupWithEmailUseCase', () => {
findByEmail: vi.fn(),
create: vi.fn(),
};
sessionPort = {
createSession: vi.fn(),
getCurrentSession: vi.fn(),
clearSession: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new SignupWithEmailUseCase(
userRepository as unknown as IUserRepository,
sessionPort as unknown as IdentitySessionPort,
logger,
output,
);
});
it('creates a new user and session for valid input', async () => {
const command: SignupWithEmailInput = {
email: 'new@example.com',
password: 'password123',
displayName: 'New User',
};
userRepository.findByEmail.mockResolvedValue(null);
const session: AuthSession = {
userRepository.create.mockResolvedValue(undefined);
sessionPort.createSession.mockResolvedValue({
token: 'session-token',
user: {
id: 'user-1',
email: command.email.toLowerCase(),
displayName: command.displayName,
displayName: 'Test User',
email: 'test@example.com',
},
issuedAt: Date.now(),
expiresAt: Date.now() + 1000,
token: 'session-token',
};
});
sessionPort.createSession.mockResolvedValue(session);
const result = await useCase.execute(command);
expect(userRepository.findByEmail).toHaveBeenCalledWith(command.email);
expect(userRepository.create).toHaveBeenCalled();
expect(sessionPort.createSession).toHaveBeenCalledWith({
id: expect.any(String),
email: command.email.toLowerCase(),
displayName: command.displayName,
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
displayName: 'Test User',
});
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({
sessionToken: 'session-token',
userId: 'user-1',
displayName: 'New User',
email: 'new@example.com',
createdAt: expect.any(Date),
isNewUser: true,
});
const signupResult = result.unwrap();
expect(signupResult.sessionToken).toBe('session-token');
expect(signupResult.userId).toBe('user-1');
expect(signupResult.displayName).toBe('Test User');
expect(signupResult.email).toBe('test@example.com');
expect(signupResult.isNewUser).toBe(true);
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
expect(userRepository.create).toHaveBeenCalled();
expect(sessionPort.createSession).toHaveBeenCalled();
});
it('returns error when email format is invalid', async () => {
const command: SignupWithEmailInput = {
it('returns error for invalid email format', async () => {
const result = await useCase.execute({
email: 'invalid-email',
password: 'password123',
displayName: 'User',
};
password: 'Password123',
displayName: 'Test User',
});
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('INVALID_EMAIL_FORMAT');
expect(result.unwrapErr().code).toBe('INVALID_EMAIL_FORMAT');
});
it('returns error when password is too short', async () => {
const command: SignupWithEmailInput = {
email: 'valid@example.com',
password: 'short',
displayName: 'User',
};
it('returns error for weak password', async () => {
const result = await useCase.execute({
email: 'test@example.com',
password: 'weak',
displayName: 'Test User',
});
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('WEAK_PASSWORD');
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
});
it('returns error when display name is too short', async () => {
const command: SignupWithEmailInput = {
email: 'valid@example.com',
password: 'password123',
displayName: ' ',
};
it('returns error for invalid display name', async () => {
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
displayName: 'A',
});
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('INVALID_DISPLAY_NAME');
expect(result.unwrapErr().code).toBe('INVALID_DISPLAY_NAME');
});
it('returns error when email already exists', async () => {
const command: SignupWithEmailInput = {
email: 'existing@example.com',
password: 'password123',
userRepository.findByEmail.mockResolvedValue({
id: 'existing-user',
email: 'test@example.com',
displayName: 'Existing User',
};
const existingUser: StoredUser = {
id: 'user-1',
email: command.email,
displayName: command.displayName,
passwordHash: 'hash',
createdAt: new Date(),
};
});
userRepository.findByEmail.mockResolvedValue(existingUser);
const result = await useCase.execute({
email: 'test@example.com',
password: 'Password123',
displayName: 'Test User',
});
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.code).toBe('EMAIL_ALREADY_EXISTS');
expect(result.unwrapErr().code).toBe('EMAIL_ALREADY_EXISTS');
});
});
});

View File

@@ -9,7 +9,7 @@ import type { AuthenticatedUser } 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';
import type { Logger } from '@core/shared/application';
export type SignupWithEmailInput = {
email: string;
@@ -43,11 +43,10 @@ export class SignupWithEmailUseCase {
private readonly userRepository: IUserRepository,
private readonly sessionPort: IdentitySessionPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<SignupWithEmailResult>,
) {}
async execute(input: SignupWithEmailInput): Promise<
Result<void, SignupWithEmailApplicationError>
Result<SignupWithEmailResult, SignupWithEmailApplicationError>
> {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -119,9 +118,7 @@ export class SignupWithEmailUseCase {
isNewUser: true,
};
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,13 +1,7 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import {
StartAuthUseCase,
type StartAuthInput,
type StartAuthResult,
type StartAuthErrorCode,
} from './StartAuthUseCase';
import { StartAuthUseCase } from './StartAuthUseCase';
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
describe('StartAuthUseCase', () => {
@@ -15,7 +9,6 @@ describe('StartAuthUseCase', () => {
startAuth: Mock;
};
let logger: Logger & { error: Mock };
let output: UseCaseOutputPort<StartAuthResult> & { present: Mock };
let useCase: StartAuthUseCase;
beforeEach(() => {
@@ -30,60 +23,58 @@ describe('StartAuthUseCase', () => {
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('returns ok and presents redirect when provider call succeeds', async () => {
const input: StartAuthInput = {
provider: 'IRACING_DEMO',
returnTo: 'https://app/callback',
};
const expected = { redirectUrl: 'https://auth/redirect', state: 'state-123' };
provider.startAuth.mockResolvedValue(expected);
const result: Result<void, ApplicationErrorCode<StartAuthErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(provider.startAuth).toHaveBeenCalledWith({
provider: input.provider,
returnTo: input.returnTo,
provider.startAuth.mockResolvedValue({
redirectUrl: 'https://auth/redirect',
state: 'state-123',
});
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as StartAuthResult;
expect(presented).toEqual(expected);
const result = await useCase.execute({
provider: 'iracing',
returnTo: '/dashboard',
});
expect(result.isOk()).toBe(true);
const startAuthResult = result.unwrap();
expect(startAuthResult.redirectUrl).toBe('https://auth/redirect');
expect(startAuthResult.state).toBe('state-123');
expect(provider.startAuth).toHaveBeenCalledWith({
provider: 'iracing',
returnTo: '/dashboard',
});
});
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
const input: StartAuthInput = {
provider: 'IRACING_DEMO',
returnTo: 'https://app/callback',
};
it('returns ok without returnTo when not provided', async () => {
provider.startAuth.mockResolvedValue({
redirectUrl: 'https://auth/redirect',
state: 'state-123',
});
provider.startAuth.mockRejectedValue(new Error('Provider failure'));
const result = await useCase.execute({
provider: 'iracing',
});
const result: Result<void, ApplicationErrorCode<StartAuthErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(provider.startAuth).toHaveBeenCalledWith({
provider: 'iracing',
});
});
it('returns error when provider call fails', async () => {
provider.startAuth.mockRejectedValue(new Error('Provider error'));
const result = await useCase.execute({
provider: 'iracing',
});
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(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -1,7 +1,7 @@
import type { IdentityProviderPort, AuthProvider, StartAuthCommand } 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';
import type { Logger } from '@core/shared/application';
export type StartAuthInput = {
provider: AuthProvider;
@@ -21,10 +21,9 @@ export class StartAuthUseCase {
constructor(
private readonly provider: IdentityProviderPort,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<StartAuthResult>,
) {}
async execute(input: StartAuthInput): Promise<Result<void, StartAuthApplicationError>> {
async execute(input: StartAuthInput): Promise<Result<StartAuthResult, StartAuthApplicationError>> {
try {
const command: StartAuthCommand = input.returnTo
? {
@@ -38,9 +37,8 @@ export class StartAuthUseCase {
const { redirectUrl, state } = await this.provider.startAuth(command);
const result: StartAuthResult = { redirectUrl, state };
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message =
error instanceof Error && error.message

View File

@@ -1,11 +1,8 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { CreateAchievementUseCase, type IAchievementRepository } from './CreateAchievementUseCase';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import { Achievement } from '@core/identity/domain/entities/Achievement';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
type CreateAchievementOutput = {
achievement: Achievement;
};
describe('CreateAchievementUseCase', () => {
let achievementRepository: {
@@ -13,7 +10,6 @@ describe('CreateAchievementUseCase', () => {
findById: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<CreateAchievementOutput> & { present: Mock };
let useCase: CreateAchievementUseCase;
beforeEach(() => {
@@ -29,46 +25,50 @@ describe('CreateAchievementUseCase', () => {
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
};
useCase = new CreateAchievementUseCase(
achievementRepository as unknown as IAchievementRepository,
logger,
output,
);
});
it('creates an achievement and persists it', async () => {
const props = {
id: 'achv-1',
name: 'First Win',
description: 'Awarded for winning your first race',
id: 'achievement-1',
name: 'First Race',
description: 'Complete your first race',
category: 'driver' as const,
rarity: 'common' as const,
iconUrl: 'https://example.com/icon.png',
points: 50,
requirements: [
{
type: 'wins' as const,
value: 1,
operator: '>=' as const,
},
],
points: 10,
requirements: [{ type: 'races_completed' as const, value: 1, operator: '>=' as const }],
isSecret: false,
};
achievementRepository.save.mockResolvedValue(undefined);
const result = await useCase.execute(props);
expect(result.isOk()).toBe(true);
expect(achievementRepository.save).toHaveBeenCalledTimes(1);
const savedAchievement = achievementRepository.save.mock.calls?.[0]?.[0];
expect(savedAchievement).toBeInstanceOf(Achievement);
expect(savedAchievement.id).toBe(props.id);
expect(savedAchievement.name).toBe(props.name);
expect(output.present).toHaveBeenCalledWith({ achievement: savedAchievement });
const createResult = result.unwrap();
expect(createResult.achievement).toBeDefined();
expect(createResult.achievement.id).toBe(props.id);
expect(createResult.achievement.name).toBe(props.name);
expect(achievementRepository.save).toHaveBeenCalled();
});
it('returns error when repository save fails', async () => {
achievementRepository.save.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute({
id: 'achievement-1',
name: 'First Race',
description: 'Complete your first race',
category: 'driver' as const,
rarity: 'common' as const,
points: 10,
requirements: [{ type: 'races_completed' as const, value: 1, operator: '>=' as const }],
isSecret: false,
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,7 @@
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';
import type { Logger } from '@core/shared/application';
export interface IAchievementRepository {
save(achievement: Achievement): Promise<void>;
@@ -25,20 +25,18 @@ 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>
Result<CreateAchievementResult, CreateAchievementApplicationError>
> {
try {
const achievement = Achievement.create(input);
await this.achievementRepository.save(achievement);
const result: CreateAchievementResult = { achievement };
this.output.present(result);
return Result.ok(undefined);
return Result.ok(result);
} catch (error) {
const message =
error instanceof Error && error.message
@@ -57,4 +55,4 @@ export class CreateAchievementUseCase {
} as CreateAchievementApplicationError);
}
}
}
}