diff --git a/apps/api/src/domain/auth/AuthProviders.ts b/apps/api/src/domain/auth/AuthProviders.ts index 822f827ec..5990ce71d 100644 --- a/apps/api/src/domain/auth/AuthProviders.ts +++ b/apps/api/src/domain/auth/AuthProviders.ts @@ -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, + ) => 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, + ) => 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) => + new LogoutUseCase(sessionPort, logger, output), + inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_OUTPUT_PORT_TOKEN], + }, ]; diff --git a/apps/api/src/domain/auth/AuthService.ts b/apps/api/src/domain/auth/AuthService.ts index 49b492d79..51c98e532 100644 --- a/apps/api/src/domain/auth/AuthService.ts +++ b/apps/api/src/domain/auth/AuthService.ts @@ -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 { - // 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 { 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 { 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 { 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; } } diff --git a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts index 10172c3c3..e7005a6ee 100644 --- a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts +++ b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.test.ts @@ -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[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[0]); + presenter.reset(); + + expect(() => presenter.responseModel).toThrow('Presenter not presented'); }); }); diff --git a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts index 0e0e81ad1..8644c88d1 100644 --- a/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts +++ b/apps/api/src/domain/auth/presenters/AuthSessionPresenter.ts @@ -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 { + 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; } } diff --git a/apps/api/src/domain/auth/presenters/CommandResultPresenter.test.ts b/apps/api/src/domain/auth/presenters/CommandResultPresenter.test.ts new file mode 100644 index 000000000..60695392c --- /dev/null +++ b/apps/api/src/domain/auth/presenters/CommandResultPresenter.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/auth/presenters/CommandResultPresenter.ts b/apps/api/src/domain/auth/presenters/CommandResultPresenter.ts index 5e119c5b0..e87581516 100644 --- a/apps/api/src/domain/auth/presenters/CommandResultPresenter.ts +++ b/apps/api/src/domain/auth/presenters/CommandResultPresenter.ts @@ -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 { + 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; } }