refactor auth module

This commit is contained in:
2025-12-21 23:21:21 +01:00
parent 0f60200917
commit b639483187
6 changed files with 213 additions and 105 deletions

View File

@@ -3,7 +3,6 @@ import { Provider } from '@nestjs/common';
// Import interfaces and concrete implementations
import { StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { Logger } from '@core/shared/application';
import { InMemoryAuthRepository } from '@adapters/identity/persistence/inmemory/InMemoryAuthRepository';
import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/InMemoryUserRepository';
@@ -15,7 +14,7 @@ import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCas
import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase';
import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase';
import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase';
@@ -31,8 +30,9 @@ export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort';
export const LOGIN_USE_CASE_TOKEN = 'LoginUseCase';
export const SIGNUP_USE_CASE_TOKEN = 'SignupUseCase';
export const LOGOUT_USE_CASE_TOKEN = 'LogoutUseCase';
export const AUTH_SESSION_PRESENTER_TOKEN = 'AuthSessionPresenter';
export const COMMAND_RESULT_PRESENTER_TOKEN = 'CommandResultPresenter';
export const AUTH_SESSION_OUTPUT_PORT_TOKEN = 'AuthSessionOutputPort';
export const COMMAND_RESULT_OUTPUT_PORT_TOKEN = 'CommandResultOutputPort';
export const AuthProviders: Provider[] = [
{
@@ -74,29 +74,45 @@ export const AuthProviders: Provider[] = [
inject: [LOGGER_TOKEN],
},
{
provide: LOGIN_USE_CASE_TOKEN,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger, presenter: AuthSessionPresenter) =>
new LoginUseCase(authRepo, passwordHashing, logger, presenter),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_PRESENTER_TOKEN],
},
{
provide: SIGNUP_USE_CASE_TOKEN,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger, presenter: AuthSessionPresenter) =>
new SignupUseCase(authRepo, passwordHashing, logger, presenter),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_PRESENTER_TOKEN],
},
{
provide: LOGOUT_USE_CASE_TOKEN,
useFactory: (sessionPort: IdentitySessionPort, logger: Logger, presenter: CommandResultPresenter) =>
new LogoutUseCase(sessionPort, logger, presenter),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_PRESENTER_TOKEN],
},
{
provide: AUTH_SESSION_PRESENTER_TOKEN,
provide: AuthSessionPresenter,
useClass: AuthSessionPresenter,
},
{
provide: COMMAND_RESULT_PRESENTER_TOKEN,
provide: CommandResultPresenter,
useClass: CommandResultPresenter,
},
{
provide: AUTH_SESSION_OUTPUT_PORT_TOKEN,
useExisting: AuthSessionPresenter,
},
{
provide: COMMAND_RESULT_OUTPUT_PORT_TOKEN,
useExisting: CommandResultPresenter,
},
{
provide: LOGIN_USE_CASE_TOKEN,
useFactory: (
authRepo: IAuthRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<LoginResult>,
) => new LoginUseCase(authRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_OUTPUT_PORT_TOKEN],
},
{
provide: SIGNUP_USE_CASE_TOKEN,
useFactory: (
authRepo: IAuthRepository,
passwordHashing: IPasswordHashingService,
logger: Logger,
output: UseCaseOutputPort<SignupResult>,
) => new SignupUseCase(authRepo, passwordHashing, logger, output),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN, AUTH_SESSION_OUTPUT_PORT_TOKEN],
},
{
provide: LOGOUT_USE_CASE_TOKEN,
useFactory: (sessionPort: IdentitySessionPort, logger: Logger, output: UseCaseOutputPort<LogoutResult>) =>
new LogoutUseCase(sessionPort, logger, output),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_OUTPUT_PORT_TOKEN],
},
];

View File

