This commit is contained in:
2025-12-21 19:53:22 +01:00
parent f2d8a23583
commit 3c64f328e2
105 changed files with 3191 additions and 1706 deletions

View File

@@ -5,21 +5,21 @@ import { SignupParams, LoginParams, AuthSessionDTO } from './dtos/AuthDto';
describe('AuthController', () => {
let controller: AuthController;
let service: ReturnType<typeof vi.mocked<AuthService>>;
let service: AuthService;
beforeEach(() => {
service = vi.mocked<AuthService>({
service = {
signupWithEmail: vi.fn(),
loginWithEmail: vi.fn(),
getCurrentSession: vi.fn(),
logout: vi.fn(),
});
} as unknown as AuthService;
controller = new AuthController(service);
});
describe('signup', () => {
it('should call service.signupWithEmail and return session', async () => {
it('should call service.signupWithEmail and return session DTO', async () => {
const params: SignupParams = {
email: 'test@example.com',
password: 'password123',
@@ -36,7 +36,7 @@ describe('AuthController', () => {
displayName: 'Test User',
},
};
service.signupWithEmail.mockResolvedValue(session);
(service.signupWithEmail as jest.Mock).mockResolvedValue(session);
const result = await controller.signup(params);
@@ -46,7 +46,7 @@ describe('AuthController', () => {
});
describe('login', () => {
it('should call service.loginWithEmail and return session', async () => {
it('should call service.loginWithEmail and return session DTO', async () => {
const params: LoginParams = {
email: 'test@example.com',
password: 'password123',
@@ -59,7 +59,7 @@ describe('AuthController', () => {
displayName: 'Test User',
},
};
service.loginWithEmail.mockResolvedValue(session);
(service.loginWithEmail as jest.Mock).mockResolvedValue(session);
const result = await controller.login(params);
@@ -69,7 +69,7 @@ describe('AuthController', () => {
});
describe('getSession', () => {
it('should call service.getCurrentSession and return session', async () => {
it('should call service.getCurrentSession and return session DTO', async () => {
const session: AuthSessionDTO = {
token: 'token123',
user: {
@@ -78,7 +78,7 @@ describe('AuthController', () => {
displayName: 'Test User',
},
};
service.getCurrentSession.mockResolvedValue(session);
(service.getCurrentSession as jest.Mock).mockResolvedValue(session);
const result = await controller.getSession();
@@ -87,7 +87,7 @@ describe('AuthController', () => {
});
it('should return null if no session', async () => {
service.getCurrentSession.mockResolvedValue(null);
(service.getCurrentSession as jest.Mock).mockResolvedValue(null);
const result = await controller.getSession();
@@ -96,13 +96,14 @@ describe('AuthController', () => {
});
describe('logout', () => {
it('should call service.logout', async () => {
service.logout.mockResolvedValue(undefined);
it('should call service.logout and return DTO', async () => {
const dto = { success: true };
(service.logout as jest.Mock).mockResolvedValue(dto);
await controller.logout();
const result = await controller.logout();
expect(service.logout).toHaveBeenCalled();
expect(result).toEqual(dto);
});
});
});

View File

@@ -8,26 +8,21 @@ export class AuthController {
@Post('signup')
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
const presenter = await this.authService.signupWithEmail(params);
return presenter.viewModel;
return this.authService.signupWithEmail(params);
}
@Post('login')
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
const presenter = await this.authService.loginWithEmail(params);
return presenter.viewModel;
return this.authService.loginWithEmail(params);
}
@Get('session')
async getSession(): Promise<AuthSessionDTO | null> {
const presenter = await this.authService.getCurrentSession();
return presenter ? presenter.viewModel : null;
return this.authService.getCurrentSession();
}
@Post('logout')
async logout(): Promise<{ success: boolean }> {
const presenter = await this.authService.logout();
return presenter.viewModel;
return this.authService.logout();
}
}

View File

@@ -10,6 +10,17 @@ import { InMemoryUserRepository } from '@adapters/identity/persistence/inmemory/
import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { CookieIdentitySessionAdapter } from '@adapters/identity/session/CookieIdentitySessionAdapter';
import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase';
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 { 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';
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
// Define the tokens for dependency injection
export const AUTH_REPOSITORY_TOKEN = 'IAuthRepository';
@@ -17,6 +28,9 @@ export const USER_REPOSITORY_TOKEN = 'IUserRepository';
export const PASSWORD_HASHING_SERVICE_TOKEN = 'IPasswordHashingService';
export const LOGGER_TOKEN = 'Logger';
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 AuthProviders: Provider[] = [
{
@@ -57,4 +71,22 @@ export const AuthProviders: Provider[] = [
useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGIN_USE_CASE_TOKEN,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger) =>
new LoginUseCase(authRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: SIGNUP_USE_CASE_TOKEN,
useFactory: (authRepo: IAuthRepository, passwordHashing: IPasswordHashingService, logger: Logger) =>
new SignupUseCase(authRepo, passwordHashing, logger),
inject: [AUTH_REPOSITORY_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, LOGGER_TOKEN],
},
{
provide: LOGOUT_USE_CASE_TOKEN,
useFactory: (sessionPort: IdentitySessionPort, logger: Logger) =>
new LogoutUseCase(sessionPort, logger),
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN],
},
];

View File

@@ -1,40 +1,33 @@
import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
// Core Use Cases
import { LoginUseCase } from '@core/identity/application/use-cases/LoginUseCase';
import { LoginUseCase, type LoginInput } from '@core/identity/application/use-cases/LoginUseCase';
import { LogoutUseCase } from '@core/identity/application/use-cases/LogoutUseCase';
import { SignupUseCase } from '@core/identity/application/use-cases/SignupUseCase';
import { SignupUseCase, type SignupInput } from '@core/identity/application/use-cases/SignupUseCase';
// Core Interfaces and Tokens
import { AuthenticatedUserDTO as CoreAuthenticatedUserDTO } from '@core/identity/application/dto/AuthenticatedUserDTO';
import { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
import { User } from '@core/identity/domain/entities/User';
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
import type { IUserRepository } from '@core/identity/domain/repositories/IUserRepository';
import type { IPasswordHashingService } from '@core/identity/domain/services/PasswordHashingService';
import type { Logger } from "@core/shared/application";
import { AUTH_REPOSITORY_TOKEN, IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, PASSWORD_HASHING_SERVICE_TOKEN, USER_REPOSITORY_TOKEN } from './AuthProviders';
import { AuthSessionDTO, LoginParams, SignupParams, AuthenticatedUserDTO } from './dtos/AuthDto';
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 { AuthenticatedUserDTO, AuthSessionDTO, LoginParams, SignupParams } from './dtos/AuthDto';
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
@Injectable()
export class AuthService {
private readonly loginUseCase: LoginUseCase;
private readonly signupUseCase: SignupUseCase;
private readonly logoutUseCase: LogoutUseCase;
constructor(
@Inject(AUTH_REPOSITORY_TOKEN) private authRepository: IAuthRepository,
@Inject(PASSWORD_HASHING_SERVICE_TOKEN) private passwordHashingService: IPasswordHashingService,
@Inject(LOGGER_TOKEN) private logger: Logger,
@Inject(IDENTITY_SESSION_PORT_TOKEN) private identitySessionPort: IdentitySessionPort,
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository, // Inject IUserRepository here
) {
this.loginUseCase = new LoginUseCase(this.authRepository, this.passwordHashingService);
this.signupUseCase = new SignupUseCase(this.authRepository, this.passwordHashingService);
this.logoutUseCase = new LogoutUseCase(this.identitySessionPort);
}
@Inject(USER_REPOSITORY_TOKEN) private userRepository: IUserRepository,
@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,
private readonly authSessionPresenter: AuthSessionPresenter,
private readonly commandResultPresenter: CommandResultPresenter,
) {}
private mapUserToAuthenticatedUserDTO(user: User): AuthenticatedUserDTO {
return {
@@ -44,74 +37,109 @@ export class AuthService {
};
}
private mapToCoreAuthenticatedUserDTO(apiDto: AuthenticatedUserDTO): CoreAuthenticatedUserDTO {
private buildAuthSessionDTO(token: string, user: AuthenticatedUserDTO): AuthSessionDTO {
return {
id: apiDto.userId,
displayName: apiDto.displayName,
email: apiDto.email,
token,
user: {
userId: user.userId,
email: user.email,
displayName: user.displayName,
},
};
}
async getCurrentSession(): Promise<AuthSessionPresenter | null> {
async getCurrentSession(): Promise<AuthSessionDTO | null> {
this.logger.debug('[AuthService] Attempting to get current session.');
const coreSession = await this.identitySessionPort.getCurrentSession();
if (!coreSession) {
return null;
}
const user = await this.userRepository.findById(coreSession.user.id); // Use userRepository to fetch full user
const user = await this.userRepository.findById(coreSession.user.id);
if (!user) {
// If session exists but user doesn't in DB, perhaps clear session?
this.logger.warn(`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`);
await this.identitySessionPort.clearSession(); // Clear potentially stale session
this.logger.warn(
`[AuthService] Session found for user ID ${coreSession.user.id}, but user not found in repository.`,
);
await this.identitySessionPort.clearSession();
return null;
}
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(User.fromStored(user));
const apiSession = this.buildAuthSessionDTO(coreSession.token, authenticatedUserDTO);
const presenter = new AuthSessionPresenter();
presenter.present({ token: coreSession.token, user: authenticatedUserDTO });
return presenter;
return apiSession;
}
async signupWithEmail(params: SignupParams): Promise<AuthSessionPresenter> {
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
const user = await this.signupUseCase.execute(params.email, params.password, params.displayName);
// Create session after successful signup
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto);
const input: SignupInput = {
email: params.email,
password: params.password,
displayName: params.displayName,
};
const presenter = new AuthSessionPresenter();
presenter.present({ token: session.token, user: authenticatedUserDTO });
return presenter;
}
const result = await this.signupUseCase.execute(input);
async loginWithEmail(params: LoginParams): Promise<AuthSessionPresenter> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
try {
const user = await this.loginUseCase.execute(params.email, params.password);
// Create session after successful login
const authenticatedUserDTO = this.mapUserToAuthenticatedUserDTO(user);
const coreDto = this.mapToCoreAuthenticatedUserDTO(authenticatedUserDTO);
const session = await this.identitySessionPort.createSession(coreDto);
const presenter = new AuthSessionPresenter();
presenter.present({ token: session.token, user: authenticatedUserDTO });
return presenter;
} catch (error) {
this.logger.error(`[AuthService] Login failed for email ${params.email}:`, error instanceof Error ? error : new Error(String(error)));
throw new InternalServerErrorException('Login failed due to invalid credentials or server error.');
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Signup failed');
}
const userDTO = this.authSessionPresenter.getResponseModel();
const coreUserDTO = {
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
};
const session = await this.identitySessionPort.createSession(coreUserDTO);
return {
token: session.token,
user: userDTO,
};
}
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
async logout(): Promise<CommandResultPresenter> {
const input: LoginInput = {
email: params.email,
password: params.password,
};
const result = await this.loginUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Login failed');
}
const userDTO = this.authSessionPresenter.getResponseModel();
const coreUserDTO = {
id: userDTO.userId,
displayName: userDTO.displayName,
email: userDTO.email,
};
const session = await this.identitySessionPort.createSession(coreUserDTO);
return {
token: session.token,
user: userDTO,
};
}
async logout(): Promise<CommandResultDTO> {
this.logger.debug('[AuthService] Attempting logout.');
const presenter = new CommandResultPresenter();
await this.logoutUseCase.execute();
presenter.present({ success: true });
return presenter;
const result = await this.logoutUseCase.execute();
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Logout failed');
}
return this.commandResultPresenter.getResponseModel();
}
}

View File

@@ -1,61 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AuthSessionPresenter } from './AuthSessionPresenter';
import { AuthenticatedUserDTO } from '../dtos/AuthDto';
import { User } from '@core/identity/domain/entities/User';
import { UserId } from '@core/identity/domain/value-objects/UserId';
describe('AuthSessionPresenter', () => {
let presenter: AuthSessionPresenter;
let mockIdentitySessionPort: any;
beforeEach(() => {
presenter = new AuthSessionPresenter();
mockIdentitySessionPort = {
createSession: vi.fn(),
};
presenter = new AuthSessionPresenter(mockIdentitySessionPort);
});
it('maps token and user DTO correctly', () => {
const user: AuthenticatedUserDTO = {
userId: 'user-1',
email: 'user@example.com',
it('maps successful result into response model', async () => {
const user = User.create({
id: UserId.fromString('user-1'),
displayName: 'Test User',
};
email: 'user@example.com',
passwordHash: { value: 'hash' } as any,
});
presenter.present({ token: 'token-123', user });
expect(presenter.viewModel).toEqual({
const expectedSession = {
token: 'token-123',
user: {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
},
});
});
it('reset clears state and causes viewModel to throw', () => {
const user: AuthenticatedUserDTO = {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
};
presenter.present({ token: 'token-123', user });
expect(presenter.viewModel).toBeDefined();
mockIdentitySessionPort.createSession.mockResolvedValue(expectedSession);
presenter.reset();
await presenter.present({ user });
expect(() => presenter.viewModel).toThrow('Presenter not presented');
expect(presenter.getResponseModel()).toEqual(expectedSession);
});
it('getViewModel returns null when not presented', () => {
expect(presenter.getViewModel()).toBeNull();
});
it('getViewModel returns the same DTO after present', () => {
const user: AuthenticatedUserDTO = {
userId: 'user-1',
email: 'user@example.com',
displayName: 'Test User',
};
presenter.present({ token: 'token-123', user });
expect(presenter.getViewModel()).toEqual(presenter.viewModel);
it('getResponseModel throws when not presented', () => {
expect(() => presenter.getResponseModel()).toThrow('Response model not set');
});
});

View File

@@ -1,31 +1,24 @@
import { AuthSessionDTO, AuthenticatedUserDTO } from '../dtos/AuthDto';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { AuthenticatedUserDTO } from '../dtos/AuthDto';
import type { User } from '@core/identity/domain/entities/User';
export interface AuthSessionViewModel extends AuthSessionDTO {}
export class AuthSessionPresenter implements UseCaseOutputPort<{ user: User }> {
private responseModel: AuthenticatedUserDTO | null = null;
export class AuthSessionPresenter {
private result: AuthSessionViewModel | null = null;
present(result: { user: User }): void {
const { user } = result;
reset() {
this.result = null;
}
present(input: { token: string; user: AuthenticatedUserDTO }): void {
this.result = {
token: input.token,
user: {
userId: input.user.userId,
email: input.user.email,
displayName: input.user.displayName,
},
this.responseModel = {
userId: user.getId().value,
email: user.getEmail() ?? '',
displayName: user.getDisplayName() ?? '',
};
}
get viewModel(): AuthSessionViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
getViewModel(): AuthSessionViewModel | null {
return this.result;
getResponseModel(): AuthenticatedUserDTO {
if (!this.responseModel) {
throw new Error('Response model not set');
}
return this.responseModel;
}
}

View File

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