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