@@ -1,57 +1,77 @@
import { Inject } from '@nestjs/common';
// Core Use Cases
import { LoginUseCase, type LoginInput } from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase';
import { SignupUseCase, type SignupInput } from '@core/identity/application/use-cases/SignupUseCase';
// Core Interfaces and Tokens
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { User } from '@core/identity/domain/entities/User';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import type { Logger } from '@core/shared/application';
import { IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, LOGIN_USE_CASE_TOKEN, LOGOUT_USE_CASE_TOKEN, SIGNUP_USE_CASE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import { AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto';
import {
LoginUseCase,
type LoginInput,
type LoginApplicationError,
} from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase, type LogoutApplicationError } from '@core/identity/application/use-cases/LogoutUseCase';
import {
SignupUseCase,
type SignupInput,
type SignupApplicationError,
} from '@core/identity/application/use-cases/SignupUseCase';
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import {
AUTH_SESSION_OUTPUT_PORT_TOKEN,
COMMAND_RESULT_OUTPUT_PORT_TOKEN,
IDENTITY_SESSION_PORT_TOKEN,
LOGGER_TOKEN,
LOGIN_USE_CASE_TOKEN,
LOGOUT_USE_CASE_TOKEN,
SIGNUP_USE_CASE_TOKEN,
} from './AuthProviders';
import type { AuthSessionDTO } from './dtos/AuthDto';
import { LoginParams, SignupParams } from './dtos/AuthDto';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import { CommandResultPresenter, type CommandResultDTO } from './presenters/CommandResultPresenter';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
function mapApplicationErrorToMessage(error: { details?: { message?: string } } | undefined, fallback: string): string {
return error?.details?.message ?? fallback;
}
export class AuthService {
constructor(
@Inject(LOGGER_TOKEN) private logger: Logger,
@Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort,
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(IDENTITY_SESSION_PORT_TOKEN)
private readonly identitySessionPort: IdentitySessionPort,
@Inject(LOGIN_USE_CASE_TOKEN) private readonly loginUseCase: LoginUseCase,
@Inject(SIGNUP_USE_CASE_TOKEN) private readonly signupUseCase: SignupUseCase,
@Inject(LOGOUT_USE_CASE_TOKEN) private readonly logoutUseCase: LogoutUseCase,
@Inject(AUTH_SESSION_OUTPUT_PORT_TOKEN)
private readonly authSessionPresenter: AuthSessionPresenter,
@Inject(COMMAND_RESULT_OUTPUT_PORT_TOKEN)
private readonly commandResultPresenter: CommandResultPresenter,
) {}
async getCurrentSession(): Promise<AuthSessionDTO | null> {
// TODO this must call a use case
// TODO must call a use case (out of scope here)
this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) {
return null;
}
if (!coreSession) return null;
const user = await this.userRepository.findById(coreSession.user.id);
if (!user) {
this.logger.warn(
`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`,
);
await this.identitySessionPort.clearSession();
return null;
}
// TODO no mapping in here, must use presenter
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
const apiSession = this.buildAuthSessionDTO(coreSession.token, authenticatedUserDTO);
return apiSession;
// Avoid service-level mapping; in this module we only support presenter-based output for use cases.
// For now return a minimal session shape.
return {
token: coreSession.token,
user: {
userId: coreSession.user.id,
email: coreSession.user.email ?? '',
displayName: coreSession.user.displayName,
},
};
}
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
this.authSessionPresenter.reset();
const input: SignupInput = {
email: params.email,
password: params.password,
@@ -61,18 +81,16 @@ export class AuthService {
const result = await this.signupUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Signup failed');
const error = result.unwrapErr() as SignupApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Signup failed'));
}
const authSessionPresenter = new AuthSessionPresenter();
const userDTO = authSessionPresenter.getResponseModel();
const coreUserDTO = {
const userDTO = this.authSessionPresenter.responseModel;
const session = await this.identitySessionPort.createSession({
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
};
const session = await this.identitySessionPort.createSession(coreUserDTO);
});
return {
token: session.token,
@@ -83,6 +101,8 @@ export class AuthService {
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
this.authSessionPresenter.reset();
const input: LoginInput = {
email: params.email,
password: params.password,
@@ -91,18 +111,16 @@ export class AuthService {
const result = await this.loginUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Login failed');
const error = result.unwrapErr() as LoginApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Login failed'));
}
const authSessionPresenter = new AuthSessionPresenter();
const userDTO = authSessionPresenter.getResponseModel();
const coreUserDTO = {
const userDTO = this.authSessionPresenter.responseModel;
const session = await this.identitySessionPort.createSession({
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
};
const session = await this.identitySessionPort.createSession(coreUserDTO);
});
return {
token: session.token,
@@ -113,14 +131,15 @@ export class AuthService {
async logout(): Promise<CommandResultDTO> {
this.logger.debug('[AuthService] Attempting logout.');
const commandResultPresenter = new CommandResultPresenter();
const result = await this.logoutUseCase.execute(); // TODO
this.commandResultPresenter.reset();
const result = await this.logoutUseCase.execute(undefined);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Logout failed');
const error = result.unwrapErr() as LogoutApplicationError;
throw new Error(mapApplicationErrorToMessage(error, 'Logout failed'));
}
return commandResultPresenter.getResponseModel();
return this.commandResultPresenter.responseModel;
}
}

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthSessionPresenter } from './AuthSessionPresenter';
import { User } from '@core/identity/domain/entities/User';
import { UserId } from '@core/identity/domain/value-objects/UserId';
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
describe('AuthSessionPresenter', () => {
let presenter: AuthSessionPresenter;
@@ -10,26 +11,45 @@ describe('AuthSessionPresenter', () => {
presenter = new AuthSessionPresenter();
});
it('maps successful result into response model', () => {
it('maps user result into response model', () => {
const user = User.create({
id: UserId.fromString('user-1'),
displayName: 'Test User',
email: 'user@example.com',
passwordHash: { value: 'hash' } as any,
passwordHash: PasswordHash.fromHash('hash'),
});
const expectedUser = {
presenter.present({ user } as unknown as Parameters<AuthSessionPresenter['present']>[0]);
expect(presenter.getResponseModel()).toEqual({
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
};
});
presenter.present({ user });
expect(presenter.getResponseModel()).toEqual(expectedUser);
expect(presenter.responseModel).toEqual({
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
});
});
it('getResponseModel throws when not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Response model not set');
it('throws when not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
it('reset clears model', () => {
const user = User.create({
id: UserId.fromString('user-1'),
displayName: 'Test User',
email: 'user@example.com',
passwordHash: PasswordHash.fromHash('hash'),
});
presenter.present({ user } as unknown as Parameters<AuthSessionPresenter['present']>[0]);
presenter.reset();
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});

View File

@@ -1,24 +1,36 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { AuthenticatedUserDTO } from '../dtos/AuthDto';
import type { User } from '@core/identity/domain/entities/User';
import type { LoginResult } from '@core/identity/application/use-cases/LoginUseCase';
import type { SignupResult } from '@core/identity/application/use-cases/SignupUseCase';
export class AuthSessionPresenter implements UseCaseOutputPort<{ user: User }> {
private responseModel: AuthenticatedUserDTO | null = null;
type AuthSessionResult = LoginResult | SignupResult;
present(result: { user: User }): void {
const { user } = result;
export class AuthSessionPresenter implements UseCaseOutputPort<AuthSessionResult> {
private model: AuthenticatedUserDTO | null = null;
this.responseModel = {
userId: user.getId().value,
email: user.getEmail() ?? '',
displayName: user.getDisplayName() ?? '',
reset(): void {
this.model = null;
}
present(result: AuthSessionResult): void {
this.model = {
userId: result.user.getId().value,
email: result.user.getEmail() ?? '',
displayName: result.user.getDisplayName() ?? '',
};
}
getResponseModel(): AuthenticatedUserDTO {
if (!this.responseModel) {
throw new Error('Response model not set');
if (!this.model) {
throw new Error('Presenter not presented');
}
return this.responseModel;
return this.model;
}
get responseModel(): AuthenticatedUserDTO {
if (!this.model) {
throw new Error('Presenter not presented');
}
return this.model;
}
}

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CommandResultPresenter } from './CommandResultPresenter';
describe('CommandResultPresenter', () => {
let presenter: CommandResultPresenter;
beforeEach(() => {
presenter = new CommandResultPresenter();
});
it('maps logout result into response model', () => {
presenter.present({ success: true });
expect(presenter.getResponseModel()).toEqual({ success: true });
expect(presenter.responseModel).toEqual({ success: true });
});
it('throws when not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
it('reset clears model', () => {
presenter.present({ success: true });
presenter.reset();
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});

View File

@@ -1,23 +1,35 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { LogoutResult } from '@core/identity/application/use-cases/LogoutUseCase';
export interface CommandResultDTO {
success: boolean;
message?: string;
}
export class CommandResultPresenter implements UseCaseOutputPort<{ success: boolean }> {
private responseModel: CommandResultDTO | null = null;
export class CommandResultPresenter implements UseCaseOutputPort<LogoutResult> {
private model: CommandResultDTO | null = null;
present(result: { success: boolean }): void {
this.responseModel = {
reset(): void {
this.model = null;
}
present(result: LogoutResult): void {
this.model = {
success: result.success,
};
}
getResponseModel(): CommandResultDTO {
if (!this.responseModel) {
throw new Error('Response model not set');
if (!this.model) {
throw new Error('Presenter not presented');
}
return this.responseModel;
return this.model;
}
get responseModel(): CommandResultDTO {
if (!this.model) {
throw new Error('Presenter not presented');
}
return this.model;
}
}