From 53aa8453ed86567bed9e1c283b800885bf0a4b78 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 24 Mar 2026 16:44:45 +0100 Subject: [PATCH 01/81] MVCS to hexagonal architecture : Create domain layer with business entities Account, Article and Comment --- src/application/domain/account.py | 31 +++++++++++++++++++++++++++++++ src/application/domain/article.py | 28 ++++++++++++++++++++++++++++ src/application/domain/comment.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/application/domain/account.py create mode 100644 src/application/domain/article.py create mode 100644 src/application/domain/comment.py diff --git a/src/application/domain/account.py b/src/application/domain/account.py new file mode 100644 index 0000000..79f3324 --- /dev/null +++ b/src/application/domain/account.py @@ -0,0 +1,31 @@ +from datetime import datetime + + +class Account: + """ + Represents a user account in the system. + + Attributes: + account_id (int): Unique identifier for the account. + account_username (str): Unique username used for authentication. + account_password (str): Securely hashed password string. + account_email (str): Unique email address for the user. + account_role (str): Permissions role ('admin', 'author', or 'user'). + account_created_at (datetime): Timestamp of account creation. + """ + + def __init__( + self, + account_id: int, + account_username: str, + account_password: str, + account_email: str, + account_role: str, + account_created_at: datetime, + ): + self.account_id = account_id + self.account_username = account_username + self.account_password = account_password + self.account_email = account_email + self.account_role = account_role + self.account_created_at = account_created_at diff --git a/src/application/domain/article.py b/src/application/domain/article.py new file mode 100644 index 0000000..dcea78a --- /dev/null +++ b/src/application/domain/article.py @@ -0,0 +1,28 @@ +from datetime import datetime + + +class Article: + """ + Represents a blog article. + + Attributes: + article_id (int): Unique identifier for the article. + article_author_id (int): Reference to the author's Account. + article_title (str): Title of the article. + article_content (str): Full text content of the article. + article_published_at (datetime): Timestamp of publication. + """ + + def __init__( + self, + article_id: int, + article_author_id: int, + article_title: str, + article_content: str, + article_published_at: datetime, + ): + self.article_id = article_id + self.article_author_id = article_author_id + self.article_title = article_title + self.article_content = article_content + self.article_published_at = article_published_at diff --git a/src/application/domain/comment.py b/src/application/domain/comment.py new file mode 100644 index 0000000..78189b3 --- /dev/null +++ b/src/application/domain/comment.py @@ -0,0 +1,31 @@ +from datetime import datetime + + +class Comment: + """ + Represents a comment or a reply on an article. + + Attributes: + comment_id (int): Unique identifier for the comment. + comment_article_id (int): Reference to the associated article. + comment_written_account_id (int): Reference to the author's account. + comment_reply_to (int | None): Reference to a parent comment (for replies). + comment_content (str): Text content of the comment. + comment_posted_at (datetime): Timestamp of when the comment was posted. + """ + + def __init__( + self, + comment_id: int, + comment_article_id: int, + comment_written_account_id: int, + comment_reply_to: int | None, + comment_content: str, + comment_posted_at: datetime, + ): + self.comment_id = comment_id + self.comment_article_id = comment_article_id + self.comment_written_account_id = comment_written_account_id + self.comment_reply_to = comment_reply_to + self.comment_content = comment_content + self.comment_posted_at = comment_posted_at From 65dd17cb28f038ee84d8b3e53dde96842de9d5d9 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 25 Mar 2026 00:17:12 +0100 Subject: [PATCH 02/81] MVCS to hexagonal architecture : Introduce account data access port and login authentication use case with tests --- .../output_ports/account_repository.py | 25 +++++++++ src/application/services/login_service.py | 39 ++++++++++++++ .../tests_services/test_login_service.py | 54 +++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/application/output_ports/account_repository.py create mode 100644 src/application/services/login_service.py create mode 100644 tests_hexagonal/tests_services/test_login_service.py diff --git a/src/application/output_ports/account_repository.py b/src/application/output_ports/account_repository.py new file mode 100644 index 0000000..a013cf2 --- /dev/null +++ b/src/application/output_ports/account_repository.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod + +from src.application.domain.account import Account + + +class AccountRepository(ABC): + """ + Output port defining the contract for Account persistence operations. + Any infrastructure adapter (SQLAlchemy, MongoDB, etc.) must implement + this interface. + """ + + @abstractmethod + def find_by_username(self, username: str) -> Account | None: + """ + Retrieves an account by its username. + + Args: + username (str): The username to search for. + + Returns: + Account | None: The Account domain entity if found, + None otherwise. + """ + pass diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py new file mode 100644 index 0000000..e95190a --- /dev/null +++ b/src/application/services/login_service.py @@ -0,0 +1,39 @@ +from src.application.domain.account import Account +from src.application.output_ports.account_repository import AccountRepository + + +class LoginService: + """ + Use case responsible for handling user authentication logic. + Depends on the AccountRepository output port for data access. + """ + + def __init__(self, account_repository: AccountRepository): + """ + Initialize the service with an AccountRepository (Dependency Injection). + + Args: + account_repository (AccountRepository): The repository port + for account data access. + """ + self.account_repository = account_repository + + def authenticate_user(self, username: str, password: str) -> Account | None: + """ + Validates the user's credentials by retrieving the account + from the repository and comparing the password. + + Args: + username (str): The username provided by the user. + password (str): The plaintext password provided by the user. + + Returns: + Account | None: The authenticated Account instance if + credentials match, None otherwise. + """ + account = self.account_repository.find_by_username(username) + + if account and account.account_password == password: + return account + + return None diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py new file mode 100644 index 0000000..4f0a2d5 --- /dev/null +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -0,0 +1,54 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from src.application.domain.account import Account +from src.application.output_ports.account_repository import AccountRepository +from src.application.services.login_service import LoginService + + +def test_authenticate_user_success(): + mock_repo = MagicMock(spec=AccountRepository) + login_service = LoginService(account_repository=mock_repo) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now() + ) + + mock_repo.find_by_username.return_value = fake_account + result = login_service.authenticate_user(username="leia", password="password123") + mock_repo.find_by_username.assert_called_once_with("leia") + assert result is not None + assert result.account_username == "leia" + + +def test_authenticate_user_wrong_password(): + mock_repo = MagicMock(spec=AccountRepository) + login_service = LoginService(account_repository=mock_repo) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now() + ) + + mock_repo.find_by_username.return_value = fake_account + result = login_service.authenticate_user(username="leia", password="bad_password") + mock_repo.find_by_username.assert_called_once_with("leia") + assert result is None + + +def test_authenticate_user_non_existent(): + mock_repo = MagicMock(spec=AccountRepository) + login_service = LoginService(account_repository=mock_repo) + mock_repo.find_by_username.return_value = None + result = login_service.authenticate_user(username="phantom", password="nothing") + mock_repo.find_by_username.assert_called_once_with("phantom") + assert result is None From af5ea630bacb1035afa9eb048dc8385040bb4ed9 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 25 Mar 2026 01:45:04 +0100 Subject: [PATCH 03/81] MVCS to hexagonal architecture : Implement account creation logic and add tests for username/email uniqueness --- .../output_ports/account_repository.py | 24 ++++++ .../services/registration_service.py | 52 ++++++++++++ .../test_registration_service.py | 82 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/application/services/registration_service.py create mode 100644 tests_hexagonal/tests_services/test_registration_service.py diff --git a/src/application/output_ports/account_repository.py b/src/application/output_ports/account_repository.py index a013cf2..b33033b 100644 --- a/src/application/output_ports/account_repository.py +++ b/src/application/output_ports/account_repository.py @@ -23,3 +23,27 @@ def find_by_username(self, username: str) -> Account | None: None otherwise. """ pass + + @abstractmethod + def find_by_email(self, email: str) -> Account | None: + """ + Retrieves an account by its email address. + + Args: + email (str): The email address to search for. + + Returns: + Account | None: The Account domain entity if found, + None otherwise. + """ + pass + + @abstractmethod + def save(self, account: Account) -> None: + """ + Saves a new account to the database. + + Args: + account (Account): The Account domain entity to save. + """ + pass diff --git a/src/application/services/registration_service.py b/src/application/services/registration_service.py new file mode 100644 index 0000000..f381c98 --- /dev/null +++ b/src/application/services/registration_service.py @@ -0,0 +1,52 @@ +from src.application.domain.account import Account +from src.application.output_ports.account_repository import AccountRepository + + +class RegistrationService: + """ + Service responsible for handling user registration and account creation logic. + Depends on the AccountRepository output port for data access. + """ + + def __init__(self, account_repository: AccountRepository): + """ + Initialize the service with an AccountRepository (Dependency Injection). + + Args: + account_repository (AccountRepository): The repository port + for account data access. + """ + self.account_repository = account_repository + + def create_account(self, username: str, password: str, email: str) -> Account | str: + """ + Creates a new user account with the default 'user' role if the + username and email are not already taken. + + Args: + username (str): The username for the new account. + password (str): The plaintext password for the new account. + email (str): The email address for the new account. + + Returns: + Account | str: The newly created Account domain entity, or an + error message string if creation fails. + """ + + if self.account_repository.find_by_username(username): + return "This username is already taken." + + if self.account_repository.find_by_email(email): + return "This email is already taken." + + new_account = Account( + account_id=0, + account_username=username, + account_password=password, + account_email=email, + account_role="user", + account_created_at=None, + ) + + self.account_repository.save(new_account) + return new_account diff --git a/tests_hexagonal/tests_services/test_registration_service.py b/tests_hexagonal/tests_services/test_registration_service.py new file mode 100644 index 0000000..137ebff --- /dev/null +++ b/tests_hexagonal/tests_services/test_registration_service.py @@ -0,0 +1,82 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from src.application.domain.account import Account +from src.application.output_ports.account_repository import AccountRepository +from src.application.services.registration_service import RegistrationService + + +def test_create_account_success(): + mock_repo = MagicMock(spec=AccountRepository) + service = RegistrationService(account_repository=mock_repo) + mock_repo.find_by_username.return_value = None + mock_repo.find_by_email.return_value = None + + result = service.create_account( + username="leia", + password="password123", + email="leia@galaxy.com" + ) + + mock_repo.find_by_username.assert_called_once_with("leia") + mock_repo.find_by_email.assert_called_once_with("leia@galaxy.com") + mock_repo.save.assert_called_once_with(result) + assert isinstance(result, Account) + assert result.account_username == "leia" + assert result.account_email == "leia@galaxy.com" + assert result.account_role == "user" + + +def test_create_account_username_taken(): + mock_repo = MagicMock(spec=AccountRepository) + service = RegistrationService(account_repository=mock_repo) + + existing_account = Account( + account_id=1, + account_username="leia", + account_password="existing_pass", + account_email="existing@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_repo.find_by_username.return_value = existing_account + + result = service.create_account( + username="leia", + password="password123", + email="new@galaxy.com" + ) + + mock_repo.find_by_username.assert_called_once_with("leia") + mock_repo.find_by_email.assert_not_called() + mock_repo.save.assert_not_called() + assert result == "This username is already taken." + + +def test_create_account_email_taken(): + mock_repo = MagicMock(spec=AccountRepository) + service = RegistrationService(account_repository=mock_repo) + + existing_account = Account( + account_id=2, + account_username="han", + account_password="other_pass", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_repo.find_by_username.return_value = None + mock_repo.find_by_email.return_value = existing_account + + result = service.create_account( + username="new_user", + password="password123", + email="leia@galaxy.com" + ) + + mock_repo.find_by_username.assert_called_once_with("new_user") + mock_repo.find_by_email.assert_called_once_with("leia@galaxy.com") + mock_repo.save.assert_not_called() + assert result == "This email is already taken." From 7f281f0dd7081dd64a4bf6de5e9b579453a7a6ea Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 25 Mar 2026 23:43:56 +0100 Subject: [PATCH 04/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Implement=20article=20creation=20and=20retrieve=20business?= =?UTF-8?q?=20logic=20with=20service=E2=80=91level=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output_ports/article_repository.py | 45 +++++++++++ .../services/article_management_service.py | 63 +++++++++++++++ .../test_article_management_service.py | 80 +++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 src/application/output_ports/article_repository.py create mode 100644 src/application/services/article_management_service.py create mode 100644 tests_hexagonal/tests_services/test_article_management_service.py diff --git a/src/application/output_ports/article_repository.py b/src/application/output_ports/article_repository.py new file mode 100644 index 0000000..490a0df --- /dev/null +++ b/src/application/output_ports/article_repository.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod + +from src.application.domain.article import Article + + +class ArticleRepository(ABC): + """ + Output port defining the contract for Article persistence operations. + Any infrastructure adapter (SQLAlchemy, MongoDB, etc.) must implement + this interface. + """ + + @abstractmethod + def get_all_ordered_by_date(self) -> list[Article]: + """ + Retrieves all articles ordered by publication date (descending). + + Returns: + list[Article]: A list of Article domain entities. + """ + pass + + @abstractmethod + def get_by_id(self, article_id: int) -> Article | None: + """ + Retrieves a single article by its ID. + + Args: + article_id (int): The unique identifier of the article. + + Returns: + Article | None: The Article domain entity if found, + None otherwise. + """ + pass + + @abstractmethod + def save(self, article: Article) -> None: + """ + Saves a new article to the database. + + Args: + article (Article): The Article domain entity to save. + """ + pass diff --git a/src/application/services/article_management_service.py b/src/application/services/article_management_service.py new file mode 100644 index 0000000..b0ce269 --- /dev/null +++ b/src/application/services/article_management_service.py @@ -0,0 +1,63 @@ +from src.application.domain.article import Article +from src.application.output_ports.article_repository import ArticleRepository + + +class ArticleManagementService: + """ + Service responsible for business logic operations related to Articles. + Depends on the ArticleRepository output port for data access. + """ + + def __init__(self, article_repository: ArticleRepository): + """ + Initialize the service with an ArticleRepository (Dependency Injection). + + Args: + article_repository (ArticleRepository): The repository port + for article data access. + """ + self.article_repository = article_repository + + def create_article(self, title: str, content: str, author_id: int) -> Article: + """ + Creates a new article and saves it via the repository. + + Args: + title (str): The title of the new article. + content (str): The body content of the new article. + author_id (int): The unique identifier of the user creating the article. + + Returns: + Article: The newly created Article domain entity. + """ + new_article = Article( + article_id=0, + article_author_id=author_id, + article_title=title, + article_content=content, + article_published_at=None, + ) + + self.article_repository.save(new_article) + return new_article + + def get_all_ordered_by_date(self) -> list[Article]: + """ + Retrieves all articles ordered by their publication date. + + Returns: + list[Article]: A list of Article domain entities. + """ + return self.article_repository.get_all_ordered_by_date() + + def get_by_id(self, article_id: int) -> Article | None: + """ + Retrieves a single article by its ID. + + Args: + article_id (int): The unique identifier of the article. + + Returns: + Article | None: The Article domain entity if found, None otherwise. + """ + return self.article_repository.get_by_id(article_id) diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py new file mode 100644 index 0000000..f5b3079 --- /dev/null +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -0,0 +1,80 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from src.application.domain.article import Article +from src.application.output_ports.article_repository import ArticleRepository +from src.application.services.article_management_service import ArticleManagementService + + +def test_create_article(): + mock_repo = MagicMock(spec=ArticleRepository) + service = ArticleManagementService(article_repository=mock_repo) + + result = service.create_article( + title="My First Article", + content="Hello World!", + author_id=1, + ) + + mock_repo.save.assert_called_once_with(result) + assert isinstance(result, Article) + assert result.article_title == "My First Article" + assert result.article_content == "Hello World!" + assert result.article_author_id == 1 + + +def test_get_all_ordered_by_date(): + mock_repo = MagicMock(spec=ArticleRepository) + service = ArticleManagementService(article_repository=mock_repo) + + fake_articles = [ + Article( + article_id=2, + article_author_id=1, + article_title="Recent Article", + article_content="Content 2", + article_published_at=datetime(2026, 3, 25), + ), + Article( + article_id=1, + article_author_id=1, + article_title="Old Article", + article_content="Content 1", + article_published_at=datetime(2026, 1, 1), + ), + ] + + mock_repo.get_all_ordered_by_date.return_value = fake_articles + result = service.get_all_ordered_by_date() + mock_repo.get_all_ordered_by_date.assert_called_once() + assert len(result) == 2 + index_first_article_list = 0 + assert result[index_first_article_list].article_title == "Recent Article" + + +def test_get_by_id_found(): + mock_repo = MagicMock(spec=ArticleRepository) + service = ArticleManagementService(article_repository=mock_repo) + + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="Found Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + mock_repo.get_by_id.return_value = fake_article + result = service.get_by_id(article_id=1) + mock_repo.get_by_id.assert_called_once_with(1) + assert result is not None + assert result.article_title == "Found Article" + + +def test_get_by_id_not_found(): + mock_repo = MagicMock(spec=ArticleRepository) + service = ArticleManagementService(article_repository=mock_repo) + mock_repo.get_by_id.return_value = None + result = service.get_by_id(article_id=999) + mock_repo.get_by_id.assert_called_once_with(999) + assert result is None From 820cd06618e5e62018ac1cdd73ce396ef289969b Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 26 Mar 2026 04:50:34 +0100 Subject: [PATCH 05/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Improve=20article=20creation=20by=20enforcing=20author=20exi?= =?UTF-8?q?stence=20and=20role=E2=80=91based=20access=20and=20rename=20lis?= =?UTF-8?q?ting=20to=20get=5Fall=5Fordered=5Fby=5Fdate=5Fdesc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I introduced a "fake_author" object to clarify the intent of the article creation tests. "test_create_article_success()" needs an existing account with a valid role to reflect the real security rules applied during article creation. "test_create_article_unauthorized_role()" requires an account with an invalid role to verify that access is correctly denied. Unlike the other tests, there is no need to create a fake_author here, because the focus is on article‑related issues rather than on elements that require using an account. --- .../output_ports/account_repository.py | 14 ++ .../output_ports/article_repository.py | 2 +- .../services/article_management_service.py | 30 ++-- .../test_article_management_service.py | 136 +++++++++++++++--- 4 files changed, 149 insertions(+), 33 deletions(-) diff --git a/src/application/output_ports/account_repository.py b/src/application/output_ports/account_repository.py index b33033b..79024e9 100644 --- a/src/application/output_ports/account_repository.py +++ b/src/application/output_ports/account_repository.py @@ -24,6 +24,20 @@ def find_by_username(self, username: str) -> Account | None: """ pass + @abstractmethod + def get_by_id(self, account_id: int) -> Account | None: + """ + Retrieves an account by its ID. + + Args: + account_id (int): The account ID. + + Returns: + Account | None: The Account domain entity if found, + None otherwise. + """ + pass + @abstractmethod def find_by_email(self, email: str) -> Account | None: """ diff --git a/src/application/output_ports/article_repository.py b/src/application/output_ports/article_repository.py index 490a0df..6996bd2 100644 --- a/src/application/output_ports/article_repository.py +++ b/src/application/output_ports/article_repository.py @@ -11,7 +11,7 @@ class ArticleRepository(ABC): """ @abstractmethod - def get_all_ordered_by_date(self) -> list[Article]: + def get_all_ordered_by_date_desc(self) -> list[Article]: """ Retrieves all articles ordered by publication date (descending). diff --git a/src/application/services/article_management_service.py b/src/application/services/article_management_service.py index b0ce269..64e3968 100644 --- a/src/application/services/article_management_service.py +++ b/src/application/services/article_management_service.py @@ -1,35 +1,45 @@ from src.application.domain.article import Article +from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository class ArticleManagementService: """ Service responsible for business logic operations related to Articles. - Depends on the ArticleRepository output port for data access. + Depends on the ArticleRepository and AccountRepository output ports. """ - def __init__(self, article_repository: ArticleRepository): + def __init__(self, article_repository: ArticleRepository, account_repository: AccountRepository): """ - Initialize the service with an ArticleRepository (Dependency Injection). + Initialize the service with repositories (Dependency Injection). Args: - article_repository (ArticleRepository): The repository port - for article data access. + article_repository (ArticleRepository): Port for article data. + account_repository (AccountRepository): Port for account data. """ self.article_repository = article_repository + self.account_repository = account_repository - def create_article(self, title: str, content: str, author_id: int) -> Article: + def create_article(self, title: str, content: str, author_id: int, author_role: str) -> Article | None: """ - Creates a new article and saves it via the repository. + Creates a new article and saves it via the repository if the account exists and the user has + the correct permissions. Args: title (str): The title of the new article. content (str): The body content of the new article. author_id (int): The unique identifier of the user creating the article. + author_role (str): The role of the user (e.g. 'admin', 'author', 'user'). Returns: - Article: The newly created Article domain entity. + Article | None: The newly created Article domain entity, + or None if unauthorized or account not found. """ + account = self.account_repository.get_by_id(author_id) + valid_roles = ["admin", "author"] + if not account or author_role not in valid_roles: + return None + new_article = Article( article_id=0, article_author_id=author_id, @@ -41,14 +51,14 @@ def create_article(self, title: str, content: str, author_id: int) -> Article: self.article_repository.save(new_article) return new_article - def get_all_ordered_by_date(self) -> list[Article]: + def get_all_ordered_by_date_desc(self) -> list[Article]: """ Retrieves all articles ordered by their publication date. Returns: list[Article]: A list of Article domain entities. """ - return self.article_repository.get_all_ordered_by_date() + return self.article_repository.get_all_ordered_by_date_desc() def get_by_id(self, article_id: int) -> Article | None: """ diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py index f5b3079..9c02d70 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -1,31 +1,111 @@ from datetime import datetime from unittest.mock import MagicMock +from src.application.domain.account import Account from src.application.domain.article import Article +from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.services.article_management_service import ArticleManagementService -def test_create_article(): - mock_repo = MagicMock(spec=ArticleRepository) - service = ArticleManagementService(article_repository=mock_repo) +def test_create_article_success(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="admin", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account result = service.create_article( title="My First Article", - content="Hello World!", - author_id=1, + content="Hello World !", + author_id=fake_account.account_id, + author_role=fake_account.account_role ) - mock_repo.save.assert_called_once_with(result) + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_article_repo.save.assert_called_once_with(result) assert isinstance(result, Article) assert result.article_title == "My First Article" - assert result.article_content == "Hello World!" - assert result.article_author_id == 1 + assert result.article_content == "Hello World !" + assert result.article_author_id == fake_account.account_id + + +def test_create_article_unauthorized_role(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + fake_account = Account( + account_id=2, + account_username="boris", + account_password="password123", + account_email="boris@ordinary.com", + account_role="user", + account_created_at=datetime.now(), + ) -def test_get_all_ordered_by_date(): - mock_repo = MagicMock(spec=ArticleRepository) - service = ArticleManagementService(article_repository=mock_repo) + mock_account_repo.get_by_id.return_value = fake_account + + result = service.create_article( + title="Hacked Article", + content="Bad Content !", + author_id=fake_account.account_id, + author_role=fake_account.account_role, + ) + + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_article_repo.save.assert_not_called() + assert result is None + + +def test_create_article_account_not_found(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_account_repo.get_by_id.return_value = None + + result = service.create_article( + title="Ghost Article", + content="Content from beyond!", + author_id=999, + author_role="author", + ) + + mock_account_repo.get_by_id.assert_called_once_with(999) + mock_article_repo.save.assert_not_called() + assert result is None + + +def test_get_all_ordered_by_date_desc(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) fake_articles = [ Article( @@ -44,17 +124,22 @@ def test_get_all_ordered_by_date(): ), ] - mock_repo.get_all_ordered_by_date.return_value = fake_articles - result = service.get_all_ordered_by_date() - mock_repo.get_all_ordered_by_date.assert_called_once() + mock_article_repo.get_all_ordered_by_date_desc.return_value = fake_articles + result = service.get_all_ordered_by_date_desc() + mock_article_repo.get_all_ordered_by_date_desc.assert_called_once() assert len(result) == 2 index_first_article_list = 0 assert result[index_first_article_list].article_title == "Recent Article" def test_get_by_id_found(): - mock_repo = MagicMock(spec=ArticleRepository) - service = ArticleManagementService(article_repository=mock_repo) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) fake_article = Article( article_id=1, @@ -64,17 +149,24 @@ def test_get_by_id_found(): article_published_at=datetime.now(), ) - mock_repo.get_by_id.return_value = fake_article + mock_article_repo.get_by_id.return_value = fake_article result = service.get_by_id(article_id=1) - mock_repo.get_by_id.assert_called_once_with(1) + mock_article_repo.get_by_id.assert_called_once_with(1) assert result is not None assert result.article_title == "Found Article" def test_get_by_id_not_found(): - mock_repo = MagicMock(spec=ArticleRepository) - service = ArticleManagementService(article_repository=mock_repo) - mock_repo.get_by_id.return_value = None + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_article_repo.get_by_id.return_value = None result = service.get_by_id(article_id=999) - mock_repo.get_by_id.assert_called_once_with(999) + mock_article_repo.get_by_id.assert_called_once_with(999) + assert result is None assert result is None From c8519d3b866272ae7afb21325f81dc189e707cb8 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Fri, 27 Mar 2026 04:16:42 +0100 Subject: [PATCH 06/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Refactored=20ownership=20checks=20with=20role=E2=80=91based?= =?UTF-8?q?=20authorization=20and=20added=20the=20update=5Farticle=20actio?= =?UTF-8?q?n=20along=20with=20focused=20tests=20for=20both=20success=20and?= =?UTF-8?q?=20failure=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/article_management_service.py | 46 +++++- .../test_article_management_service.py | 145 ++++++++++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) diff --git a/src/application/services/article_management_service.py b/src/application/services/article_management_service.py index 64e3968..e62c72c 100644 --- a/src/application/services/article_management_service.py +++ b/src/application/services/article_management_service.py @@ -1,3 +1,4 @@ +from src.application.domain.account import Account from src.application.domain.article import Article from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository @@ -20,6 +21,22 @@ def __init__(self, article_repository: ArticleRepository, account_repository: Ac self.article_repository = article_repository self.account_repository = account_repository + def _get_authorized_account(self, user_id: int) -> Account | None: + """ + Checks if a user exists and has the required permissions (admin or author). + + Args: + user_id (int): The unique identifier of the user. + + Returns: + Account | None: The Account domain entity if authorized, None otherwise. + """ + account = self.account_repository.get_by_id(user_id) + valid_roles = ["admin", "author"] + if not account or account.account_role not in valid_roles: + return None + return account + def create_article(self, title: str, content: str, author_id: int, author_role: str) -> Article | None: """ Creates a new article and saves it via the repository if the account exists and the user has @@ -35,9 +52,7 @@ def create_article(self, title: str, content: str, author_id: int, author_role: Article | None: The newly created Article domain entity, or None if unauthorized or account not found. """ - account = self.account_repository.get_by_id(author_id) - valid_roles = ["admin", "author"] - if not account or author_role not in valid_roles: + if not self._get_authorized_account(author_id): return None new_article = Article( @@ -71,3 +86,28 @@ def get_by_id(self, article_id: int) -> Article | None: Article | None: The Article domain entity if found, None otherwise. """ return self.article_repository.get_by_id(article_id) + + def update_article(self, article_id: int, user_id: int, title: str, content: str) -> Article | None: + """ + Updates an existing article ensuring the requester is the original author. + + Args: + article_id (int): ID of the article to update. + user_id (int): ID of the user requesting the update. + title (str): New title for the article. + content (str): New content for the article. + + Returns: + Article | None: The updated Article domain entity, + or None if not found or unauthorized. + """ + article = self.article_repository.get_by_id(article_id) + if not article or article.article_author_id != user_id: + return None + + if not self._get_authorized_account(user_id): + return None + + article.article_title = title + article.article_content = content + return article diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py index 9c02d70..b60f66d 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -169,4 +169,149 @@ def test_get_by_id_not_found(): result = service.get_by_id(article_id=999) mock_article_repo.get_by_id.assert_called_once_with(999) assert result is None + + +def test_update_article_success(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="Old Title", + article_content="Old Content", + article_published_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="author", + account_created_at=datetime.now(), + ) + mock_account_repo.get_by_id.return_value = fake_account + + result = service.update_article( + article_id=1, + user_id=fake_account.account_id, + title="New Title", + content="New Content", + ) + + mock_article_repo.get_by_id.assert_called_once_with(1) + mock_account_repo.get_by_id.assert_called_once_with(1) + assert result is not None + assert result.article_title == "New Title" + assert result.article_content == "New Content" + + +def test_update_article_unauthorized(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="Old Title", + article_content="Old Content", + article_published_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + + fake_account = Account( + account_id=99, + account_username="hacker", + account_password="password123", + account_email="hacker@cyber.com", + account_role="admin", + account_created_at=datetime.now(), + ) + mock_account_repo.get_by_id.return_value = fake_account + + result = service.update_article( + article_id=1, + user_id=fake_account.account_id, + title="Hacked Title", + content="Hacked Content", + ) + + mock_article_repo.get_by_id.assert_called_once_with(1) + assert result is None + + +def test_update_article_insufficient_role(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="Old Title", + article_content="Old Content", + article_published_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + mock_account_repo.get_by_id.return_value = fake_account + + result = service.update_article( + article_id=1, + user_id=1, + title="Hacked Title", + content="Hacked Content", + ) + + mock_article_repo.get_by_id.assert_called_once_with(1) + mock_account_repo.get_by_id.assert_called_once_with(1) + assert result is None + + +def test_update_article_not_found(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_article_repo.get_by_id.return_value = None + + result = service.update_article( + article_id=999, + user_id=1, + title="New Title", + content="New Content", + ) + + mock_article_repo.get_by_id.assert_called_once_with(999) assert result is None From e4ca5feca979e35c5c5f5f31369ffd2b81d0134c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Fri, 27 Mar 2026 04:17:23 +0100 Subject: [PATCH 07/81] MVCS to hexagonal architecture : Add TODO markers for future exceptions --- src/application/services/article_management_service.py | 2 ++ src/application/services/login_service.py | 1 + src/application/services/registration_service.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/src/application/services/article_management_service.py b/src/application/services/article_management_service.py index e62c72c..850e1ed 100644 --- a/src/application/services/article_management_service.py +++ b/src/application/services/article_management_service.py @@ -34,6 +34,7 @@ def _get_authorized_account(self, user_id: int) -> Account | None: account = self.account_repository.get_by_id(user_id) valid_roles = ["admin", "author"] if not account or account.account_role not in valid_roles: + # TODO: Raise AccountNotFoundException or InsufficientPermissionsException return None return account @@ -103,6 +104,7 @@ def update_article(self, article_id: int, user_id: int, title: str, content: str """ article = self.article_repository.get_by_id(article_id) if not article or article.article_author_id != user_id: + # TODO: Raise ArticleNotFoundException or OwnershipException return None if not self._get_authorized_account(user_id): diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py index e95190a..411c4b2 100644 --- a/src/application/services/login_service.py +++ b/src/application/services/login_service.py @@ -36,4 +36,5 @@ def authenticate_user(self, username: str, password: str) -> Account | None: if account and account.account_password == password: return account + # TODO: Raise InvalidCredentialsException return None diff --git a/src/application/services/registration_service.py b/src/application/services/registration_service.py index f381c98..cca3557 100644 --- a/src/application/services/registration_service.py +++ b/src/application/services/registration_service.py @@ -34,9 +34,11 @@ def create_account(self, username: str, password: str, email: str) -> Account | """ if self.account_repository.find_by_username(username): + # TODO: Raise UsernameAlreadyTakenException return "This username is already taken." if self.account_repository.find_by_email(email): + # TODO: Raise EmailAlreadyTakenException return "This email is already taken." new_account = Account( From d3aa82b4ac5eae2d43545a7f09f4910b56823714 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Fri, 27 Mar 2026 05:54:37 +0100 Subject: [PATCH 08/81] MVCS to hexagonal architecture : Update unit tests to use `fake_account.account_id` and `fake_article.article_id` instead of hardcoded values --- .../test_article_management_service.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py index b60f66d..24dd33f 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -150,8 +150,8 @@ def test_get_by_id_found(): ) mock_article_repo.get_by_id.return_value = fake_article - result = service.get_by_id(article_id=1) - mock_article_repo.get_by_id.assert_called_once_with(1) + result = service.get_by_id(article_id=fake_article.article_id) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) assert result is not None assert result.article_title == "Found Article" @@ -201,14 +201,14 @@ def test_update_article_success(): mock_account_repo.get_by_id.return_value = fake_account result = service.update_article( - article_id=1, + article_id=fake_article.article_id, user_id=fake_account.account_id, title="New Title", content="New Content", ) - mock_article_repo.get_by_id.assert_called_once_with(1) - mock_account_repo.get_by_id.assert_called_once_with(1) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) assert result is not None assert result.article_title == "New Title" assert result.article_content == "New Content" @@ -244,13 +244,13 @@ def test_update_article_unauthorized(): mock_account_repo.get_by_id.return_value = fake_account result = service.update_article( - article_id=1, + article_id=fake_article.article_id, user_id=fake_account.account_id, title="Hacked Title", content="Hacked Content", ) - mock_article_repo.get_by_id.assert_called_once_with(1) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) assert result is None @@ -284,14 +284,14 @@ def test_update_article_insufficient_role(): mock_account_repo.get_by_id.return_value = fake_account result = service.update_article( - article_id=1, - user_id=1, + article_id=fake_article.article_id, + user_id=fake_account.account_id, title="Hacked Title", content="Hacked Content", ) - mock_article_repo.get_by_id.assert_called_once_with(1) - mock_account_repo.get_by_id.assert_called_once_with(1) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) assert result is None From e40632f1adf4dbf2ca64cdcdb2916443b914650c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Fri, 27 Mar 2026 06:17:44 +0100 Subject: [PATCH 09/81] MVCS to hexagonal architecture : Implemented delete_article with author/admin authorization, added the delete entry point, reordered permission checks in update_article and added targeted tests for allowed and forbidden deletions --- .../output_ports/article_repository.py | 10 ++ .../services/article_management_service.py | 34 +++- .../test_article_management_service.py | 154 +++++++++++++++++- 3 files changed, 193 insertions(+), 5 deletions(-) diff --git a/src/application/output_ports/article_repository.py b/src/application/output_ports/article_repository.py index 6996bd2..a990a7f 100644 --- a/src/application/output_ports/article_repository.py +++ b/src/application/output_ports/article_repository.py @@ -43,3 +43,13 @@ def save(self, article: Article) -> None: article (Article): The Article domain entity to save. """ pass + + @abstractmethod + def delete(self, article: Article) -> None: + """ + Deletes a given article. + + Args: + article (Article): The Article domain entity to delete. + """ + pass diff --git a/src/application/services/article_management_service.py b/src/application/services/article_management_service.py index 850e1ed..08150ad 100644 --- a/src/application/services/article_management_service.py +++ b/src/application/services/article_management_service.py @@ -102,14 +102,42 @@ def update_article(self, article_id: int, user_id: int, title: str, content: str Article | None: The updated Article domain entity, or None if not found or unauthorized. """ + account = self._get_authorized_account(user_id) + if not account: + return None + article = self.article_repository.get_by_id(article_id) if not article or article.article_author_id != user_id: # TODO: Raise ArticleNotFoundException or OwnershipException return None - if not self._get_authorized_account(user_id): - return None - article.article_title = title article.article_content = content return article + + def delete_article(self, article_id: int, user_id: int) -> bool: + """ + Deletes an article. Only the original author or an admin can delete it. + + Args: + article_id (int): ID of the article to delete. + user_id (int): ID of the user requesting the deletion. + + Returns: + bool: True if deletion was successful, False otherwise. + """ + account = self._get_authorized_account(user_id) + if not account: + return False + + article = self.article_repository.get_by_id(article_id) + if not article: + # TODO: Raise ArticleNotFoundException + return False + + if account.account_role != "admin" and article.article_author_id != user_id: + # TODO: Raise OwnershipException + return False + + self.article_repository.delete(article) + return True diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py index 24dd33f..9fce1c2 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -290,7 +290,7 @@ def test_update_article_insufficient_role(): content="Hacked Content", ) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_article_repo.get_by_id.assert_not_called() mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) assert result is None @@ -306,12 +306,162 @@ def test_update_article_not_found(): mock_article_repo.get_by_id.return_value = None + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="author", + account_created_at=datetime.now(), + ) + mock_account_repo.get_by_id.return_value = fake_account + result = service.update_article( article_id=999, - user_id=1, + user_id=fake_account.account_id, title="New Title", content="New Content", ) mock_article_repo.get_by_id.assert_called_once_with(999) assert result is None + + +def test_delete_article_success_by_author(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="To Be Deleted", + article_content="Delete me", + article_published_at=datetime.now(), + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="author", + account_created_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + mock_account_repo.get_by_id.return_value = fake_account + + result = service.delete_article(article_id=fake_article.article_id, user_id=fake_account.account_id) + + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_article_repo.delete.assert_called_once_with(fake_article) + assert result is True + + +def test_delete_article_success_by_admin(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="To Be Deleted", + article_content="Delete me", + article_published_at=datetime.now(), + ) + + fake_admin = Account( + account_id=99, + account_username="admin2", + account_password="password123", + account_email="admin@cyber.com", + account_role="admin", + account_created_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + mock_account_repo.get_by_id.return_value = fake_admin + + result = service.delete_article(article_id=fake_article.article_id, user_id=fake_admin.account_id) + + mock_account_repo.get_by_id.assert_called_once_with(fake_admin.account_id) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_article_repo.delete.assert_called_once_with(fake_article) + assert result is True + + +def test_delete_article_unauthorized_ownership(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="To Be Deleted", + article_content="Delete me", + article_published_at=datetime.now(), + ) + + fake_author_other = Account( + account_id=99, + account_username="other", + account_password="password123", + account_email="other@cyber.com", + account_role="author", + account_created_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + mock_account_repo.get_by_id.return_value = fake_author_other + + result = service.delete_article(article_id=fake_article.article_id, user_id=fake_author_other.account_id) + + mock_account_repo.get_by_id.assert_called_once_with(fake_author_other.account_id) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_article_repo.delete.assert_not_called() + assert result is False + + +def test_delete_article_not_found(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="author", + account_created_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = None + mock_account_repo.get_by_id.return_value = fake_account + + result = service.delete_article(article_id=999, user_id=fake_account.account_id) + + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_article_repo.get_by_id.assert_called_once_with(999) + mock_article_repo.delete.assert_not_called() + assert result is False From 762d2af65344c47ffd8d55d83e70051de47fda01 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sat, 28 Mar 2026 11:48:43 +0100 Subject: [PATCH 10/81] MVCS to hexagonal architecture : Add article pagination and total-count retrieval to the application layer with default parameters and supporting unit tests --- .../output_ports/article_repository.py | 24 +++++ .../services/article_management_service.py | 25 +++++ .../test_article_management_service.py | 92 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/application/output_ports/article_repository.py b/src/application/output_ports/article_repository.py index a990a7f..d029c45 100644 --- a/src/application/output_ports/article_repository.py +++ b/src/application/output_ports/article_repository.py @@ -53,3 +53,27 @@ def delete(self, article: Article) -> None: article (Article): The Article domain entity to delete. """ pass + + @abstractmethod + def get_paginated(self, page: int, per_page: int) -> list[Article]: + """ + Retrieves a paginated list of articles. + + Args: + page (int): The page number (1-indexed). + per_page (int): The number of items per page. + + Returns: + list[Article]: A list of Article domain entities for the given page. + """ + pass + + @abstractmethod + def count_all(self) -> int: + """ + Retrieves the total number of articles. + + Returns: + int: The total count of articles. + """ + pass diff --git a/src/application/services/article_management_service.py b/src/application/services/article_management_service.py index 08150ad..7128ddd 100644 --- a/src/application/services/article_management_service.py +++ b/src/application/services/article_management_service.py @@ -141,3 +141,28 @@ def delete_article(self, article_id: int, user_id: int) -> bool: self.article_repository.delete(article) return True + + def get_paginated_articles(self, page: int = 1, per_page: int = 10) -> list[Article]: + """ + Retrieves a paginated list of articles. + + Args: + page (int): The page number requested (1-indexed). Defaults to 1. + per_page (int): The number of items to display per page. Defaults to 10. + + Returns: + list[Article]: A list of Article domain entities. + """ + min_page = 1 + if page < min_page: + page = min_page + return self.article_repository.get_paginated(page, per_page) + + def get_total_count(self) -> int: + """ + Retrieves the total number of articles. + + Returns: + int: The total count of all articles. + """ + return self.article_repository.count_all() diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py index 9fce1c2..f0ca808 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -465,3 +465,95 @@ def test_delete_article_not_found(): mock_article_repo.get_by_id.assert_called_once_with(999) mock_article_repo.delete.assert_not_called() assert result is False + + +def test_get_paginated_articles(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_articles = [ + Article( + article_id=1, + article_author_id=1, + article_title="First", + article_content="Content 1", + article_published_at=datetime.now(), + ), + Article( + article_id=2, + article_author_id=1, + article_title="Second", + article_content="Content 2", + article_published_at=datetime.now(), + ) + ] + + mock_article_repo.get_paginated.return_value = fake_articles + result = service.get_paginated_articles(page=2, per_page=10) + mock_article_repo.get_paginated.assert_called_once_with(2, 10) + assert len(result) == 2 + first_article_list = result[0] + second_article_list = result[1] + assert first_article_list.article_title == "First" + assert second_article_list.article_title == "Second" + + +def test_get_paginated_articles_page_less_than_one(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_articles = [ + Article( + article_id=1, + article_author_id=1, + article_title="Paged Title", + article_content="Paged Content", + article_published_at=datetime.now(), + ) + ] + + mock_article_repo.get_paginated.return_value = fake_articles + result = service.get_paginated_articles(page=-5, per_page=10) + mock_article_repo.get_paginated.assert_called_once_with(1, 10) + assert len(result) == 1 + + +def test_get_total_count(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_article_repo.count_all.return_value = 42 + result = service.get_total_count() + mock_article_repo.count_all.assert_called_once() + assert result == 42 + + +def test_get_paginated_articles_defaults(): + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = ArticleManagementService( + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_article_repo.get_paginated.return_value = [] + service.get_paginated_articles() + page = 1 + per_page = 10 + mock_article_repo.get_paginated.assert_called_once_with(page, per_page) From 0a339fa09f1f6d14e0fadfd8c6976d5630ccb32c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sat, 28 Mar 2026 11:51:28 +0100 Subject: [PATCH 11/81] MVCS to hexagonal architecture : Delete unnecessary line breaks to improve code readability --- .../test_article_management_service.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py index f0ca808..394dc78 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -128,8 +128,8 @@ def test_get_all_ordered_by_date_desc(): result = service.get_all_ordered_by_date_desc() mock_article_repo.get_all_ordered_by_date_desc.assert_called_once() assert len(result) == 2 - index_first_article_list = 0 - assert result[index_first_article_list].article_title == "Recent Article" + first_article_list = result[0] + assert first_article_list.article_title == "Recent Article" def test_get_by_id_found(): @@ -198,6 +198,7 @@ def test_update_article_success(): account_role="author", account_created_at=datetime.now(), ) + mock_account_repo.get_by_id.return_value = fake_account result = service.update_article( @@ -241,6 +242,7 @@ def test_update_article_unauthorized(): account_role="admin", account_created_at=datetime.now(), ) + mock_account_repo.get_by_id.return_value = fake_account result = service.update_article( @@ -281,6 +283,7 @@ def test_update_article_insufficient_role(): account_role="user", account_created_at=datetime.now(), ) + mock_account_repo.get_by_id.return_value = fake_account result = service.update_article( @@ -314,6 +317,7 @@ def test_update_article_not_found(): account_role="author", account_created_at=datetime.now(), ) + mock_account_repo.get_by_id.return_value = fake_account result = service.update_article( @@ -355,9 +359,7 @@ def test_delete_article_success_by_author(): mock_article_repo.get_by_id.return_value = fake_article mock_account_repo.get_by_id.return_value = fake_account - result = service.delete_article(article_id=fake_article.article_id, user_id=fake_account.account_id) - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) mock_article_repo.delete.assert_called_once_with(fake_article) @@ -392,9 +394,7 @@ def test_delete_article_success_by_admin(): mock_article_repo.get_by_id.return_value = fake_article mock_account_repo.get_by_id.return_value = fake_admin - result = service.delete_article(article_id=fake_article.article_id, user_id=fake_admin.account_id) - mock_account_repo.get_by_id.assert_called_once_with(fake_admin.account_id) mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) mock_article_repo.delete.assert_called_once_with(fake_article) From bfa7362c4a15771cdd05c3a59d8bae6786588459 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Mon, 30 Mar 2026 02:23:24 +0200 Subject: [PATCH 12/81] MVCS to hexagonal architecture : Standardize error handling by returning descriptive string messages across services, including splitting previously chained `if` conditions using `or` into clearer, separate checks --- .../services/article_management_service.py | 63 +++++++++++-------- .../test_article_management_service.py | 14 ++--- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/application/services/article_management_service.py b/src/application/services/article_management_service.py index 7128ddd..c4e5dc9 100644 --- a/src/application/services/article_management_service.py +++ b/src/application/services/article_management_service.py @@ -21,7 +21,7 @@ def __init__(self, article_repository: ArticleRepository, account_repository: Ac self.article_repository = article_repository self.account_repository = account_repository - def _get_authorized_account(self, user_id: int) -> Account | None: + def _get_authorized_account(self, user_id: int) -> Account | str: """ Checks if a user exists and has the required permissions (admin or author). @@ -29,16 +29,21 @@ def _get_authorized_account(self, user_id: int) -> Account | None: user_id (int): The unique identifier of the user. Returns: - Account | None: The Account domain entity if authorized, None otherwise. + Account | str: The Account domain entity if authorized, or an error message string. """ account = self.account_repository.get_by_id(user_id) + if not account: + # TODO: Raise AccountNotFoundException + return "Account not found." + valid_roles = ["admin", "author"] - if not account or account.account_role not in valid_roles: - # TODO: Raise AccountNotFoundException or InsufficientPermissionsException - return None + if account.account_role not in valid_roles: + # TODO: Raise InsufficientPermissionsException + return "Insufficient permissions." + return account - def create_article(self, title: str, content: str, author_id: int, author_role: str) -> Article | None: + def create_article(self, title: str, content: str, author_id: int, author_role: str) -> Article | str: """ Creates a new article and saves it via the repository if the account exists and the user has the correct permissions. @@ -50,11 +55,12 @@ def create_article(self, title: str, content: str, author_id: int, author_role: author_role (str): The role of the user (e.g. 'admin', 'author', 'user'). Returns: - Article | None: The newly created Article domain entity, - or None if unauthorized or account not found. + Article | str: The newly created Article domain entity, + or an error message string if unauthorized or account not found. """ - if not self._get_authorized_account(author_id): - return None + account_or_error = self._get_authorized_account(author_id) + if isinstance(account_or_error, str): + return account_or_error new_article = Article( article_id=0, @@ -88,7 +94,7 @@ def get_by_id(self, article_id: int) -> Article | None: """ return self.article_repository.get_by_id(article_id) - def update_article(self, article_id: int, user_id: int, title: str, content: str) -> Article | None: + def update_article(self, article_id: int, user_id: int, title: str, content: str) -> Article | str: """ Updates an existing article ensuring the requester is the original author. @@ -99,23 +105,27 @@ def update_article(self, article_id: int, user_id: int, title: str, content: str content (str): New content for the article. Returns: - Article | None: The updated Article domain entity, - or None if not found or unauthorized. + Article | str: The updated Article domain entity, + or an error message string if not found or unauthorized. """ - account = self._get_authorized_account(user_id) - if not account: - return None + account_or_error = self._get_authorized_account(user_id) + if isinstance(account_or_error, str): + return account_or_error article = self.article_repository.get_by_id(article_id) - if not article or article.article_author_id != user_id: - # TODO: Raise ArticleNotFoundException or OwnershipException - return None + if not article: + # TODO: Raise ArticleNotFoundException + return "Article not found." + + if article.article_author_id != user_id: + # TODO: Raise OwnershipException + return "Unauthorized : You are not the author of this article." article.article_title = title article.article_content = content return article - def delete_article(self, article_id: int, user_id: int) -> bool: + def delete_article(self, article_id: int, user_id: int) -> bool | str: """ Deletes an article. Only the original author or an admin can delete it. @@ -124,20 +134,21 @@ def delete_article(self, article_id: int, user_id: int) -> bool: user_id (int): ID of the user requesting the deletion. Returns: - bool: True if deletion was successful, False otherwise. + bool | str: True if deletion was successful, or an error message string. """ - account = self._get_authorized_account(user_id) - if not account: - return False + account_or_error = self._get_authorized_account(user_id) + if isinstance(account_or_error, str): + return account_or_error + account: Account = account_or_error article = self.article_repository.get_by_id(article_id) if not article: # TODO: Raise ArticleNotFoundException - return False + return "Article not found." if account.account_role != "admin" and article.article_author_id != user_id: # TODO: Raise OwnershipException - return False + return "Unauthorized : Only authors or admins can delete articles." self.article_repository.delete(article) return True diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_management_service.py index 394dc78..36585f6 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_management_service.py @@ -72,7 +72,7 @@ def test_create_article_unauthorized_role(): mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) mock_article_repo.save.assert_not_called() - assert result is None + assert result == "Insufficient permissions." def test_create_article_account_not_found(): @@ -95,7 +95,7 @@ def test_create_article_account_not_found(): mock_account_repo.get_by_id.assert_called_once_with(999) mock_article_repo.save.assert_not_called() - assert result is None + assert result == "Account not found." def test_get_all_ordered_by_date_desc(): @@ -253,7 +253,7 @@ def test_update_article_unauthorized(): ) mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - assert result is None + assert result == "Unauthorized : You are not the author of this article." def test_update_article_insufficient_role(): @@ -295,7 +295,7 @@ def test_update_article_insufficient_role(): mock_article_repo.get_by_id.assert_not_called() mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - assert result is None + assert result == "Insufficient permissions." def test_update_article_not_found(): @@ -328,7 +328,7 @@ def test_update_article_not_found(): ) mock_article_repo.get_by_id.assert_called_once_with(999) - assert result is None + assert result == "Article not found." def test_delete_article_success_by_author(): @@ -435,7 +435,7 @@ def test_delete_article_unauthorized_ownership(): mock_account_repo.get_by_id.assert_called_once_with(fake_author_other.account_id) mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) mock_article_repo.delete.assert_not_called() - assert result is False + assert result == "Unauthorized : Only authors or admins can delete articles." def test_delete_article_not_found(): @@ -464,7 +464,7 @@ def test_delete_article_not_found(): mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) mock_article_repo.get_by_id.assert_called_once_with(999) mock_article_repo.delete.assert_not_called() - assert result is False + assert result == "Article not found." def test_get_paginated_articles(): From 00d4040b326085d1fd053b02404bdb652d61e7f8 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Mon, 30 Mar 2026 02:56:24 +0200 Subject: [PATCH 13/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Adds=20the=20comment=E2=80=91creation=20use=20case=20by=20de?= =?UTF-8?q?fining=20the=20repository=20port,=20implementing=20the=20servic?= =?UTF-8?q?e=20method=20and=20adding=20tests=20for=20both=20success=20and?= =?UTF-8?q?=20failure=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output_ports/comment_repository.py | 20 +++ .../services/comment_management_service.py | 74 +++++++++++ .../test_comment_management_service.py | 120 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/application/output_ports/comment_repository.py create mode 100644 src/application/services/comment_management_service.py create mode 100644 tests_hexagonal/tests_services/test_comment_management_service.py diff --git a/src/application/output_ports/comment_repository.py b/src/application/output_ports/comment_repository.py new file mode 100644 index 0000000..1f74074 --- /dev/null +++ b/src/application/output_ports/comment_repository.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod + +from src.application.domain.comment import Comment + + +class CommentRepository(ABC): + """ + Output port (interface) for Comment persistence operations. + Defines how the application interacts with the database for comments. + """ + + @abstractmethod + def save(self, comment: Comment) -> None: + """ + Saves a new comment or updates an existing one. + + Args: + comment (Comment): The Comment domain entity to save. + """ + pass diff --git a/src/application/services/comment_management_service.py b/src/application/services/comment_management_service.py new file mode 100644 index 0000000..5391fa0 --- /dev/null +++ b/src/application/services/comment_management_service.py @@ -0,0 +1,74 @@ +from datetime import datetime + +from src.application.domain.account import Account +from src.application.domain.comment import Comment +from src.application.output_ports.account_repository import AccountRepository +from src.application.output_ports.article_repository import ArticleRepository +from src.application.output_ports.comment_repository import CommentRepository + + +class CommentManagementService: + """ + Service responsible for business logic operations related to Comments. + Depends on CommentRepository, ArticleRepository, and AccountRepository output ports. + """ + + def __init__( + self, + comment_repository: CommentRepository, + article_repository: ArticleRepository, + account_repository: AccountRepository, + ): + """ + Initialize the service via Dependency Injection. + """ + self.comment_repository = comment_repository + self.article_repository = article_repository + self.account_repository = account_repository + + def _get_authorized_account(self, user_id: int) -> Account | str: + """ + Helper method to retrieve and validate an account. + """ + account = self.account_repository.get_by_id(user_id) + if not account: + # TODO: Raise AccountNotFoundException + return "Account not found." + return account + + def create_comment(self, article_id: int, user_id: int, content: str) -> Comment | str: + """ + Creates a top-level comment on an article. + + Args: + article_id (int): ID of the article being commented on. + user_id (int): ID of the user creating the comment. + content (str): Text content of the comment. + + Returns: + Comment | str: The created Comment entity, or an error message string. + """ + account_or_error = self._get_authorized_account(user_id) + if isinstance(account_or_error, str): + # TODO: Raise UnauthorizedException later + return account_or_error + + account: Account = account_or_error + + article = self.article_repository.get_by_id(article_id) + if not article: + # TODO: Raise ArticleNotFoundException later + return "Article not found." + + fake_comment_id = 0 + new_comment = Comment( + comment_id=fake_comment_id, + comment_article_id=article.article_id, + comment_written_account_id=account.account_id, + comment_reply_to=None, + comment_content=content, + comment_posted_at=datetime.now(), + ) + + self.comment_repository.save(new_comment) + return new_comment diff --git a/tests_hexagonal/tests_services/test_comment_management_service.py b/tests_hexagonal/tests_services/test_comment_management_service.py new file mode 100644 index 0000000..22050a4 --- /dev/null +++ b/tests_hexagonal/tests_services/test_comment_management_service.py @@ -0,0 +1,120 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from src.application.domain.account import Account +from src.application.domain.article import Article +from src.application.output_ports.account_repository import AccountRepository +from src.application.output_ports.article_repository import ArticleRepository +from src.application.output_ports.comment_repository import CommentRepository +from src.application.services.comment_management_service import CommentManagementService + + +def test_create_comment_success(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentManagementService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account + + fake_article = Article( + article_id=1, + article_author_id=2, + article_title="My Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + + result = service.create_comment( + article_id=fake_article.article_id, + user_id=fake_account.account_id, + content="Great post!" + ) + + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_comment_repo.save.assert_called_once() + index_args = 0 + index_kwargs = 0 + saved_comment = mock_comment_repo.save.call_args[index_args][index_kwargs] + assert saved_comment.comment_article_id == fake_article.article_id + assert saved_comment.comment_written_account_id == fake_account.account_id + assert saved_comment.comment_reply_to is None + assert saved_comment.comment_content == "Great post!" + assert result is saved_comment + + +def test_create_comment_account_not_found(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentManagementService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_account_repo.get_by_id.return_value = None + + result = service.create_comment( + article_id=1, + user_id=999, + content="This will not post." + ) + + mock_account_repo.get_by_id.assert_called_once_with(999) + mock_article_repo.get_by_id.assert_not_called() + mock_comment_repo.save.assert_not_called() + assert result == "Account not found." + + +def test_create_comment_article_not_found(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentManagementService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account + mock_article_repo.get_by_id.return_value = None + + result = service.create_comment( + article_id=999, + user_id=fake_account.account_id, + content="Writing in the void." + ) + + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_article_repo.get_by_id.assert_called_once_with(999) + mock_comment_repo.save.assert_not_called() + assert result == "Article not found." From 50140393fb31046dc77cc977ea3bb746b87a756d Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Mon, 30 Mar 2026 03:17:59 +0200 Subject: [PATCH 14/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20This=20refactor=20simplifies=20the=20application=20layer=20b?= =?UTF-8?q?y=20dropping=20the=20=E2=80=9CManagement=E2=80=9D=20suffix=20fr?= =?UTF-8?q?om=20service=20classes,=20updating=20all=20related=20files=20an?= =?UTF-8?q?d=20tests=20to=20align=20with=20the=20cleaner=20and=20more=20co?= =?UTF-8?q?nsistent=20`Service`=20naming=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nagement_service.py => article_service.py} | 2 +- ...nagement_service.py => comment_service.py} | 2 +- ...ent_service.py => test_article_service.py} | 38 +++++++++---------- ...ent_service.py => test_comment_service.py} | 8 ++-- 4 files changed, 25 insertions(+), 25 deletions(-) rename src/application/services/{article_management_service.py => article_service.py} (99%) rename src/application/services/{comment_management_service.py => comment_service.py} (98%) rename tests_hexagonal/tests_services/{test_article_management_service.py => test_article_service.py} (95%) rename tests_hexagonal/tests_services/{test_comment_management_service.py => test_comment_service.py} (94%) diff --git a/src/application/services/article_management_service.py b/src/application/services/article_service.py similarity index 99% rename from src/application/services/article_management_service.py rename to src/application/services/article_service.py index c4e5dc9..4437cf2 100644 --- a/src/application/services/article_management_service.py +++ b/src/application/services/article_service.py @@ -4,7 +4,7 @@ from src.application.output_ports.article_repository import ArticleRepository -class ArticleManagementService: +class ArticleService: """ Service responsible for business logic operations related to Articles. Depends on the ArticleRepository and AccountRepository output ports. diff --git a/src/application/services/comment_management_service.py b/src/application/services/comment_service.py similarity index 98% rename from src/application/services/comment_management_service.py rename to src/application/services/comment_service.py index 5391fa0..fc9f149 100644 --- a/src/application/services/comment_management_service.py +++ b/src/application/services/comment_service.py @@ -7,7 +7,7 @@ from src.application.output_ports.comment_repository import CommentRepository -class CommentManagementService: +class CommentService: """ Service responsible for business logic operations related to Comments. Depends on CommentRepository, ArticleRepository, and AccountRepository output ports. diff --git a/tests_hexagonal/tests_services/test_article_management_service.py b/tests_hexagonal/tests_services/test_article_service.py similarity index 95% rename from tests_hexagonal/tests_services/test_article_management_service.py rename to tests_hexagonal/tests_services/test_article_service.py index 36585f6..fd0ec5c 100644 --- a/tests_hexagonal/tests_services/test_article_management_service.py +++ b/tests_hexagonal/tests_services/test_article_service.py @@ -5,14 +5,14 @@ from src.application.domain.article import Article from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository -from src.application.services.article_management_service import ArticleManagementService +from src.application.services.article_service import ArticleService def test_create_article_success(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -47,7 +47,7 @@ def test_create_article_unauthorized_role(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -79,7 +79,7 @@ def test_create_article_account_not_found(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -102,7 +102,7 @@ def test_get_all_ordered_by_date_desc(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -136,7 +136,7 @@ def test_get_by_id_found(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -160,7 +160,7 @@ def test_get_by_id_not_found(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -175,7 +175,7 @@ def test_update_article_success(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -219,7 +219,7 @@ def test_update_article_unauthorized(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -260,7 +260,7 @@ def test_update_article_insufficient_role(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -302,7 +302,7 @@ def test_update_article_not_found(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -335,7 +335,7 @@ def test_delete_article_success_by_author(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -370,7 +370,7 @@ def test_delete_article_success_by_admin(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -405,7 +405,7 @@ def test_delete_article_unauthorized_ownership(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -442,7 +442,7 @@ def test_delete_article_not_found(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -471,7 +471,7 @@ def test_get_paginated_articles(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -507,7 +507,7 @@ def test_get_paginated_articles_page_less_than_one(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -532,7 +532,7 @@ def test_get_total_count(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) @@ -547,7 +547,7 @@ def test_get_paginated_articles_defaults(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = ArticleManagementService( + service = ArticleService( article_repository=mock_article_repo, account_repository=mock_account_repo ) diff --git a/tests_hexagonal/tests_services/test_comment_management_service.py b/tests_hexagonal/tests_services/test_comment_service.py similarity index 94% rename from tests_hexagonal/tests_services/test_comment_management_service.py rename to tests_hexagonal/tests_services/test_comment_service.py index 22050a4..7f26549 100644 --- a/tests_hexagonal/tests_services/test_comment_management_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -6,7 +6,7 @@ from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.output_ports.comment_repository import CommentRepository -from src.application.services.comment_management_service import CommentManagementService +from src.application.services.comment_service import CommentService def test_create_comment_success(): @@ -14,7 +14,7 @@ def test_create_comment_success(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = CommentManagementService( + service = CommentService( comment_repository=mock_comment_repo, article_repository=mock_article_repo, account_repository=mock_account_repo @@ -65,7 +65,7 @@ def test_create_comment_account_not_found(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = CommentManagementService( + service = CommentService( comment_repository=mock_comment_repo, article_repository=mock_article_repo, account_repository=mock_account_repo @@ -90,7 +90,7 @@ def test_create_comment_article_not_found(): mock_article_repo = MagicMock(spec=ArticleRepository) mock_account_repo = MagicMock(spec=AccountRepository) - service = CommentManagementService( + service = CommentService( comment_repository=mock_comment_repo, article_repository=mock_article_repo, account_repository=mock_account_repo From e341734ad258820b5cfa621a99da5a36dbad6ee2 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Mon, 30 Mar 2026 04:10:08 +0200 Subject: [PATCH 15/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Implements=20the=20reply=E2=80=91to=E2=80=91comment=20featur?= =?UTF-8?q?e=20by=20adding=20the=20repository=20lookup=20method,=20introdu?= =?UTF-8?q?cing=20`create=5Freply`=20with=20a=20simplified=20threading=20m?= =?UTF-8?q?odel=20and=20refining=20both=20the=20threading=20logic=20and=20?= =?UTF-8?q?unit=20tests=20for=20clarity=20and=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output_ports/comment_repository.py | 13 ++ src/application/services/comment_service.py | 46 +++++++ .../tests_services/test_comment_service.py | 116 ++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/src/application/output_ports/comment_repository.py b/src/application/output_ports/comment_repository.py index 1f74074..bf3793a 100644 --- a/src/application/output_ports/comment_repository.py +++ b/src/application/output_ports/comment_repository.py @@ -18,3 +18,16 @@ def save(self, comment: Comment) -> None: comment (Comment): The Comment domain entity to save. """ pass + + @abstractmethod + def get_by_id(self, comment_id: int) -> Comment | None: + """ + Retrieves a single comment by its ID. + + Args: + comment_id (int): The unique identifier of the comment. + + Returns: + Comment | None: The Comment domain entity if found, None otherwise. + """ + pass diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index fc9f149..3569933 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -72,3 +72,49 @@ def create_comment(self, article_id: int, user_id: int, content: str) -> Comment self.comment_repository.save(new_comment) return new_comment + + def create_reply(self, parent_comment_id: int, user_id: int, content: str) -> Comment | str: + """ + Creates a reply to an existing comment. A reply is linked + either to the parent directly or to the parent's top-level + comment (threading logic). + + Args: + parent_comment_id (int): The ID of the comment being replied to. + user_id (int): The identifier of the user creating the reply. + content (str): The text content of the reply. + + Returns: + Comment | str: The new Comment domain entity if successful, + or an error message string if unauthorized or parent not found. + """ + account_or_error = self._get_authorized_account(user_id) + if isinstance(account_or_error, str): + # TODO: Raise UnauthorizedException later + return account_or_error + + account: Account = account_or_error + + parent_comment = self.comment_repository.get_by_id(parent_comment_id) + if not parent_comment: + # TODO: Raise CommentNotFoundException later + return "Parent comment not found." + + is_parent_a_reply = parent_comment.comment_reply_to is not None + if is_parent_a_reply: + thread_root_id = parent_comment.comment_reply_to + else: + thread_root_id = parent_comment.comment_id + + fake_comment_id = 0 + new_reply = Comment( + comment_id=fake_comment_id, + comment_article_id=parent_comment.comment_article_id, + comment_written_account_id=account.account_id, + comment_content=content, + comment_reply_to=thread_root_id, + comment_posted_at=datetime.now(), + ) + + self.comment_repository.save(new_reply) + return new_reply diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index 7f26549..48451ec 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -3,6 +3,7 @@ from src.application.domain.account import Account from src.application.domain.article import Article +from src.application.domain.comment import Comment from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.output_ports.comment_repository import CommentRepository @@ -118,3 +119,118 @@ def test_create_comment_article_not_found(): mock_article_repo.get_by_id.assert_called_once_with(999) mock_comment_repo.save.assert_not_called() assert result == "Article not found." + + +def test_create_reply_success_root_comment(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account + + parent_comment = Comment( + comment_id=10, + comment_article_id=5, + comment_written_account_id=2, + comment_reply_to=None, + comment_content="Root comment", + comment_posted_at=datetime.now(), + ) + + mock_comment_repo.get_by_id.return_value = parent_comment + result = service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="This is a reply") + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_comment_repo.get_by_id.assert_called_once_with(parent_comment.comment_id) + mock_comment_repo.save.assert_called_once() + index_args = 0 + index_kwargs = 0 + saved_reply = mock_comment_repo.save.call_args[index_args][index_kwargs] + + assert saved_reply.comment_article_id == parent_comment.comment_article_id + assert saved_reply.comment_written_account_id == fake_account.account_id + assert saved_reply.comment_reply_to == parent_comment.comment_id + assert saved_reply.comment_content == "This is a reply" + assert result is saved_reply + + +def test_create_reply_success_nested_comment(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account + + parent_comment = Comment( + comment_id=15, + comment_article_id=5, + comment_written_account_id=2, + comment_reply_to=10, + comment_content="I am a reply", + comment_posted_at=datetime.now(), + ) + mock_comment_repo.get_by_id.return_value = parent_comment + result = service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="Replying to a reply") + mock_comment_repo.save.assert_called_once() + index_args = 0 + index_kwargs = 0 + saved_reply = mock_comment_repo.save.call_args[index_args][index_kwargs] + assert saved_reply.comment_reply_to == 10 + assert result is saved_reply + + +def test_create_reply_parent_not_found(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account + mock_comment_repo.get_by_id.return_value = None + result = service.create_reply(parent_comment_id=999, user_id=fake_account.account_id, content="Replying to nothing") + mock_comment_repo.get_by_id.assert_called_once_with(999) + mock_comment_repo.save.assert_not_called() + assert result == "Parent comment not found." From aeaab394bbd1c7465abc65b178babd160afe97c4 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Mon, 30 Mar 2026 06:09:42 +0200 Subject: [PATCH 16/81] MVCS to hexagonal architecture : Implement get_comments_for_article with optimized O(n) threaded tree build using defaultdict, add get_all_by_article_id to the output port, apply attrgetter-based sorting and include core unit tests --- .../output_ports/comment_repository.py | 13 +++ src/application/services/comment_service.py | 46 +++++++++ .../tests_services/test_comment_service.py | 97 +++++++++++++++++++ 3 files changed, 156 insertions(+) diff --git a/src/application/output_ports/comment_repository.py b/src/application/output_ports/comment_repository.py index bf3793a..a9c035a 100644 --- a/src/application/output_ports/comment_repository.py +++ b/src/application/output_ports/comment_repository.py @@ -31,3 +31,16 @@ def get_by_id(self, comment_id: int) -> Comment | None: Comment | None: The Comment domain entity if found, None otherwise. """ pass + + @abstractmethod + def get_all_by_article_id(self, article_id: int) -> list[Comment]: + """ + Retrieves all comments associated with a specific article. + + Args: + article_id (int): ID of the article. + + Returns: + list[Comment]: A list of all Comment domain entities for this article. + """ + pass diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index 3569933..5d7b0a5 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -1,4 +1,6 @@ +from collections import defaultdict from datetime import datetime +from operator import attrgetter from src.application.domain.account import Account from src.application.domain.comment import Comment @@ -118,3 +120,47 @@ def create_reply(self, parent_comment_id: int, user_id: int, content: str) -> Co self.comment_repository.save(new_reply) return new_reply + + def get_comments_for_article(self, article_id: int) -> dict[str | int, list[Comment]] | str: + """ + Retrieves all comments for a specific article and structures them + in a dictionary for easy display (threading). + + Args: + article_id (int): ID of the article. + + Returns: + dict[str | int, list[Comment]] | str: A dictionary containing the threaded comments, + or an error message string if the article is not found. + Structure: + { + "root": [Comment1, Comment2], + comment_id_1: [Reply1, Reply2], + comment_id_2: [Reply3] + } + """ + article = self.article_repository.get_by_id(article_id) + if not article: + # TODO: Raise ArticleNotFoundException later + return "Article not found." + + all_comments = self.comment_repository.get_all_by_article_id(article_id) + tree = defaultdict(list) + tree["root"] = [] + + for comment in all_comments: + if comment.comment_reply_to is None: + key = "root" + else: + key = comment.comment_reply_to + + tree[key].append(comment) + + get_date = attrgetter("comment_posted_at") + if "root" in tree: + tree["root"].sort(key=get_date, reverse=True) + + for root in tree["root"]: + tree[root.comment_id].sort(key=get_date) + + return dict(tree) diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index 48451ec..25ad108 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -234,3 +234,100 @@ def test_create_reply_parent_not_found(): mock_comment_repo.get_by_id.assert_called_once_with(999) mock_comment_repo.save.assert_not_called() assert result == "Parent comment not found." + + +def test_get_comments_for_article_not_found(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_article_repo.get_by_id.return_value = None + result = service.get_comments_for_article(article_id=999) + mock_article_repo.get_by_id.assert_called_once_with(999) + mock_comment_repo.get_all_by_article_id.assert_not_called() + assert result == "Article not found." + + +def test_get_comments_for_article_empty(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=2, + article_title="My Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + mock_comment_repo.get_all_by_article_id.return_value = [] + result = service.get_comments_for_article(article_id=fake_article.article_id) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) + assert isinstance(result, dict) + assert result == {"root": []} + + +def test_get_comments_for_article_success(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_article = Article( + article_id=1, + article_author_id=2, + article_title="My Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + mock_article_repo.get_by_id.return_value = fake_article + + root_comment = Comment( + comment_id=10, + comment_article_id=fake_article.article_id, + comment_written_account_id=3, + comment_reply_to=None, + comment_content="First!", + comment_posted_at=datetime.now(), + ) + + reply = Comment( + comment_id=15, + comment_article_id=fake_article.article_id, + comment_written_account_id=4, + comment_reply_to=root_comment.comment_id, + comment_content="Awesome!", + comment_posted_at=datetime.now(), + ) + + mock_comment_repo.get_all_by_article_id.return_value = [root_comment, reply] + result = service.get_comments_for_article(article_id=fake_article.article_id) + mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) + assert isinstance(result, dict) + assert "root" in result + assert result["root"] == [root_comment] + comment_id_key = root_comment.comment_id + assert comment_id_key in result + assert result[comment_id_key] == [reply] From b1746ed1abf9ce68cdef4ea18a64149d8879d908 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 31 Mar 2026 03:58:03 +0200 Subject: [PATCH 17/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Introducing=20strict=20admin=E2=80=91only=20control=20for=20?= =?UTF-8?q?comment=20deletion=20to=20prevent=20unauthorized=20removals.=20?= =?UTF-8?q?The=20service=20applies=20a=20fail=E2=80=91fast=20validation=20?= =?UTF-8?q?flow=20to=20ensure=20data=20integrity=20and=20clear=20authoriza?= =?UTF-8?q?tion=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output_ports/comment_repository.py | 10 ++ src/application/services/comment_service.py | 30 +++++ .../tests_services/test_comment_service.py | 114 ++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/src/application/output_ports/comment_repository.py b/src/application/output_ports/comment_repository.py index a9c035a..0adc140 100644 --- a/src/application/output_ports/comment_repository.py +++ b/src/application/output_ports/comment_repository.py @@ -44,3 +44,13 @@ def get_all_by_article_id(self, article_id: int) -> list[Comment]: list[Comment]: A list of all Comment domain entities for this article. """ pass + + @abstractmethod + def delete(self, comment_id: int) -> None: + """ + Deletes a comment by its ID from the repository. + + Args: + comment_id (int): ID of the comment to remove. + """ + pass diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index 5d7b0a5..cc877bd 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -164,3 +164,33 @@ def get_comments_for_article(self, article_id: int) -> dict[str | int, list[Comm tree[root.comment_id].sort(key=get_date) return dict(tree) + + def delete_comment(self, comment_id: int, user_id: int) -> bool | str: + """ + Deletes a comment. Only an admin can delete a comment. + + Args: + comment_id (int): ID of the comment to delete. + user_id (int): ID of the user requesting the deletion. + + Returns: + bool | str: True if deletion was successful, or an error message string. + """ + account_or_error = self._get_authorized_account(user_id) + if isinstance(account_or_error, str): + # TODO: Raise UnauthorizedException later + return account_or_error + + account: Account = account_or_error + + if account.account_role != "admin": + # TODO: Raise InsufficientPermissionsException later + return "Unauthorized : Only admins can delete comments." + + comment = self.comment_repository.get_by_id(comment_id) + if not comment: + # TODO: Raise CommentNotFoundException later + return "Comment not found." + + self.comment_repository.delete(comment_id) + return True diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index 25ad108..8775aef 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -331,3 +331,117 @@ def test_get_comments_for_article_success(): comment_id_key = root_comment.comment_id assert comment_id_key in result assert result[comment_id_key] == [reply] + + +def test_delete_comment_success_as_admin(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + admin_account = Account( + account_id=1, + account_username="admin_user", + account_password="password123", + account_email="admin@galaxy.com", + account_role="admin", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = admin_account + + comment_to_delete = Comment( + comment_id=10, + comment_article_id=5, + comment_written_account_id=2, + comment_reply_to=None, + comment_content="Bad comment", + comment_posted_at=datetime.now(), + ) + + mock_comment_repo.get_by_id.return_value = comment_to_delete + result = service.delete_comment(comment_id=comment_to_delete.comment_id, user_id=admin_account.account_id) + mock_account_repo.get_by_id.assert_called_once_with(admin_account.account_id) + mock_comment_repo.get_by_id.assert_called_once_with(comment_to_delete.comment_id) + mock_comment_repo.delete.assert_called_once_with(comment_to_delete.comment_id) + assert result is True + + +def test_delete_comment_unauthorized_not_admin(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=2, + account_username="regular_user", + account_password="password123", + account_email="user@galaxy.com", + account_role="user", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account + result = service.delete_comment(comment_id=10, user_id=fake_account.account_id) + mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + mock_comment_repo.get_by_id.assert_not_called() + mock_comment_repo.delete.assert_not_called() + assert result == "Unauthorized : Only admins can delete comments." + + +def test_delete_comment_not_found(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + fake_account = Account( + account_id=1, + account_username="admin_user", + account_password="password123", + account_email="admin@galaxy.com", + account_role="admin", + account_created_at=datetime.now(), + ) + + mock_account_repo.get_by_id.return_value = fake_account + mock_comment_repo.get_by_id.return_value = None + result = service.delete_comment(comment_id=999, user_id=fake_account.account_id) + mock_comment_repo.get_by_id.assert_called_once_with(999) + mock_comment_repo.delete.assert_not_called() + assert result == "Comment not found." + + +def test_delete_comment_account_not_found(): + mock_comment_repo = MagicMock(spec=CommentRepository) + mock_article_repo = MagicMock(spec=ArticleRepository) + mock_account_repo = MagicMock(spec=AccountRepository) + + service = CommentService( + comment_repository=mock_comment_repo, + article_repository=mock_article_repo, + account_repository=mock_account_repo + ) + + mock_account_repo.get_by_id.return_value = None + result = service.delete_comment(comment_id=10, user_id=999) + mock_account_repo.get_by_id.assert_called_once_with(999) + mock_comment_repo.get_by_id.assert_not_called() + mock_comment_repo.delete.assert_not_called() + assert result == "Account not found." From b4c40b42c4fc0aff404e9c0bd4c54005d4db95bf Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 31 Mar 2026 04:46:27 +0200 Subject: [PATCH 18/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Replacing=20hard=E2=80=91coded=20role=20strings=20with=20a?= =?UTF-8?q?=20dedicated=20AccountRole=20Enum=20to=20improve=20type=20safet?= =?UTF-8?q?y=20and=20consistency=20across=20domain=20services.=20This=20re?= =?UTF-8?q?factor=20updates=20service=20logic=20and=20aligns=20all=20unit?= =?UTF-8?q?=20tests=20with=20the=20new=20role=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/application/domain/account.py | 15 ++++++++++-- src/application/services/article_service.py | 9 ++++--- src/application/services/comment_service.py | 4 ++-- .../services/registration_service.py | 4 ++-- .../tests_services/test_article_service.py | 24 +++++++++---------- .../tests_services/test_comment_service.py | 18 +++++++------- .../tests_services/test_login_service.py | 6 ++--- .../test_registration_service.py | 8 +++---- 8 files changed, 49 insertions(+), 39 deletions(-) diff --git a/src/application/domain/account.py b/src/application/domain/account.py index 79f3324..458deb6 100644 --- a/src/application/domain/account.py +++ b/src/application/domain/account.py @@ -1,4 +1,15 @@ from datetime import datetime +from enum import Enum + + +class AccountRole(str, Enum): + """ + Available roles for user accounts. + Inheriting from str ensures JSON serializability and string comparisons work. + """ + ADMIN = "admin" + AUTHOR = "author" + USER = "user" class Account: @@ -10,7 +21,7 @@ class Account: account_username (str): Unique username used for authentication. account_password (str): Securely hashed password string. account_email (str): Unique email address for the user. - account_role (str): Permissions role ('admin', 'author', or 'user'). + account_role (AccountRole): Permissions role. account_created_at (datetime): Timestamp of account creation. """ @@ -20,7 +31,7 @@ def __init__( account_username: str, account_password: str, account_email: str, - account_role: str, + account_role: AccountRole, account_created_at: datetime, ): self.account_id = account_id diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index 4437cf2..c0e4ba7 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -1,4 +1,4 @@ -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountRole from src.application.domain.article import Article from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository @@ -36,8 +36,7 @@ def _get_authorized_account(self, user_id: int) -> Account | str: # TODO: Raise AccountNotFoundException return "Account not found." - valid_roles = ["admin", "author"] - if account.account_role not in valid_roles: + if account.account_role not in [AccountRole.ADMIN, AccountRole.AUTHOR]: # TODO: Raise InsufficientPermissionsException return "Insufficient permissions." @@ -52,7 +51,7 @@ def create_article(self, title: str, content: str, author_id: int, author_role: title (str): The title of the new article. content (str): The body content of the new article. author_id (int): The unique identifier of the user creating the article. - author_role (str): The role of the user (e.g. 'admin', 'author', 'user'). + author_role (AccountRole): The role of the user. Returns: Article | str: The newly created Article domain entity, @@ -146,7 +145,7 @@ def delete_article(self, article_id: int, user_id: int) -> bool | str: # TODO: Raise ArticleNotFoundException return "Article not found." - if account.account_role != "admin" and article.article_author_id != user_id: + if account.account_role != AccountRole.ADMIN and article.article_author_id != user_id: # TODO: Raise OwnershipException return "Unauthorized : Only authors or admins can delete articles." diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index cc877bd..bebaead 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -2,7 +2,7 @@ from datetime import datetime from operator import attrgetter -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountRole from src.application.domain.comment import Comment from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository @@ -183,7 +183,7 @@ def delete_comment(self, comment_id: int, user_id: int) -> bool | str: account: Account = account_or_error - if account.account_role != "admin": + if account.account_role != AccountRole.ADMIN: # TODO: Raise InsufficientPermissionsException later return "Unauthorized : Only admins can delete comments." diff --git a/src/application/services/registration_service.py b/src/application/services/registration_service.py index cca3557..9988061 100644 --- a/src/application/services/registration_service.py +++ b/src/application/services/registration_service.py @@ -1,4 +1,4 @@ -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository @@ -46,7 +46,7 @@ def create_account(self, username: str, password: str, email: str) -> Account | account_username=username, account_password=password, account_email=email, - account_role="user", + account_role=AccountRole.USER, account_created_at=None, ) diff --git a/tests_hexagonal/tests_services/test_article_service.py b/tests_hexagonal/tests_services/test_article_service.py index fd0ec5c..80a39ab 100644 --- a/tests_hexagonal/tests_services/test_article_service.py +++ b/tests_hexagonal/tests_services/test_article_service.py @@ -1,7 +1,7 @@ from datetime import datetime from unittest.mock import MagicMock -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountRole from src.application.domain.article import Article from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository @@ -22,7 +22,7 @@ def test_create_article_success(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="admin", + account_role=AccountRole.ADMIN, account_created_at=datetime.now(), ) @@ -57,7 +57,7 @@ def test_create_article_unauthorized_role(): account_username="boris", account_password="password123", account_email="boris@ordinary.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -90,7 +90,7 @@ def test_create_article_account_not_found(): title="Ghost Article", content="Content from beyond!", author_id=999, - author_role="author", + author_role=AccountRole.AUTHOR, ) mock_account_repo.get_by_id.assert_called_once_with(999) @@ -195,7 +195,7 @@ def test_update_article_success(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="author", + account_role=AccountRole.AUTHOR, account_created_at=datetime.now(), ) @@ -239,7 +239,7 @@ def test_update_article_unauthorized(): account_username="hacker", account_password="password123", account_email="hacker@cyber.com", - account_role="admin", + account_role=AccountRole.ADMIN, account_created_at=datetime.now(), ) @@ -280,7 +280,7 @@ def test_update_article_insufficient_role(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -314,7 +314,7 @@ def test_update_article_not_found(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="author", + account_role=AccountRole.AUTHOR, account_created_at=datetime.now(), ) @@ -353,7 +353,7 @@ def test_delete_article_success_by_author(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="author", + account_role=AccountRole.AUTHOR, account_created_at=datetime.now(), ) @@ -388,7 +388,7 @@ def test_delete_article_success_by_admin(): account_username="admin2", account_password="password123", account_email="admin@cyber.com", - account_role="admin", + account_role=AccountRole.ADMIN, account_created_at=datetime.now(), ) @@ -423,7 +423,7 @@ def test_delete_article_unauthorized_ownership(): account_username="other", account_password="password123", account_email="other@cyber.com", - account_role="author", + account_role=AccountRole.AUTHOR, account_created_at=datetime.now(), ) @@ -452,7 +452,7 @@ def test_delete_article_not_found(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="author", + account_role=AccountRole.AUTHOR, account_created_at=datetime.now(), ) diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index 8775aef..400e2a7 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -1,7 +1,7 @@ from datetime import datetime from unittest.mock import MagicMock -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountRole from src.application.domain.article import Article from src.application.domain.comment import Comment from src.application.output_ports.account_repository import AccountRepository @@ -26,7 +26,7 @@ def test_create_comment_success(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -102,7 +102,7 @@ def test_create_comment_article_not_found(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -137,7 +137,7 @@ def test_create_reply_success_root_comment(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -184,7 +184,7 @@ def test_create_reply_success_nested_comment(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -224,7 +224,7 @@ def test_create_reply_parent_not_found(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -349,7 +349,7 @@ def test_delete_comment_success_as_admin(): account_username="admin_user", account_password="password123", account_email="admin@galaxy.com", - account_role="admin", + account_role=AccountRole.ADMIN, account_created_at=datetime.now(), ) @@ -388,7 +388,7 @@ def test_delete_comment_unauthorized_not_admin(): account_username="regular_user", account_password="password123", account_email="user@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -416,7 +416,7 @@ def test_delete_comment_not_found(): account_username="admin_user", account_password="password123", account_email="admin@galaxy.com", - account_role="admin", + account_role=AccountRole.ADMIN, account_created_at=datetime.now(), ) diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py index 4f0a2d5..f377b5e 100644 --- a/tests_hexagonal/tests_services/test_login_service.py +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -1,7 +1,7 @@ from datetime import datetime from unittest.mock import MagicMock -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository from src.application.services.login_service import LoginService @@ -15,7 +15,7 @@ def test_authenticate_user_success(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now() ) @@ -35,7 +35,7 @@ def test_authenticate_user_wrong_password(): account_username="leia", account_password="password123", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now() ) diff --git a/tests_hexagonal/tests_services/test_registration_service.py b/tests_hexagonal/tests_services/test_registration_service.py index 137ebff..94d3f56 100644 --- a/tests_hexagonal/tests_services/test_registration_service.py +++ b/tests_hexagonal/tests_services/test_registration_service.py @@ -1,7 +1,7 @@ from datetime import datetime from unittest.mock import MagicMock -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository from src.application.services.registration_service import RegistrationService @@ -24,7 +24,7 @@ def test_create_account_success(): assert isinstance(result, Account) assert result.account_username == "leia" assert result.account_email == "leia@galaxy.com" - assert result.account_role == "user" + assert result.account_role == AccountRole.USER def test_create_account_username_taken(): @@ -36,7 +36,7 @@ def test_create_account_username_taken(): account_username="leia", account_password="existing_pass", account_email="existing@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) @@ -63,7 +63,7 @@ def test_create_account_email_taken(): account_username="han", account_password="other_pass", account_email="leia@galaxy.com", - account_role="user", + account_role=AccountRole.USER, account_created_at=datetime.now(), ) From d88e9d1f0abaca42ed59ee97b05d062d3fa48ca1 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 31 Mar 2026 05:22:10 +0200 Subject: [PATCH 19/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Grouped=20the=2018=20ArticleService=20tests=20into=20five=20?= =?UTF-8?q?action=E2=80=91focused=20classes=20and=20introduced=20a=20share?= =?UTF-8?q?d=20base=20class=20to=20handle=20common=20setup,=20reducing=20d?= =?UTF-8?q?uplication=20and=20keeping=20the=20suite=20clean=20and=20readab?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/application/services/article_service.py | 8 +- src/application/services/comment_service.py | 8 +- .../tests_services/test_article_service.py | 898 ++++++++---------- 3 files changed, 384 insertions(+), 530 deletions(-) diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index c0e4ba7..0530889 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -21,7 +21,7 @@ def __init__(self, article_repository: ArticleRepository, account_repository: Ac self.article_repository = article_repository self.account_repository = account_repository - def _get_authorized_account(self, user_id: int) -> Account | str: + def _get_account_if_author_or_admin(self, user_id: int) -> Account | str: """ Checks if a user exists and has the required permissions (admin or author). @@ -57,7 +57,7 @@ def create_article(self, title: str, content: str, author_id: int, author_role: Article | str: The newly created Article domain entity, or an error message string if unauthorized or account not found. """ - account_or_error = self._get_authorized_account(author_id) + account_or_error = self._get_account_if_author_or_admin(author_id) if isinstance(account_or_error, str): return account_or_error @@ -107,7 +107,7 @@ def update_article(self, article_id: int, user_id: int, title: str, content: str Article | str: The updated Article domain entity, or an error message string if not found or unauthorized. """ - account_or_error = self._get_authorized_account(user_id) + account_or_error = self._get_account_if_author_or_admin(user_id) if isinstance(account_or_error, str): return account_or_error @@ -135,7 +135,7 @@ def delete_article(self, article_id: int, user_id: int) -> bool | str: Returns: bool | str: True if deletion was successful, or an error message string. """ - account_or_error = self._get_authorized_account(user_id) + account_or_error = self._get_account_if_author_or_admin(user_id) if isinstance(account_or_error, str): return account_or_error diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index bebaead..25b28e0 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -28,7 +28,7 @@ def __init__( self.article_repository = article_repository self.account_repository = account_repository - def _get_authorized_account(self, user_id: int) -> Account | str: + def _get_account_if_exists(self, user_id: int) -> Account | str: """ Helper method to retrieve and validate an account. """ @@ -50,7 +50,7 @@ def create_comment(self, article_id: int, user_id: int, content: str) -> Comment Returns: Comment | str: The created Comment entity, or an error message string. """ - account_or_error = self._get_authorized_account(user_id) + account_or_error = self._get_account_if_exists(user_id) if isinstance(account_or_error, str): # TODO: Raise UnauthorizedException later return account_or_error @@ -90,7 +90,7 @@ def create_reply(self, parent_comment_id: int, user_id: int, content: str) -> Co Comment | str: The new Comment domain entity if successful, or an error message string if unauthorized or parent not found. """ - account_or_error = self._get_authorized_account(user_id) + account_or_error = self._get_account_if_exists(user_id) if isinstance(account_or_error, str): # TODO: Raise UnauthorizedException later return account_or_error @@ -176,7 +176,7 @@ def delete_comment(self, comment_id: int, user_id: int) -> bool | str: Returns: bool | str: True if deletion was successful, or an error message string. """ - account_or_error = self._get_authorized_account(user_id) + account_or_error = self._get_account_if_exists(user_id) if isinstance(account_or_error, str): # TODO: Raise UnauthorizedException later return account_or_error diff --git a/tests_hexagonal/tests_services/test_article_service.py b/tests_hexagonal/tests_services/test_article_service.py index 80a39ab..5c896c4 100644 --- a/tests_hexagonal/tests_services/test_article_service.py +++ b/tests_hexagonal/tests_services/test_article_service.py @@ -8,552 +8,406 @@ from src.application.services.article_service import ArticleService -def test_create_article_success(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - result = service.create_article( - title="My First Article", - content="Hello World !", - author_id=fake_account.account_id, - author_role=fake_account.account_role - ) - - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_article_repo.save.assert_called_once_with(result) - assert isinstance(result, Article) - assert result.article_title == "My First Article" - assert result.article_content == "Hello World !" - assert result.article_author_id == fake_account.account_id - - -def test_create_article_unauthorized_role(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=2, - account_username="boris", - account_password="password123", - account_email="boris@ordinary.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - result = service.create_article( - title="Hacked Article", - content="Bad Content !", - author_id=fake_account.account_id, - author_role=fake_account.account_role, - ) - - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_article_repo.save.assert_not_called() - assert result == "Insufficient permissions." - - -def test_create_article_account_not_found(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - mock_account_repo.get_by_id.return_value = None - - result = service.create_article( - title="Ghost Article", - content="Content from beyond!", - author_id=999, - author_role=AccountRole.AUTHOR, - ) - - mock_account_repo.get_by_id.assert_called_once_with(999) - mock_article_repo.save.assert_not_called() - assert result == "Account not found." - - -def test_get_all_ordered_by_date_desc(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_articles = [ - Article( - article_id=2, +class ArticleServiceTestBase: + def setup_method(self): + self.mock_article_repo = MagicMock(spec=ArticleRepository) + self.mock_account_repo = MagicMock(spec=AccountRepository) + self.service = ArticleService( + article_repository=self.mock_article_repo, + account_repository=self.mock_account_repo + ) + + +class TestCreateArticle(ArticleServiceTestBase): + def test_create_article_success(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.ADMIN, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + + result = self.service.create_article( + title="My First Article", + content="Hello World !", + author_id=fake_account.account_id, + author_role=fake_account.account_role + ) + + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.save.assert_called_once_with(result) + assert isinstance(result, Article) + assert result.article_title == "My First Article" + assert result.article_content == "Hello World !" + assert result.article_author_id == fake_account.account_id + + def test_create_article_unauthorized_role(self): + fake_account = Account( + account_id=2, + account_username="boris", + account_password="password123", + account_email="boris@ordinary.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + + result = self.service.create_article( + title="Hacked Article", + content="Bad Content !", + author_id=fake_account.account_id, + author_role=fake_account.account_role, + ) + + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.save.assert_not_called() + assert result == "Insufficient permissions." + + def test_create_article_account_not_found(self): + self.mock_account_repo.get_by_id.return_value = None + + result = self.service.create_article( + title="Ghost Article", + content="Content from beyond!", + author_id=999, + author_role=AccountRole.AUTHOR, + ) + + self.mock_account_repo.get_by_id.assert_called_once_with(999) + self.mock_article_repo.save.assert_not_called() + assert result == "Account not found." + + +class TestGetArticles(ArticleServiceTestBase): + def test_get_all_ordered_by_date_desc(self): + fake_articles = [ + Article( + article_id=2, + article_author_id=1, + article_title="Recent Article", + article_content="Content 2", + article_published_at=datetime(2026, 3, 25), + ), + Article( + article_id=1, + article_author_id=1, + article_title="Old Article", + article_content="Content 1", + article_published_at=datetime(2026, 1, 1), + ), + ] + + self.mock_article_repo.get_all_ordered_by_date_desc.return_value = fake_articles + result = self.service.get_all_ordered_by_date_desc() + self.mock_article_repo.get_all_ordered_by_date_desc.assert_called_once() + assert len(result) == 2 + first_article_list = result[0] + assert first_article_list.article_title == "Recent Article" + + def test_get_paginated_articles(self): + fake_articles = [ + Article( + article_id=1, + article_author_id=1, + article_title="First", + article_content="Content 1", + article_published_at=datetime.now(), + ), + Article( + article_id=2, + article_author_id=1, + article_title="Second", + article_content="Content 2", + article_published_at=datetime.now(), + ) + ] + + self.mock_article_repo.get_paginated.return_value = fake_articles + result = self.service.get_paginated_articles(page=2, per_page=10) + self.mock_article_repo.get_paginated.assert_called_once_with(2, 10) + assert len(result) == 2 + first_article_list = result[0] + second_article_list = result[1] + assert first_article_list.article_title == "First" + assert second_article_list.article_title == "Second" + + def test_get_paginated_articles_page_less_than_one(self): + fake_articles = [ + Article( + article_id=1, + article_author_id=1, + article_title="Paged Title", + article_content="Paged Content", + article_published_at=datetime.now(), + ) + ] + + self.mock_article_repo.get_paginated.return_value = fake_articles + result = self.service.get_paginated_articles(page=-5, per_page=10) + self.mock_article_repo.get_paginated.assert_called_once_with(1, 10) + assert len(result) == 1 + + def test_get_paginated_articles_defaults(self): + self.mock_article_repo.get_paginated.return_value = [] + self.service.get_paginated_articles() + page = 1 + per_page = 10 + self.mock_article_repo.get_paginated.assert_called_once_with(page, per_page) + + def test_get_total_count(self): + self.mock_article_repo.count_all.return_value = 42 + result = self.service.get_total_count() + self.mock_article_repo.count_all.assert_called_once() + assert result == 42 + + +class TestGetArticleById(ArticleServiceTestBase): + def test_get_by_id_found(self): + fake_article = Article( + article_id=1, article_author_id=1, - article_title="Recent Article", - article_content="Content 2", - article_published_at=datetime(2026, 3, 25), - ), - Article( + article_title="Found Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + self.mock_article_repo.get_by_id.return_value = fake_article + result = self.service.get_by_id(article_id=fake_article.article_id) + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + assert result is not None + assert result.article_title == "Found Article" + + def test_get_by_id_not_found(self): + self.mock_article_repo.get_by_id.return_value = None + result = self.service.get_by_id(article_id=999) + self.mock_article_repo.get_by_id.assert_called_once_with(999) + assert result is None + + +class TestUpdateArticle(ArticleServiceTestBase): + def test_update_article_success(self): + fake_article = Article( article_id=1, article_author_id=1, - article_title="Old Article", - article_content="Content 1", - article_published_at=datetime(2026, 1, 1), - ), - ] - - mock_article_repo.get_all_ordered_by_date_desc.return_value = fake_articles - result = service.get_all_ordered_by_date_desc() - mock_article_repo.get_all_ordered_by_date_desc.assert_called_once() - assert len(result) == 2 - first_article_list = result[0] - assert first_article_list.article_title == "Recent Article" - - -def test_get_by_id_found(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Found Article", - article_content="Content", - article_published_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - result = service.get_by_id(article_id=fake_article.article_id) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - assert result is not None - assert result.article_title == "Found Article" - - -def test_get_by_id_not_found(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - mock_article_repo.get_by_id.return_value = None - result = service.get_by_id(article_id=999) - mock_article_repo.get_by_id.assert_called_once_with(999) - assert result is None - - -def test_update_article_success(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Old Title", - article_content="Old Content", - article_published_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - result = service.update_article( - article_id=fake_article.article_id, - user_id=fake_account.account_id, - title="New Title", - content="New Content", - ) - - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - assert result is not None - assert result.article_title == "New Title" - assert result.article_content == "New Content" - - -def test_update_article_unauthorized(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Old Title", - article_content="Old Content", - article_published_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - - fake_account = Account( - account_id=99, - account_username="hacker", - account_password="password123", - account_email="hacker@cyber.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - result = service.update_article( - article_id=fake_article.article_id, - user_id=fake_account.account_id, - title="Hacked Title", - content="Hacked Content", - ) - - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - assert result == "Unauthorized : You are not the author of this article." - - -def test_update_article_insufficient_role(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Old Title", - article_content="Old Content", - article_published_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - result = service.update_article( - article_id=fake_article.article_id, - user_id=fake_account.account_id, - title="Hacked Title", - content="Hacked Content", - ) - - mock_article_repo.get_by_id.assert_not_called() - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - assert result == "Insufficient permissions." - - -def test_update_article_not_found(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - mock_article_repo.get_by_id.return_value = None - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - result = service.update_article( - article_id=999, - user_id=fake_account.account_id, - title="New Title", - content="New Content", - ) - - mock_article_repo.get_by_id.assert_called_once_with(999) - assert result == "Article not found." - - -def test_delete_article_success_by_author(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="To Be Deleted", - article_content="Delete me", - article_published_at=datetime.now(), - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - mock_account_repo.get_by_id.return_value = fake_account - result = service.delete_article(article_id=fake_article.article_id, user_id=fake_account.account_id) - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - mock_article_repo.delete.assert_called_once_with(fake_article) - assert result is True - - -def test_delete_article_success_by_admin(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="To Be Deleted", - article_content="Delete me", - article_published_at=datetime.now(), - ) - - fake_admin = Account( - account_id=99, - account_username="admin2", - account_password="password123", - account_email="admin@cyber.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - mock_account_repo.get_by_id.return_value = fake_admin - result = service.delete_article(article_id=fake_article.article_id, user_id=fake_admin.account_id) - mock_account_repo.get_by_id.assert_called_once_with(fake_admin.account_id) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - mock_article_repo.delete.assert_called_once_with(fake_article) - assert result is True - - -def test_delete_article_unauthorized_ownership(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="To Be Deleted", - article_content="Delete me", - article_published_at=datetime.now(), - ) - - fake_author_other = Account( - account_id=99, - account_username="other", - account_password="password123", - account_email="other@cyber.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - mock_account_repo.get_by_id.return_value = fake_author_other - - result = service.delete_article(article_id=fake_article.article_id, user_id=fake_author_other.account_id) - - mock_account_repo.get_by_id.assert_called_once_with(fake_author_other.account_id) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - mock_article_repo.delete.assert_not_called() - assert result == "Unauthorized : Only authors or admins can delete articles." - - -def test_delete_article_not_found(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = None - mock_account_repo.get_by_id.return_value = fake_account - - result = service.delete_article(article_id=999, user_id=fake_account.account_id) - - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_article_repo.get_by_id.assert_called_once_with(999) - mock_article_repo.delete.assert_not_called() - assert result == "Article not found." - - -def test_get_paginated_articles(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_articles = [ - Article( + article_title="Old Title", + article_content="Old Content", + article_published_at=datetime.now(), + ) + + self.mock_article_repo.get_by_id.return_value = fake_article + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.AUTHOR, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + + result = self.service.update_article( + article_id=fake_article.article_id, + user_id=fake_account.account_id, + title="New Title", + content="New Content", + ) + + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + assert result is not None + assert result.article_title == "New Title" + assert result.article_content == "New Content" + + def test_update_article_unauthorized(self): + fake_article = Article( article_id=1, article_author_id=1, - article_title="First", - article_content="Content 1", + article_title="Old Title", + article_content="Old Content", article_published_at=datetime.now(), - ), - Article( - article_id=2, + ) + + self.mock_article_repo.get_by_id.return_value = fake_article + + fake_account = Account( + account_id=99, + account_username="hacker", + account_password="password123", + account_email="hacker@cyber.com", + account_role=AccountRole.ADMIN, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + + result = self.service.update_article( + article_id=fake_article.article_id, + user_id=fake_account.account_id, + title="Hacked Title", + content="Hacked Content", + ) + + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + assert result == "Unauthorized : You are not the author of this article." + + def test_update_article_insufficient_role(self): + fake_article = Article( + article_id=1, article_author_id=1, - article_title="Second", - article_content="Content 2", + article_title="Old Title", + article_content="Old Content", article_published_at=datetime.now(), ) - ] - mock_article_repo.get_paginated.return_value = fake_articles - result = service.get_paginated_articles(page=2, per_page=10) - mock_article_repo.get_paginated.assert_called_once_with(2, 10) - assert len(result) == 2 - first_article_list = result[0] - second_article_list = result[1] - assert first_article_list.article_title == "First" - assert second_article_list.article_title == "Second" + self.mock_article_repo.get_by_id.return_value = fake_article + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + result = self.service.update_article( + article_id=fake_article.article_id, + user_id=fake_account.account_id, + title="Hacked Title", + content="Hacked Content", + ) + + self.mock_article_repo.get_by_id.assert_not_called() + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + assert result == "Insufficient permissions." -def test_get_paginated_articles_page_less_than_one(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) + def test_update_article_not_found(self): + self.mock_article_repo.get_by_id.return_value = None + + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.AUTHOR, + account_created_at=datetime.now(), + ) - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) + self.mock_account_repo.get_by_id.return_value = fake_account - fake_articles = [ - Article( + result = self.service.update_article( + article_id=999, + user_id=fake_account.account_id, + title="New Title", + content="New Content", + ) + + self.mock_article_repo.get_by_id.assert_called_once_with(999) + assert result == "Article not found." + + +class TestDeleteArticle(ArticleServiceTestBase): + def test_delete_article_success_by_author(self): + fake_article = Article( article_id=1, article_author_id=1, - article_title="Paged Title", - article_content="Paged Content", + article_title="To Be Deleted", + article_content="Delete me", article_published_at=datetime.now(), ) - ] - mock_article_repo.get_paginated.return_value = fake_articles - result = service.get_paginated_articles(page=-5, per_page=10) - mock_article_repo.get_paginated.assert_called_once_with(1, 10) - assert len(result) == 1 + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.AUTHOR, + account_created_at=datetime.now(), + ) + self.mock_article_repo.get_by_id.return_value = fake_article + self.mock_account_repo.get_by_id.return_value = fake_account + result = self.service.delete_article(article_id=fake_article.article_id, user_id=fake_account.account_id) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_article_repo.delete.assert_called_once_with(fake_article) + assert result is True -def test_get_total_count(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) + def test_delete_article_success_by_admin(self): + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="To Be Deleted", + article_content="Delete me", + article_published_at=datetime.now(), + ) - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) + fake_admin = Account( + account_id=99, + account_username="admin2", + account_password="password123", + account_email="admin@cyber.com", + account_role=AccountRole.ADMIN, + account_created_at=datetime.now(), + ) - mock_article_repo.count_all.return_value = 42 - result = service.get_total_count() - mock_article_repo.count_all.assert_called_once() - assert result == 42 + self.mock_article_repo.get_by_id.return_value = fake_article + self.mock_account_repo.get_by_id.return_value = fake_admin + result = self.service.delete_article(article_id=fake_article.article_id, user_id=fake_admin.account_id) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_admin.account_id) + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_article_repo.delete.assert_called_once_with(fake_article) + assert result is True + def test_delete_article_unauthorized_ownership(self): + fake_article = Article( + article_id=1, + article_author_id=1, + article_title="To Be Deleted", + article_content="Delete me", + article_published_at=datetime.now(), + ) -def test_get_paginated_articles_defaults(): - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) + fake_author_other = Account( + account_id=99, + account_username="other", + account_password="password123", + account_email="other@cyber.com", + account_role=AccountRole.AUTHOR, + account_created_at=datetime.now(), + ) - service = ArticleService( - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) + self.mock_article_repo.get_by_id.return_value = fake_article + self.mock_account_repo.get_by_id.return_value = fake_author_other + result = self.service.delete_article(article_id=fake_article.article_id, user_id=fake_author_other.account_id) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_author_other.account_id) + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_article_repo.delete.assert_not_called() + assert result == "Unauthorized : Only authors or admins can delete articles." + + def test_delete_article_not_found(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.AUTHOR, + account_created_at=datetime.now(), + ) - mock_article_repo.get_paginated.return_value = [] - service.get_paginated_articles() - page = 1 - per_page = 10 - mock_article_repo.get_paginated.assert_called_once_with(page, per_page) + self.mock_article_repo.get_by_id.return_value = None + self.mock_account_repo.get_by_id.return_value = fake_account + result = self.service.delete_article(article_id=999, user_id=fake_account.account_id) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.get_by_id.assert_called_once_with(999) + self.mock_article_repo.delete.assert_not_called() + assert result == "Article not found." From 9d0301a1d2adcd5b7a4589a708a1e0deff1bec95 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 1 Apr 2026 00:45:58 +0200 Subject: [PATCH 20/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Grouped=20the=2013=20CommentService=20tests=20into=20four=20?= =?UTF-8?q?action=E2=80=91focused=20classes=20and=20introduced=20a=20share?= =?UTF-8?q?d=20base=20class=20to=20handle=20common=20setup=20for=20the=20s?= =?UTF-8?q?ervice=20and=20its=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests_services/test_comment_service.py | 748 ++++++++---------- 1 file changed, 313 insertions(+), 435 deletions(-) diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index 400e2a7..cef5263 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -10,438 +10,316 @@ from src.application.services.comment_service import CommentService -def test_create_comment_success(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - fake_article = Article( - article_id=1, - article_author_id=2, - article_title="My Article", - article_content="Content", - article_published_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - - result = service.create_comment( - article_id=fake_article.article_id, - user_id=fake_account.account_id, - content="Great post!" - ) - - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - mock_comment_repo.save.assert_called_once() - index_args = 0 - index_kwargs = 0 - saved_comment = mock_comment_repo.save.call_args[index_args][index_kwargs] - assert saved_comment.comment_article_id == fake_article.article_id - assert saved_comment.comment_written_account_id == fake_account.account_id - assert saved_comment.comment_reply_to is None - assert saved_comment.comment_content == "Great post!" - assert result is saved_comment - - -def test_create_comment_account_not_found(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - mock_account_repo.get_by_id.return_value = None - - result = service.create_comment( - article_id=1, - user_id=999, - content="This will not post." - ) - - mock_account_repo.get_by_id.assert_called_once_with(999) - mock_article_repo.get_by_id.assert_not_called() - mock_comment_repo.save.assert_not_called() - assert result == "Account not found." - - -def test_create_comment_article_not_found(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - mock_article_repo.get_by_id.return_value = None - - result = service.create_comment( - article_id=999, - user_id=fake_account.account_id, - content="Writing in the void." - ) - - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_article_repo.get_by_id.assert_called_once_with(999) - mock_comment_repo.save.assert_not_called() - assert result == "Article not found." - - -def test_create_reply_success_root_comment(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - parent_comment = Comment( - comment_id=10, - comment_article_id=5, - comment_written_account_id=2, - comment_reply_to=None, - comment_content="Root comment", - comment_posted_at=datetime.now(), - ) - - mock_comment_repo.get_by_id.return_value = parent_comment - result = service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="This is a reply") - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_comment_repo.get_by_id.assert_called_once_with(parent_comment.comment_id) - mock_comment_repo.save.assert_called_once() - index_args = 0 - index_kwargs = 0 - saved_reply = mock_comment_repo.save.call_args[index_args][index_kwargs] - - assert saved_reply.comment_article_id == parent_comment.comment_article_id - assert saved_reply.comment_written_account_id == fake_account.account_id - assert saved_reply.comment_reply_to == parent_comment.comment_id - assert saved_reply.comment_content == "This is a reply" - assert result is saved_reply - - -def test_create_reply_success_nested_comment(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - - parent_comment = Comment( - comment_id=15, - comment_article_id=5, - comment_written_account_id=2, - comment_reply_to=10, - comment_content="I am a reply", - comment_posted_at=datetime.now(), - ) - mock_comment_repo.get_by_id.return_value = parent_comment - result = service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="Replying to a reply") - mock_comment_repo.save.assert_called_once() - index_args = 0 - index_kwargs = 0 - saved_reply = mock_comment_repo.save.call_args[index_args][index_kwargs] - assert saved_reply.comment_reply_to == 10 - assert result is saved_reply - - -def test_create_reply_parent_not_found(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - mock_comment_repo.get_by_id.return_value = None - result = service.create_reply(parent_comment_id=999, user_id=fake_account.account_id, content="Replying to nothing") - mock_comment_repo.get_by_id.assert_called_once_with(999) - mock_comment_repo.save.assert_not_called() - assert result == "Parent comment not found." - - -def test_get_comments_for_article_not_found(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - mock_article_repo.get_by_id.return_value = None - result = service.get_comments_for_article(article_id=999) - mock_article_repo.get_by_id.assert_called_once_with(999) - mock_comment_repo.get_all_by_article_id.assert_not_called() - assert result == "Article not found." - - -def test_get_comments_for_article_empty(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=2, - article_title="My Article", - article_content="Content", - article_published_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - mock_comment_repo.get_all_by_article_id.return_value = [] - result = service.get_comments_for_article(article_id=fake_article.article_id) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) - assert isinstance(result, dict) - assert result == {"root": []} - - -def test_get_comments_for_article_success(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_article = Article( - article_id=1, - article_author_id=2, - article_title="My Article", - article_content="Content", - article_published_at=datetime.now(), - ) - - mock_article_repo.get_by_id.return_value = fake_article - - root_comment = Comment( - comment_id=10, - comment_article_id=fake_article.article_id, - comment_written_account_id=3, - comment_reply_to=None, - comment_content="First!", - comment_posted_at=datetime.now(), - ) - - reply = Comment( - comment_id=15, - comment_article_id=fake_article.article_id, - comment_written_account_id=4, - comment_reply_to=root_comment.comment_id, - comment_content="Awesome!", - comment_posted_at=datetime.now(), - ) - - mock_comment_repo.get_all_by_article_id.return_value = [root_comment, reply] - result = service.get_comments_for_article(article_id=fake_article.article_id) - mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) - mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) - assert isinstance(result, dict) - assert "root" in result - assert result["root"] == [root_comment] - comment_id_key = root_comment.comment_id - assert comment_id_key in result - assert result[comment_id_key] == [reply] - - -def test_delete_comment_success_as_admin(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - admin_account = Account( - account_id=1, - account_username="admin_user", - account_password="password123", - account_email="admin@galaxy.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = admin_account - - comment_to_delete = Comment( - comment_id=10, - comment_article_id=5, - comment_written_account_id=2, - comment_reply_to=None, - comment_content="Bad comment", - comment_posted_at=datetime.now(), - ) - - mock_comment_repo.get_by_id.return_value = comment_to_delete - result = service.delete_comment(comment_id=comment_to_delete.comment_id, user_id=admin_account.account_id) - mock_account_repo.get_by_id.assert_called_once_with(admin_account.account_id) - mock_comment_repo.get_by_id.assert_called_once_with(comment_to_delete.comment_id) - mock_comment_repo.delete.assert_called_once_with(comment_to_delete.comment_id) - assert result is True - - -def test_delete_comment_unauthorized_not_admin(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=2, - account_username="regular_user", - account_password="password123", - account_email="user@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - result = service.delete_comment(comment_id=10, user_id=fake_account.account_id) - mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - mock_comment_repo.get_by_id.assert_not_called() - mock_comment_repo.delete.assert_not_called() - assert result == "Unauthorized : Only admins can delete comments." - - -def test_delete_comment_not_found(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - fake_account = Account( - account_id=1, - account_username="admin_user", - account_password="password123", - account_email="admin@galaxy.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - - mock_account_repo.get_by_id.return_value = fake_account - mock_comment_repo.get_by_id.return_value = None - result = service.delete_comment(comment_id=999, user_id=fake_account.account_id) - mock_comment_repo.get_by_id.assert_called_once_with(999) - mock_comment_repo.delete.assert_not_called() - assert result == "Comment not found." - - -def test_delete_comment_account_not_found(): - mock_comment_repo = MagicMock(spec=CommentRepository) - mock_article_repo = MagicMock(spec=ArticleRepository) - mock_account_repo = MagicMock(spec=AccountRepository) - - service = CommentService( - comment_repository=mock_comment_repo, - article_repository=mock_article_repo, - account_repository=mock_account_repo - ) - - mock_account_repo.get_by_id.return_value = None - result = service.delete_comment(comment_id=10, user_id=999) - mock_account_repo.get_by_id.assert_called_once_with(999) - mock_comment_repo.get_by_id.assert_not_called() - mock_comment_repo.delete.assert_not_called() - assert result == "Account not found." +class CommentServiceTestBase: + def setup_method(self): + self.mock_comment_repo = MagicMock(spec=CommentRepository) + self.mock_article_repo = MagicMock(spec=ArticleRepository) + self.mock_account_repo = MagicMock(spec=AccountRepository) + + self.service = CommentService( + comment_repository=self.mock_comment_repo, + article_repository=self.mock_article_repo, + account_repository=self.mock_account_repo + ) + + +class TestCreateComment(CommentServiceTestBase): + def test_create_comment_success(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + + fake_article = Article( + article_id=1, + article_author_id=2, + article_title="My Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + self.mock_article_repo.get_by_id.return_value = fake_article + + result = self.service.create_comment( + article_id=fake_article.article_id, + user_id=fake_account.account_id, + content="Great post!" + ) + + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_comment_repo.save.assert_called_once() + index_args = 0 + index_kwargs = 0 + saved_comment = self.mock_comment_repo.save.call_args[index_args][index_kwargs] + assert saved_comment.comment_article_id == fake_article.article_id + assert saved_comment.comment_written_account_id == fake_account.account_id + assert saved_comment.comment_reply_to is None + assert saved_comment.comment_content == "Great post!" + assert result is saved_comment + + def test_create_comment_account_not_found(self): + self.mock_account_repo.get_by_id.return_value = None + + result = self.service.create_comment( + article_id=1, + user_id=999, + content="This will not post." + ) + + self.mock_account_repo.get_by_id.assert_called_once_with(999) + self.mock_article_repo.get_by_id.assert_not_called() + self.mock_comment_repo.save.assert_not_called() + assert result == "Account not found." + + def test_create_comment_article_not_found(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + self.mock_article_repo.get_by_id.return_value = None + + result = self.service.create_comment( + article_id=999, + user_id=fake_account.account_id, + content="Writing in the void." + ) + + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.get_by_id.assert_called_once_with(999) + self.mock_comment_repo.save.assert_not_called() + assert result == "Article not found." + + +class TestCreateReply(CommentServiceTestBase): + def test_create_reply_success_root_comment(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + + parent_comment = Comment( + comment_id=10, + comment_article_id=5, + comment_written_account_id=2, + comment_reply_to=None, + comment_content="Root comment", + comment_posted_at=datetime.now(), + ) + + self.mock_comment_repo.get_by_id.return_value = parent_comment + result = self.service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="This is a reply") + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_comment_repo.get_by_id.assert_called_once_with(parent_comment.comment_id) + self.mock_comment_repo.save.assert_called_once() + index_args = 0 + index_kwargs = 0 + saved_reply = self.mock_comment_repo.save.call_args[index_args][index_kwargs] + + assert saved_reply.comment_article_id == parent_comment.comment_article_id + assert saved_reply.comment_written_account_id == fake_account.account_id + assert saved_reply.comment_reply_to == parent_comment.comment_id + assert saved_reply.comment_content == "This is a reply" + assert result is saved_reply + + def test_create_reply_success_nested_comment(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + + parent_comment = Comment( + comment_id=15, + comment_article_id=5, + comment_written_account_id=2, + comment_reply_to=10, + comment_content="I am a reply", + comment_posted_at=datetime.now(), + ) + self.mock_comment_repo.get_by_id.return_value = parent_comment + result = self.service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="Replying to a reply") + self.mock_comment_repo.save.assert_called_once() + index_args = 0 + index_kwargs = 0 + saved_reply = self.mock_comment_repo.save.call_args[index_args][index_kwargs] + assert saved_reply.comment_reply_to == 10 + assert result is saved_reply + + def test_create_reply_parent_not_found(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + self.mock_comment_repo.get_by_id.return_value = None + result = self.service.create_reply(parent_comment_id=999, user_id=fake_account.account_id, content="Replying to nothing") + self.mock_comment_repo.get_by_id.assert_called_once_with(999) + self.mock_comment_repo.save.assert_not_called() + assert result == "Parent comment not found." + + +class TestGetComments(CommentServiceTestBase): + def test_get_comments_for_article_not_found(self): + self.mock_article_repo.get_by_id.return_value = None + result = self.service.get_comments_for_article(article_id=999) + self.mock_article_repo.get_by_id.assert_called_once_with(999) + self.mock_comment_repo.get_all_by_article_id.assert_not_called() + assert result == "Article not found." + + def test_get_comments_for_article_empty(self): + fake_article = Article( + article_id=1, + article_author_id=2, + article_title="My Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + self.mock_article_repo.get_by_id.return_value = fake_article + self.mock_comment_repo.get_all_by_article_id.return_value = [] + result = self.service.get_comments_for_article(article_id=fake_article.article_id) + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) + assert isinstance(result, dict) + assert result == {"root": []} + + def test_get_comments_for_article_success(self): + fake_article = Article( + article_id=1, + article_author_id=2, + article_title="My Article", + article_content="Content", + article_published_at=datetime.now(), + ) + + self.mock_article_repo.get_by_id.return_value = fake_article + + root_comment = Comment( + comment_id=10, + comment_article_id=fake_article.article_id, + comment_written_account_id=3, + comment_reply_to=None, + comment_content="First!", + comment_posted_at=datetime.now(), + ) + + reply = Comment( + comment_id=15, + comment_article_id=fake_article.article_id, + comment_written_account_id=4, + comment_reply_to=root_comment.comment_id, + comment_content="Awesome!", + comment_posted_at=datetime.now(), + ) + + self.mock_comment_repo.get_all_by_article_id.return_value = [root_comment, reply] + result = self.service.get_comments_for_article(article_id=fake_article.article_id) + self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) + assert isinstance(result, dict) + assert "root" in result + assert result["root"] == [root_comment] + comment_id_key = root_comment.comment_id + assert comment_id_key in result + assert result[comment_id_key] == [reply] + + +class TestDeleteComment(CommentServiceTestBase): + def test_delete_comment_success_as_admin(self): + admin_account = Account( + account_id=1, + account_username="admin_user", + account_password="password123", + account_email="admin@galaxy.com", + account_role=AccountRole.ADMIN, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = admin_account + + comment_to_delete = Comment( + comment_id=10, + comment_article_id=5, + comment_written_account_id=2, + comment_reply_to=None, + comment_content="Bad comment", + comment_posted_at=datetime.now(), + ) + + self.mock_comment_repo.get_by_id.return_value = comment_to_delete + result = self.service.delete_comment(comment_id=comment_to_delete.comment_id, user_id=admin_account.account_id) + self.mock_account_repo.get_by_id.assert_called_once_with(admin_account.account_id) + self.mock_comment_repo.get_by_id.assert_called_once_with(comment_to_delete.comment_id) + self.mock_comment_repo.delete.assert_called_once_with(comment_to_delete.comment_id) + assert result is True + + def test_delete_comment_unauthorized_not_admin(self): + fake_account = Account( + account_id=2, + account_username="regular_user", + account_password="password123", + account_email="user@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + result = self.service.delete_comment(comment_id=10, user_id=fake_account.account_id) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_comment_repo.get_by_id.assert_not_called() + self.mock_comment_repo.delete.assert_not_called() + assert result == "Unauthorized : Only admins can delete comments." + + def test_delete_comment_not_found(self): + fake_account = Account( + account_id=1, + account_username="admin_user", + account_password="password123", + account_email="admin@galaxy.com", + account_role=AccountRole.ADMIN, + account_created_at=datetime.now(), + ) + + self.mock_account_repo.get_by_id.return_value = fake_account + self.mock_comment_repo.get_by_id.return_value = None + result = self.service.delete_comment(comment_id=999, user_id=fake_account.account_id) + self.mock_comment_repo.get_by_id.assert_called_once_with(999) + self.mock_comment_repo.delete.assert_not_called() + assert result == "Comment not found." + + def test_delete_comment_account_not_found(self): + self.mock_account_repo.get_by_id.return_value = None + result = self.service.delete_comment(comment_id=10, user_id=999) + self.mock_account_repo.get_by_id.assert_called_once_with(999) + self.mock_comment_repo.get_by_id.assert_not_called() + self.mock_comment_repo.delete.assert_not_called() + assert result == "Account not found." From d169348c873573b6fcd673a467a3e870ea7caf3c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 1 Apr 2026 01:20:08 +0200 Subject: [PATCH 21/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20=20Reorganize=20login=20and=20registration=20test=20suites?= =?UTF-8?q?=20by=20introducing=20action=E2=80=91focused=20classes=20and=20?= =?UTF-8?q?shared=20test=20bases=20to=20remove=20duplicated=20setup,=20whi?= =?UTF-8?q?le=20replacing=20hardcoded=20strings=20with=20entity=20properti?= =?UTF-8?q?es=20for=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests_services/test_login_service.py | 89 ++++++----- .../test_registration_service.py | 145 +++++++++--------- 2 files changed, 114 insertions(+), 120 deletions(-) diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py index f377b5e..1921fb9 100644 --- a/tests_hexagonal/tests_services/test_login_service.py +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -6,49 +6,46 @@ from src.application.services.login_service import LoginService -def test_authenticate_user_success(): - mock_repo = MagicMock(spec=AccountRepository) - login_service = LoginService(account_repository=mock_repo) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now() - ) - - mock_repo.find_by_username.return_value = fake_account - result = login_service.authenticate_user(username="leia", password="password123") - mock_repo.find_by_username.assert_called_once_with("leia") - assert result is not None - assert result.account_username == "leia" - - -def test_authenticate_user_wrong_password(): - mock_repo = MagicMock(spec=AccountRepository) - login_service = LoginService(account_repository=mock_repo) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now() - ) - - mock_repo.find_by_username.return_value = fake_account - result = login_service.authenticate_user(username="leia", password="bad_password") - mock_repo.find_by_username.assert_called_once_with("leia") - assert result is None - - -def test_authenticate_user_non_existent(): - mock_repo = MagicMock(spec=AccountRepository) - login_service = LoginService(account_repository=mock_repo) - mock_repo.find_by_username.return_value = None - result = login_service.authenticate_user(username="phantom", password="nothing") - mock_repo.find_by_username.assert_called_once_with("phantom") - assert result is None +class LoginServiceTestBase: + def setup_method(self): + self.mock_repo = MagicMock(spec=AccountRepository) + self.service = LoginService(account_repository=self.mock_repo) + + +class TestAuthenticateUser(LoginServiceTestBase): + def test_authenticate_user_success(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now() + ) + + self.mock_repo.find_by_username.return_value = fake_account + result = self.service.authenticate_user(username=fake_account.account_username, password=fake_account.account_password) + self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + assert result is not None + assert result.account_username == "leia" + + def test_authenticate_user_wrong_password(self): + fake_account = Account( + account_id=1, + account_username="leia", + account_password="password123", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now() + ) + + self.mock_repo.find_by_username.return_value = fake_account + result = self.service.authenticate_user(username=fake_account.account_username, password="bad_password") + self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + assert result is None + + def test_authenticate_user_non_existent(self): + self.mock_repo.find_by_username.return_value = None + result = self.service.authenticate_user(username="phantom", password="nothing") + self.mock_repo.find_by_username.assert_called_once_with("phantom") + assert result is None diff --git a/tests_hexagonal/tests_services/test_registration_service.py b/tests_hexagonal/tests_services/test_registration_service.py index 94d3f56..9ad5309 100644 --- a/tests_hexagonal/tests_services/test_registration_service.py +++ b/tests_hexagonal/tests_services/test_registration_service.py @@ -6,77 +6,74 @@ from src.application.services.registration_service import RegistrationService -def test_create_account_success(): - mock_repo = MagicMock(spec=AccountRepository) - service = RegistrationService(account_repository=mock_repo) - mock_repo.find_by_username.return_value = None - mock_repo.find_by_email.return_value = None - - result = service.create_account( - username="leia", - password="password123", - email="leia@galaxy.com" - ) - - mock_repo.find_by_username.assert_called_once_with("leia") - mock_repo.find_by_email.assert_called_once_with("leia@galaxy.com") - mock_repo.save.assert_called_once_with(result) - assert isinstance(result, Account) - assert result.account_username == "leia" - assert result.account_email == "leia@galaxy.com" - assert result.account_role == AccountRole.USER - - -def test_create_account_username_taken(): - mock_repo = MagicMock(spec=AccountRepository) - service = RegistrationService(account_repository=mock_repo) - - existing_account = Account( - account_id=1, - account_username="leia", - account_password="existing_pass", - account_email="existing@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_repo.find_by_username.return_value = existing_account - - result = service.create_account( - username="leia", - password="password123", - email="new@galaxy.com" - ) - - mock_repo.find_by_username.assert_called_once_with("leia") - mock_repo.find_by_email.assert_not_called() - mock_repo.save.assert_not_called() - assert result == "This username is already taken." - - -def test_create_account_email_taken(): - mock_repo = MagicMock(spec=AccountRepository) - service = RegistrationService(account_repository=mock_repo) - - existing_account = Account( - account_id=2, - account_username="han", - account_password="other_pass", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - - mock_repo.find_by_username.return_value = None - mock_repo.find_by_email.return_value = existing_account - - result = service.create_account( - username="new_user", - password="password123", - email="leia@galaxy.com" - ) - - mock_repo.find_by_username.assert_called_once_with("new_user") - mock_repo.find_by_email.assert_called_once_with("leia@galaxy.com") - mock_repo.save.assert_not_called() - assert result == "This email is already taken." +class RegistrationServiceTestBase: + def setup_method(self): + self.mock_repo = MagicMock(spec=AccountRepository) + self.service = RegistrationService(account_repository=self.mock_repo) + + +class TestCreateAccount(RegistrationServiceTestBase): + def test_create_account_success(self): + self.mock_repo.find_by_username.return_value = None + self.mock_repo.find_by_email.return_value = None + + result = self.service.create_account( + username="leia", + password="password123", + email="leia@galaxy.com" + ) + + self.mock_repo.find_by_username.assert_called_once_with("leia") + self.mock_repo.find_by_email.assert_called_once_with("leia@galaxy.com") + self.mock_repo.save.assert_called_once_with(result) + assert isinstance(result, Account) + assert result.account_username == "leia" + assert result.account_email == "leia@galaxy.com" + assert result.account_role == AccountRole.USER + + def test_create_account_username_taken(self): + existing_account = Account( + account_id=1, + account_username="leia", + account_password="existing_pass", + account_email="existing@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_repo.find_by_username.return_value = existing_account + + result = self.service.create_account( + username="leia", + password="password123", + email="new@galaxy.com" + ) + + self.mock_repo.find_by_username.assert_called_once_with(existing_account.account_username) + self.mock_repo.find_by_email.assert_not_called() + self.mock_repo.save.assert_not_called() + assert result == "This username is already taken." + + def test_create_account_email_taken(self): + existing_account = Account( + account_id=2, + account_username="han", + account_password="other_pass", + account_email="leia@galaxy.com", + account_role=AccountRole.USER, + account_created_at=datetime.now(), + ) + + self.mock_repo.find_by_username.return_value = None + self.mock_repo.find_by_email.return_value = existing_account + + result = self.service.create_account( + username="new_user", + password="password123", + email="leia@galaxy.com" + ) + + self.mock_repo.find_by_username.assert_called_once_with("new_user") + self.mock_repo.find_by_email.assert_called_once_with(existing_account.account_email) + self.mock_repo.save.assert_not_called() + assert result == "This email is already taken." From 55e81dff8193a6d834e04c658c1cee164724ca4f Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 1 Apr 2026 03:07:11 +0200 Subject: [PATCH 22/81] MVCS to hexagonal architecture : Restored autospec=True on all Repository mocks in setup_method to enforce accurate interface usage and ensure tests fail when calling undefined methods --- .../tests_services/test_article_service.py | 213 +++--------------- .../tests_services/test_comment_service.py | 147 ++---------- .../tests_services/test_domain_factories.py | 68 ++++++ .../tests_services/test_login_service.py | 23 +- .../test_registration_service.py | 18 +- 5 files changed, 128 insertions(+), 341 deletions(-) create mode 100644 tests_hexagonal/tests_services/test_domain_factories.py diff --git a/tests_hexagonal/tests_services/test_article_service.py b/tests_hexagonal/tests_services/test_article_service.py index 5c896c4..d73a25a 100644 --- a/tests_hexagonal/tests_services/test_article_service.py +++ b/tests_hexagonal/tests_services/test_article_service.py @@ -1,17 +1,17 @@ from datetime import datetime from unittest.mock import MagicMock -from src.application.domain.account import Account, AccountRole -from src.application.domain.article import Article +from src.application.domain.account import AccountRole from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.services.article_service import ArticleService +from tests_hexagonal.tests_services.test_domain_factories import create_test_account, create_test_article class ArticleServiceTestBase: def setup_method(self): - self.mock_article_repo = MagicMock(spec=ArticleRepository) - self.mock_account_repo = MagicMock(spec=AccountRepository) + self.mock_article_repo = MagicMock(spec=ArticleRepository, autospec=True) + self.mock_account_repo = MagicMock(spec=AccountRepository, autospec=True) self.service = ArticleService( article_repository=self.mock_article_repo, account_repository=self.mock_account_repo @@ -20,14 +20,7 @@ def setup_method(self): class TestCreateArticle(ArticleServiceTestBase): def test_create_article_success(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) + fake_account = create_test_account(account_role=AccountRole.ADMIN) self.mock_account_repo.get_by_id.return_value = fake_account @@ -40,19 +33,16 @@ def test_create_article_success(self): self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) self.mock_article_repo.save.assert_called_once_with(result) - assert isinstance(result, Article) assert result.article_title == "My First Article" assert result.article_content == "Hello World !" assert result.article_author_id == fake_account.account_id def test_create_article_unauthorized_role(self): - fake_account = Account( + fake_account = create_test_account( account_id=2, account_username="boris", - account_password="password123", account_email="boris@ordinary.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), + account_role=AccountRole.USER ) self.mock_account_repo.get_by_id.return_value = fake_account @@ -86,18 +76,14 @@ def test_create_article_account_not_found(self): class TestGetArticles(ArticleServiceTestBase): def test_get_all_ordered_by_date_desc(self): fake_articles = [ - Article( + create_test_article( article_id=2, - article_author_id=1, article_title="Recent Article", - article_content="Content 2", article_published_at=datetime(2026, 3, 25), ), - Article( + create_test_article( article_id=1, - article_author_id=1, article_title="Old Article", - article_content="Content 1", article_published_at=datetime(2026, 1, 1), ), ] @@ -111,20 +97,8 @@ def test_get_all_ordered_by_date_desc(self): def test_get_paginated_articles(self): fake_articles = [ - Article( - article_id=1, - article_author_id=1, - article_title="First", - article_content="Content 1", - article_published_at=datetime.now(), - ), - Article( - article_id=2, - article_author_id=1, - article_title="Second", - article_content="Content 2", - article_published_at=datetime.now(), - ) + create_test_article(article_id=1, article_title="First"), + create_test_article(article_id=2, article_title="Second") ] self.mock_article_repo.get_paginated.return_value = fake_articles @@ -137,16 +111,7 @@ def test_get_paginated_articles(self): assert second_article_list.article_title == "Second" def test_get_paginated_articles_page_less_than_one(self): - fake_articles = [ - Article( - article_id=1, - article_author_id=1, - article_title="Paged Title", - article_content="Paged Content", - article_published_at=datetime.now(), - ) - ] - + fake_articles = [create_test_article(article_title="Paged Title")] self.mock_article_repo.get_paginated.return_value = fake_articles result = self.service.get_paginated_articles(page=-5, per_page=10) self.mock_article_repo.get_paginated.assert_called_once_with(1, 10) @@ -155,9 +120,7 @@ def test_get_paginated_articles_page_less_than_one(self): def test_get_paginated_articles_defaults(self): self.mock_article_repo.get_paginated.return_value = [] self.service.get_paginated_articles() - page = 1 - per_page = 10 - self.mock_article_repo.get_paginated.assert_called_once_with(page, per_page) + self.mock_article_repo.get_paginated.assert_called_once_with(1, 10) def test_get_total_count(self): self.mock_article_repo.count_all.return_value = 42 @@ -168,14 +131,7 @@ def test_get_total_count(self): class TestGetArticleById(ArticleServiceTestBase): def test_get_by_id_found(self): - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Found Article", - article_content="Content", - article_published_at=datetime.now(), - ) - + fake_article = create_test_article(article_title="Found Article") self.mock_article_repo.get_by_id.return_value = fake_article result = self.service.get_by_id(article_id=fake_article.article_id) self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) @@ -191,25 +147,9 @@ def test_get_by_id_not_found(self): class TestUpdateArticle(ArticleServiceTestBase): def test_update_article_success(self): - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Old Title", - article_content="Old Content", - article_published_at=datetime.now(), - ) - + fake_article = create_test_article(article_id=1, article_title="Old Title") self.mock_article_repo.get_by_id.return_value = fake_article - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=1, account_role=AccountRole.AUTHOR) self.mock_account_repo.get_by_id.return_value = fake_account result = self.service.update_article( @@ -221,30 +161,13 @@ def test_update_article_success(self): self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) - assert result is not None assert result.article_title == "New Title" assert result.article_content == "New Content" def test_update_article_unauthorized(self): - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Old Title", - article_content="Old Content", - article_published_at=datetime.now(), - ) - + fake_article = create_test_article(article_author_id=1) self.mock_article_repo.get_by_id.return_value = fake_article - - fake_account = Account( - account_id=99, - account_username="hacker", - account_password="password123", - account_email="hacker@cyber.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=99, account_role=AccountRole.ADMIN) self.mock_account_repo.get_by_id.return_value = fake_account result = self.service.update_article( @@ -258,29 +181,11 @@ def test_update_article_unauthorized(self): assert result == "Unauthorized : You are not the author of this article." def test_update_article_insufficient_role(self): - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="Old Title", - article_content="Old Content", - article_published_at=datetime.now(), - ) - - self.mock_article_repo.get_by_id.return_value = fake_article - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account result = self.service.update_article( - article_id=fake_article.article_id, + article_id=1, user_id=fake_account.account_id, title="Hacked Title", content="Hacked Content", @@ -292,16 +197,7 @@ def test_update_article_insufficient_role(self): def test_update_article_not_found(self): self.mock_article_repo.get_by_id.return_value = None - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_role=AccountRole.AUTHOR) self.mock_account_repo.get_by_id.return_value = fake_account result = self.service.update_article( @@ -317,23 +213,8 @@ def test_update_article_not_found(self): class TestDeleteArticle(ArticleServiceTestBase): def test_delete_article_success_by_author(self): - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="To Be Deleted", - article_content="Delete me", - article_published_at=datetime.now(), - ) - - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - + fake_article = create_test_article(article_id=1, article_author_id=1) + fake_account = create_test_account(account_id=1, account_role=AccountRole.AUTHOR) self.mock_article_repo.get_by_id.return_value = fake_article self.mock_account_repo.get_by_id.return_value = fake_account result = self.service.delete_article(article_id=fake_article.article_id, user_id=fake_account.account_id) @@ -343,23 +224,8 @@ def test_delete_article_success_by_author(self): assert result is True def test_delete_article_success_by_admin(self): - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="To Be Deleted", - article_content="Delete me", - article_published_at=datetime.now(), - ) - - fake_admin = Account( - account_id=99, - account_username="admin2", - account_password="password123", - account_email="admin@cyber.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - + fake_article = create_test_article(article_id=1, article_author_id=1) + fake_admin = create_test_account(account_id=99, account_role=AccountRole.ADMIN) self.mock_article_repo.get_by_id.return_value = fake_article self.mock_account_repo.get_by_id.return_value = fake_admin result = self.service.delete_article(article_id=fake_article.article_id, user_id=fake_admin.account_id) @@ -369,23 +235,8 @@ def test_delete_article_success_by_admin(self): assert result is True def test_delete_article_unauthorized_ownership(self): - fake_article = Article( - article_id=1, - article_author_id=1, - article_title="To Be Deleted", - article_content="Delete me", - article_published_at=datetime.now(), - ) - - fake_author_other = Account( - account_id=99, - account_username="other", - account_password="password123", - account_email="other@cyber.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - + fake_article = create_test_article(article_author_id=1) + fake_author_other = create_test_account(account_id=99, account_role=AccountRole.AUTHOR) self.mock_article_repo.get_by_id.return_value = fake_article self.mock_account_repo.get_by_id.return_value = fake_author_other result = self.service.delete_article(article_id=fake_article.article_id, user_id=fake_author_other.account_id) @@ -395,15 +246,7 @@ def test_delete_article_unauthorized_ownership(self): assert result == "Unauthorized : Only authors or admins can delete articles." def test_delete_article_not_found(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.AUTHOR, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_role=AccountRole.AUTHOR) self.mock_article_repo.get_by_id.return_value = None self.mock_account_repo.get_by_id.return_value = fake_account result = self.service.delete_article(article_id=999, user_id=fake_account.account_id) diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index cef5263..1426bff 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -1,20 +1,19 @@ from datetime import datetime from unittest.mock import MagicMock -from src.application.domain.account import Account, AccountRole -from src.application.domain.article import Article -from src.application.domain.comment import Comment +from src.application.domain.account import AccountRole from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.output_ports.comment_repository import CommentRepository from src.application.services.comment_service import CommentService +from tests_hexagonal.tests_services.test_domain_factories import create_test_account, create_test_article, create_test_comment class CommentServiceTestBase: def setup_method(self): - self.mock_comment_repo = MagicMock(spec=CommentRepository) - self.mock_article_repo = MagicMock(spec=ArticleRepository) - self.mock_account_repo = MagicMock(spec=AccountRepository) + self.mock_comment_repo = MagicMock(spec=CommentRepository, autospec=True) + self.mock_article_repo = MagicMock(spec=ArticleRepository, autospec=True) + self.mock_account_repo = MagicMock(spec=AccountRepository, autospec=True) self.service = CommentService( comment_repository=self.mock_comment_repo, @@ -25,25 +24,10 @@ def setup_method(self): class TestCreateComment(CommentServiceTestBase): def test_create_comment_success(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account - fake_article = Article( - article_id=1, - article_author_id=2, - article_title="My Article", - article_content="Content", - article_published_at=datetime.now(), - ) - + fake_article = create_test_article(article_id=1, article_author_id=2) self.mock_article_repo.get_by_id.return_value = fake_article result = self.service.create_comment( @@ -79,15 +63,7 @@ def test_create_comment_account_not_found(self): assert result == "Account not found." def test_create_comment_article_not_found(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account self.mock_article_repo.get_by_id.return_value = None @@ -105,24 +81,15 @@ def test_create_comment_article_not_found(self): class TestCreateReply(CommentServiceTestBase): def test_create_reply_success_root_comment(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account - parent_comment = Comment( + parent_comment = create_test_comment( comment_id=10, comment_article_id=5, comment_written_account_id=2, comment_reply_to=None, comment_content="Root comment", - comment_posted_at=datetime.now(), ) self.mock_comment_repo.get_by_id.return_value = parent_comment @@ -141,24 +108,15 @@ def test_create_reply_success_root_comment(self): assert result is saved_reply def test_create_reply_success_nested_comment(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account - parent_comment = Comment( + parent_comment = create_test_comment( comment_id=15, comment_article_id=5, comment_written_account_id=2, comment_reply_to=10, comment_content="I am a reply", - comment_posted_at=datetime.now(), ) self.mock_comment_repo.get_by_id.return_value = parent_comment result = self.service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="Replying to a reply") @@ -170,15 +128,7 @@ def test_create_reply_success_nested_comment(self): assert result is saved_reply def test_create_reply_parent_not_found(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account self.mock_comment_repo.get_by_id.return_value = None result = self.service.create_reply(parent_comment_id=999, user_id=fake_account.account_id, content="Replying to nothing") @@ -196,85 +146,48 @@ def test_get_comments_for_article_not_found(self): assert result == "Article not found." def test_get_comments_for_article_empty(self): - fake_article = Article( - article_id=1, - article_author_id=2, - article_title="My Article", - article_content="Content", - article_published_at=datetime.now(), - ) - + fake_article = create_test_article(article_id=1, article_author_id=2) self.mock_article_repo.get_by_id.return_value = fake_article self.mock_comment_repo.get_all_by_article_id.return_value = [] result = self.service.get_comments_for_article(article_id=fake_article.article_id) self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) self.mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) - assert isinstance(result, dict) assert result == {"root": []} def test_get_comments_for_article_success(self): - fake_article = Article( - article_id=1, - article_author_id=2, - article_title="My Article", - article_content="Content", - article_published_at=datetime.now(), - ) - + fake_article = create_test_article(article_id=1, article_author_id=2) self.mock_article_repo.get_by_id.return_value = fake_article - root_comment = Comment( + root_comment = create_test_comment( comment_id=10, comment_article_id=fake_article.article_id, comment_written_account_id=3, comment_reply_to=None, comment_content="First!", - comment_posted_at=datetime.now(), ) - reply = Comment( + reply = create_test_comment( comment_id=15, comment_article_id=fake_article.article_id, comment_written_account_id=4, comment_reply_to=root_comment.comment_id, comment_content="Awesome!", - comment_posted_at=datetime.now(), ) self.mock_comment_repo.get_all_by_article_id.return_value = [root_comment, reply] result = self.service.get_comments_for_article(article_id=fake_article.article_id) self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) self.mock_comment_repo.get_all_by_article_id.assert_called_once_with(fake_article.article_id) - assert isinstance(result, dict) - assert "root" in result assert result["root"] == [root_comment] - comment_id_key = root_comment.comment_id - assert comment_id_key in result - assert result[comment_id_key] == [reply] + assert result[root_comment.comment_id] == [reply] class TestDeleteComment(CommentServiceTestBase): def test_delete_comment_success_as_admin(self): - admin_account = Account( - account_id=1, - account_username="admin_user", - account_password="password123", - account_email="admin@galaxy.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - + admin_account = create_test_account(account_id=1, account_role=AccountRole.ADMIN) self.mock_account_repo.get_by_id.return_value = admin_account - comment_to_delete = Comment( - comment_id=10, - comment_article_id=5, - comment_written_account_id=2, - comment_reply_to=None, - comment_content="Bad comment", - comment_posted_at=datetime.now(), - ) - + comment_to_delete = create_test_comment(comment_id=10, comment_written_account_id=2) self.mock_comment_repo.get_by_id.return_value = comment_to_delete result = self.service.delete_comment(comment_id=comment_to_delete.comment_id, user_id=admin_account.account_id) self.mock_account_repo.get_by_id.assert_called_once_with(admin_account.account_id) @@ -283,15 +196,7 @@ def test_delete_comment_success_as_admin(self): assert result is True def test_delete_comment_unauthorized_not_admin(self): - fake_account = Account( - account_id=2, - account_username="regular_user", - account_password="password123", - account_email="user@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=2, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account result = self.service.delete_comment(comment_id=10, user_id=fake_account.account_id) self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) @@ -300,15 +205,7 @@ def test_delete_comment_unauthorized_not_admin(self): assert result == "Unauthorized : Only admins can delete comments." def test_delete_comment_not_found(self): - fake_account = Account( - account_id=1, - account_username="admin_user", - account_password="password123", - account_email="admin@galaxy.com", - account_role=AccountRole.ADMIN, - account_created_at=datetime.now(), - ) - + fake_account = create_test_account(account_id=1, account_role=AccountRole.ADMIN) self.mock_account_repo.get_by_id.return_value = fake_account self.mock_comment_repo.get_by_id.return_value = None result = self.service.delete_comment(comment_id=999, user_id=fake_account.account_id) diff --git a/tests_hexagonal/tests_services/test_domain_factories.py b/tests_hexagonal/tests_services/test_domain_factories.py new file mode 100644 index 0000000..ac6d8d8 --- /dev/null +++ b/tests_hexagonal/tests_services/test_domain_factories.py @@ -0,0 +1,68 @@ +from datetime import datetime +from src.application.domain.account import Account, AccountRole +from src.application.domain.article import Article +from src.application.domain.comment import Comment + + +def create_test_account( + account_id: int = 1, + account_username: str = "leia", + account_password: str = "password123", + account_email: str = "leia@galaxy.com", + account_role: AccountRole = AccountRole.USER, + account_created_at: datetime = None, +) -> Account: + """Factory to create a test Account entity with sensible defaults.""" + if account_created_at is None: + account_created_at = datetime.now() + + return Account( + account_id=account_id, + account_username=account_username, + account_password=account_password, + account_email=account_email, + account_role=account_role, + account_created_at=account_created_at, + ) + + +def create_test_article( + article_id: int = 1, + article_author_id: int = 1, + article_title: str = "Test Article Title", + article_content: str = "Test article content.", + article_published_at: datetime = None, +) -> Article: + """Factory to create a test Article entity with sensible defaults.""" + if article_published_at is None: + article_published_at = datetime.now() + + return Article( + article_id=article_id, + article_author_id=article_author_id, + article_title=article_title, + article_content=article_content, + article_published_at=article_published_at, + ) + + +def create_test_comment( + comment_id: int = 1, + comment_article_id: int = 1, + comment_written_account_id: int = 1, + comment_reply_to: int | None = None, + comment_content: str = "Test comment content.", + comment_posted_at: datetime = None, +) -> Comment: + """Factory to create a test Comment entity with sensible defaults.""" + if comment_posted_at is None: + comment_posted_at = datetime.now() + + return Comment( + comment_id=comment_id, + comment_article_id=comment_article_id, + comment_written_account_id=comment_written_account_id, + comment_reply_to=comment_reply_to, + comment_content=comment_content, + comment_posted_at=comment_posted_at, + ) diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py index 1921fb9..b303217 100644 --- a/tests_hexagonal/tests_services/test_login_service.py +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -1,27 +1,19 @@ -from datetime import datetime from unittest.mock import MagicMock -from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository from src.application.services.login_service import LoginService +from tests_hexagonal.tests_services.test_domain_factories import create_test_account class LoginServiceTestBase: def setup_method(self): - self.mock_repo = MagicMock(spec=AccountRepository) + self.mock_repo = MagicMock(spec=AccountRepository, autospec=True) self.service = LoginService(account_repository=self.mock_repo) class TestAuthenticateUser(LoginServiceTestBase): def test_authenticate_user_success(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now() - ) + fake_account = create_test_account() self.mock_repo.find_by_username.return_value = fake_account result = self.service.authenticate_user(username=fake_account.account_username, password=fake_account.account_password) @@ -30,14 +22,7 @@ def test_authenticate_user_success(self): assert result.account_username == "leia" def test_authenticate_user_wrong_password(self): - fake_account = Account( - account_id=1, - account_username="leia", - account_password="password123", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now() - ) + fake_account = create_test_account() self.mock_repo.find_by_username.return_value = fake_account result = self.service.authenticate_user(username=fake_account.account_username, password="bad_password") diff --git a/tests_hexagonal/tests_services/test_registration_service.py b/tests_hexagonal/tests_services/test_registration_service.py index 9ad5309..3362b71 100644 --- a/tests_hexagonal/tests_services/test_registration_service.py +++ b/tests_hexagonal/tests_services/test_registration_service.py @@ -1,14 +1,14 @@ -from datetime import datetime from unittest.mock import MagicMock from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository from src.application.services.registration_service import RegistrationService +from tests_hexagonal.tests_services.test_domain_factories import create_test_account class RegistrationServiceTestBase: def setup_method(self): - self.mock_repo = MagicMock(spec=AccountRepository) + self.mock_repo = MagicMock(spec=AccountRepository, autospec=True) self.service = RegistrationService(account_repository=self.mock_repo) @@ -32,13 +32,10 @@ def test_create_account_success(self): assert result.account_role == AccountRole.USER def test_create_account_username_taken(self): - existing_account = Account( + existing_account = create_test_account( account_id=1, account_username="leia", - account_password="existing_pass", - account_email="existing@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), + account_email="existing@galaxy.com" ) self.mock_repo.find_by_username.return_value = existing_account @@ -55,13 +52,10 @@ def test_create_account_username_taken(self): assert result == "This username is already taken." def test_create_account_email_taken(self): - existing_account = Account( + existing_account = create_test_account( account_id=2, account_username="han", - account_password="other_pass", - account_email="leia@galaxy.com", - account_role=AccountRole.USER, - account_created_at=datetime.now(), + account_email="leia@galaxy.com" ) self.mock_repo.find_by_username.return_value = None From 48f1af9631dea7e6be2607571673a6bb796b071c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 1 Apr 2026 03:38:51 +0200 Subject: [PATCH 23/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Increased=20Ruff=E2=80=99s=20line-length=20to=20130=20and=20?= =?UTF-8?q?reformatted=20the=20test=20suite=20with=20multi=E2=80=91line=20?= =?UTF-8?q?imports=20and=20calls,=20while=20also=20removing=20an=20unused?= =?UTF-8?q?=20datetime=20import=20and=20cleaning=20trailing=20whitespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- .../tests_services/test_article_service.py | 5 ++- .../tests_services/test_comment_service.py | 37 +++++++++++++++---- .../tests_services/test_domain_factories.py | 7 ++-- .../tests_services/test_login_service.py | 8 +++- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 127ccee..f7e3199 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dev = [ ] [tool.ruff] -line-length = 88 +line-length = 130 [tool.ruff.lint] select = ["E", "F", "W", "I", "B", "UP", "Q", "T"] diff --git a/tests_hexagonal/tests_services/test_article_service.py b/tests_hexagonal/tests_services/test_article_service.py index d73a25a..2b0c741 100644 --- a/tests_hexagonal/tests_services/test_article_service.py +++ b/tests_hexagonal/tests_services/test_article_service.py @@ -5,7 +5,10 @@ from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.services.article_service import ArticleService -from tests_hexagonal.tests_services.test_domain_factories import create_test_account, create_test_article +from tests_hexagonal.tests_services.test_domain_factories import ( + create_test_account, + create_test_article, +) class ArticleServiceTestBase: diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index 1426bff..16d920b 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -1,4 +1,3 @@ -from datetime import datetime from unittest.mock import MagicMock from src.application.domain.account import AccountRole @@ -6,7 +5,11 @@ from src.application.output_ports.article_repository import ArticleRepository from src.application.output_ports.comment_repository import CommentRepository from src.application.services.comment_service import CommentService -from tests_hexagonal.tests_services.test_domain_factories import create_test_account, create_test_article, create_test_comment +from tests_hexagonal.tests_services.test_domain_factories import ( + create_test_account, + create_test_article, + create_test_comment, +) class CommentServiceTestBase: @@ -93,14 +96,18 @@ def test_create_reply_success_root_comment(self): ) self.mock_comment_repo.get_by_id.return_value = parent_comment - result = self.service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="This is a reply") + + result = self.service.create_reply( + parent_comment_id=parent_comment.comment_id, + user_id=fake_account.account_id, content="This is a reply" + ) + self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) self.mock_comment_repo.get_by_id.assert_called_once_with(parent_comment.comment_id) self.mock_comment_repo.save.assert_called_once() index_args = 0 index_kwargs = 0 saved_reply = self.mock_comment_repo.save.call_args[index_args][index_kwargs] - assert saved_reply.comment_article_id == parent_comment.comment_article_id assert saved_reply.comment_written_account_id == fake_account.account_id assert saved_reply.comment_reply_to == parent_comment.comment_id @@ -119,7 +126,12 @@ def test_create_reply_success_nested_comment(self): comment_content="I am a reply", ) self.mock_comment_repo.get_by_id.return_value = parent_comment - result = self.service.create_reply(parent_comment_id=parent_comment.comment_id, user_id=fake_account.account_id, content="Replying to a reply") + + result = self.service.create_reply( + parent_comment_id=parent_comment.comment_id, + user_id=fake_account.account_id, content="Replying to a reply" + ) + self.mock_comment_repo.save.assert_called_once() index_args = 0 index_kwargs = 0 @@ -131,7 +143,13 @@ def test_create_reply_parent_not_found(self): fake_account = create_test_account(account_id=1, account_role=AccountRole.USER) self.mock_account_repo.get_by_id.return_value = fake_account self.mock_comment_repo.get_by_id.return_value = None - result = self.service.create_reply(parent_comment_id=999, user_id=fake_account.account_id, content="Replying to nothing") + + result = self.service.create_reply( + parent_comment_id=999, + user_id=fake_account.account_id, + content="Replying to nothing" + ) + self.mock_comment_repo.get_by_id.assert_called_once_with(999) self.mock_comment_repo.save.assert_not_called() assert result == "Parent comment not found." @@ -189,7 +207,12 @@ def test_delete_comment_success_as_admin(self): comment_to_delete = create_test_comment(comment_id=10, comment_written_account_id=2) self.mock_comment_repo.get_by_id.return_value = comment_to_delete - result = self.service.delete_comment(comment_id=comment_to_delete.comment_id, user_id=admin_account.account_id) + + result = self.service.delete_comment( + comment_id=comment_to_delete.comment_id, + user_id=admin_account.account_id + ) + self.mock_account_repo.get_by_id.assert_called_once_with(admin_account.account_id) self.mock_comment_repo.get_by_id.assert_called_once_with(comment_to_delete.comment_id) self.mock_comment_repo.delete.assert_called_once_with(comment_to_delete.comment_id) diff --git a/tests_hexagonal/tests_services/test_domain_factories.py b/tests_hexagonal/tests_services/test_domain_factories.py index ac6d8d8..8b88a80 100644 --- a/tests_hexagonal/tests_services/test_domain_factories.py +++ b/tests_hexagonal/tests_services/test_domain_factories.py @@ -1,4 +1,5 @@ from datetime import datetime + from src.application.domain.account import Account, AccountRole from src.application.domain.article import Article from src.application.domain.comment import Comment @@ -15,7 +16,7 @@ def create_test_account( """Factory to create a test Account entity with sensible defaults.""" if account_created_at is None: account_created_at = datetime.now() - + return Account( account_id=account_id, account_username=account_username, @@ -36,7 +37,7 @@ def create_test_article( """Factory to create a test Article entity with sensible defaults.""" if article_published_at is None: article_published_at = datetime.now() - + return Article( article_id=article_id, article_author_id=article_author_id, @@ -57,7 +58,7 @@ def create_test_comment( """Factory to create a test Comment entity with sensible defaults.""" if comment_posted_at is None: comment_posted_at = datetime.now() - + return Comment( comment_id=comment_id, comment_article_id=comment_article_id, diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py index b303217..556313f 100644 --- a/tests_hexagonal/tests_services/test_login_service.py +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -16,14 +16,18 @@ def test_authenticate_user_success(self): fake_account = create_test_account() self.mock_repo.find_by_username.return_value = fake_account - result = self.service.authenticate_user(username=fake_account.account_username, password=fake_account.account_password) + + result = self.service.authenticate_user( + username=fake_account.account_username, + password=fake_account.account_password + ) + self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) assert result is not None assert result.account_username == "leia" def test_authenticate_user_wrong_password(self): fake_account = create_test_account() - self.mock_repo.find_by_username.return_value = fake_account result = self.service.authenticate_user(username=fake_account.account_username, password="bad_password") self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) From 35dfcf353121016913aa4e45647483488ea6375e Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 1 Apr 2026 04:19:51 +0200 Subject: [PATCH 24/81] MVCS to hexagonal architecture : Added a save() call in ArticleService.update_article to ensure changes are written to the repository, updated the corresponding test to assert a single save() invocation with the modified article --- src/application/services/article_service.py | 1 + tests_hexagonal/tests_services/test_article_service.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index 0530889..e419ba6 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -122,6 +122,7 @@ def update_article(self, article_id: int, user_id: int, title: str, content: str article.article_title = title article.article_content = content + self.article_repository.save(article) return article def delete_article(self, article_id: int, user_id: int) -> bool | str: diff --git a/tests_hexagonal/tests_services/test_article_service.py b/tests_hexagonal/tests_services/test_article_service.py index 2b0c741..5d56ce6 100644 --- a/tests_hexagonal/tests_services/test_article_service.py +++ b/tests_hexagonal/tests_services/test_article_service.py @@ -164,6 +164,7 @@ def test_update_article_success(self): self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.save.assert_called_once_with(result) assert result.article_title == "New Title" assert result.article_content == "New Content" From 8a199cba30eeb193ebcaeff1441b8dfdae694083 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 1 Apr 2026 05:23:28 +0200 Subject: [PATCH 25/81] MVCS to hexagonal architecture : Reorganized the factory methods by extracting them from the service test suite, as they are not specific to the `tests_services` layer and should be treated as shared test utilities --- .../test_domain_factories.py | 0 .../tests_services/test_article_service.py | 5 ++++- .../tests_services/test_comment_service.py | 17 +++++++---------- .../tests_services/test_login_service.py | 2 +- .../tests_services/test_registration_service.py | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) rename tests_hexagonal/{tests_services => }/test_domain_factories.py (100%) diff --git a/tests_hexagonal/tests_services/test_domain_factories.py b/tests_hexagonal/test_domain_factories.py similarity index 100% rename from tests_hexagonal/tests_services/test_domain_factories.py rename to tests_hexagonal/test_domain_factories.py diff --git a/tests_hexagonal/tests_services/test_article_service.py b/tests_hexagonal/tests_services/test_article_service.py index 5d56ce6..1bf138f 100644 --- a/tests_hexagonal/tests_services/test_article_service.py +++ b/tests_hexagonal/tests_services/test_article_service.py @@ -5,7 +5,7 @@ from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.services.article_service import ArticleService -from tests_hexagonal.tests_services.test_domain_factories import ( +from tests_hexagonal.test_domain_factories import ( create_test_account, create_test_article, ) @@ -182,6 +182,7 @@ def test_update_article_unauthorized(self): ) self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) + self.mock_article_repo.save.assert_not_called() assert result == "Unauthorized : You are not the author of this article." def test_update_article_insufficient_role(self): @@ -197,6 +198,7 @@ def test_update_article_insufficient_role(self): self.mock_article_repo.get_by_id.assert_not_called() self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) + self.mock_article_repo.save.assert_not_called() assert result == "Insufficient permissions." def test_update_article_not_found(self): @@ -212,6 +214,7 @@ def test_update_article_not_found(self): ) self.mock_article_repo.get_by_id.assert_called_once_with(999) + self.mock_article_repo.save.assert_not_called() assert result == "Article not found." diff --git a/tests_hexagonal/tests_services/test_comment_service.py b/tests_hexagonal/tests_services/test_comment_service.py index 16d920b..45a152a 100644 --- a/tests_hexagonal/tests_services/test_comment_service.py +++ b/tests_hexagonal/tests_services/test_comment_service.py @@ -5,7 +5,7 @@ from src.application.output_ports.article_repository import ArticleRepository from src.application.output_ports.comment_repository import CommentRepository from src.application.services.comment_service import CommentService -from tests_hexagonal.tests_services.test_domain_factories import ( +from tests_hexagonal.test_domain_factories import ( create_test_account, create_test_article, create_test_comment, @@ -42,9 +42,8 @@ def test_create_comment_success(self): self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) self.mock_article_repo.get_by_id.assert_called_once_with(fake_article.article_id) self.mock_comment_repo.save.assert_called_once() - index_args = 0 - index_kwargs = 0 - saved_comment = self.mock_comment_repo.save.call_args[index_args][index_kwargs] + index_first_arg = 0 + saved_comment = self.mock_comment_repo.save.call_args.args[index_first_arg] assert saved_comment.comment_article_id == fake_article.article_id assert saved_comment.comment_written_account_id == fake_account.account_id assert saved_comment.comment_reply_to is None @@ -105,9 +104,8 @@ def test_create_reply_success_root_comment(self): self.mock_account_repo.get_by_id.assert_called_once_with(fake_account.account_id) self.mock_comment_repo.get_by_id.assert_called_once_with(parent_comment.comment_id) self.mock_comment_repo.save.assert_called_once() - index_args = 0 - index_kwargs = 0 - saved_reply = self.mock_comment_repo.save.call_args[index_args][index_kwargs] + index_first_arg = 0 + saved_reply = self.mock_comment_repo.save.call_args.args[index_first_arg] assert saved_reply.comment_article_id == parent_comment.comment_article_id assert saved_reply.comment_written_account_id == fake_account.account_id assert saved_reply.comment_reply_to == parent_comment.comment_id @@ -133,9 +131,8 @@ def test_create_reply_success_nested_comment(self): ) self.mock_comment_repo.save.assert_called_once() - index_args = 0 - index_kwargs = 0 - saved_reply = self.mock_comment_repo.save.call_args[index_args][index_kwargs] + index_first_arg = 0 + saved_reply = self.mock_comment_repo.save.call_args.args[index_first_arg] assert saved_reply.comment_reply_to == 10 assert result is saved_reply diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py index 556313f..20e621d 100644 --- a/tests_hexagonal/tests_services/test_login_service.py +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -2,7 +2,7 @@ from src.application.output_ports.account_repository import AccountRepository from src.application.services.login_service import LoginService -from tests_hexagonal.tests_services.test_domain_factories import create_test_account +from tests_hexagonal.test_domain_factories import create_test_account class LoginServiceTestBase: diff --git a/tests_hexagonal/tests_services/test_registration_service.py b/tests_hexagonal/tests_services/test_registration_service.py index 3362b71..6f58b02 100644 --- a/tests_hexagonal/tests_services/test_registration_service.py +++ b/tests_hexagonal/tests_services/test_registration_service.py @@ -3,7 +3,7 @@ from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository from src.application.services.registration_service import RegistrationService -from tests_hexagonal.tests_services.test_domain_factories import create_test_account +from tests_hexagonal.test_domain_factories import create_test_account class RegistrationServiceTestBase: From 19c56766b02c84092d13bed12f90b0a183b9dd9a Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 2 Apr 2026 02:39:06 +0200 Subject: [PATCH 26/81] MVCS to hexagonal architecture : Adds `pydantic[email]` as a dependency to support the upcoming introduction of DTOs in the infrastructure layer as part of the hexagonal architecture migration --- poetry.lock | 238 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 238 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index c137e87..e9a69b5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "black" version = "25.9.0" @@ -205,6 +217,43 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "flake8" version = "7.3.0" @@ -393,6 +442,21 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil", "setuptools"] +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.3.0" @@ -841,6 +905,163 @@ files = [ {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, ] +[[package]] +name = "pydantic" +version = "2.12.5" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + [[package]] name = "pyflakes" version = "3.4.0" @@ -1120,6 +1341,21 @@ files = [ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "werkzeug" version = "3.1.3" @@ -1159,4 +1395,4 @@ email = ["email-validator"] [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "c8592be2ed789497781fdca83b7bb69464381ab635294da63082345abdbf3254" +content-hash = "ba9dde610bf1b82e630431808df18f9677ac65bac933e7d073ad12580102a82e" diff --git a/pyproject.toml b/pyproject.toml index f7e3199..c06e5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "psycopg2-binary (>=2.9.11,<3.0.0)", "python-dotenv (>=1.2.1,<2.0.0)", "sqlalchemy (>=2.0.46,<3.0.0)", + "pydantic[email] (>=2.12.5,<3.0.0)", ] From 8491ecb43413769d198d43aa51e4a8510b21ae50 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 2 Apr 2026 07:44:12 +0200 Subject: [PATCH 27/81] MVCS to hexagonal architecture : Introduces a dedicated hexagonal infrastructure for account management, including configuration handling, SQLAlchemy setup, ORM registry and account model, the SQLAlchemy-based AccountRepository adapter and a Pydantic DTO for safe data transfer to the Domain. It also adds unit tests for the DTO and full integration tests for the SQLAlchemy account adapter --- src/infrastructure/config.py | 59 +++++++ .../output_adapters/dto/account_record.py | 40 +++++ .../models/sqlalchemy_account_model.py | 24 +++ .../sqlalchemy/models/sqlalchemy_registry.py | 12 ++ .../sqlalchemy/sqlalchemy_account_adapter.py | 97 +++++++++++ .../sqlalchemy/sqlalchemy_setup_database.py | 24 +++ .../dto/test_account_record.py | 116 +++++++++++++ .../test_sqlalchemy_account_adapter.py | 157 ++++++++++++++++++ 8 files changed, 529 insertions(+) create mode 100644 src/infrastructure/config.py create mode 100644 src/infrastructure/output_adapters/dto/account_record.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_registry.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_setup_database.py create mode 100644 tests_hexagonal/tests_infrastructure/dto/test_account_record.py create mode 100644 tests_hexagonal/tests_infrastructure/test_sqlalchemy_account_adapter.py diff --git a/src/infrastructure/config.py b/src/infrastructure/config.py new file mode 100644 index 0000000..5e326ea --- /dev/null +++ b/src/infrastructure/config.py @@ -0,0 +1,59 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +load_dotenv(BASE_DIR / ".env") +load_dotenv(BASE_DIR / ".env.test") + + +class InfraConfig: + """ + Configuration manager for the infrastructure layer. + + Handles loading environment variables from .env and .env.test files + to ensure total isolation from the legacy app/ configuration. + """ + + def _get_env(self, name: str) -> str: + """ + Helper method to retrieve an environment variable. + + Args: + name (str): The name of the environment variable to fetch. + + Returns: + str: The value of the environment variable. + + Raises: + RuntimeError: If the mandatory environment variable is missing. + """ + value = os.getenv(name) + if not value: + raise RuntimeError(f"Infrastructure Error : Missing environment variable '{name}'") + return value + + @property + def database_url(self) -> str: + """ + Retrieves the production database connection string. + + Returns: + str: The PostgreSQL connection URL. + """ + return self._get_env("DATABASE_URL") + + @property + def test_database_url(self) -> str: + """ + Retrieves the test database connection string. + + Returns: + str: The PostgreSQL connection URL for testing. + """ + return self._get_env("TEST_DATABASE_URL") + + +infra_config = InfraConfig() diff --git a/src/infrastructure/output_adapters/dto/account_record.py b/src/infrastructure/output_adapters/dto/account_record.py new file mode 100644 index 0000000..913006d --- /dev/null +++ b/src/infrastructure/output_adapters/dto/account_record.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from src.application.domain.account import Account, AccountRole + + +class AccountRecord(BaseModel): + """ + Pydantic DTO (Data Transfer Object) for account database records. + + This class faithfully mirrors the 'accounts' table schema and provides + validation when loading data from the persistence layer. + """ + + model_config = ConfigDict(from_attributes=True) + + account_id: int + account_username: str + account_password: str + account_email: str + account_role: str + account_created_at: datetime | None + + def to_domain(self) -> Account: + """ + Converts the database record into a domain Account entity. + + Returns: + Account: The corresponding domain entity, including the + conversion of the 'account_role' string to an AccountRole enum. + """ + return Account( + account_id=self.account_id, + account_username=self.account_username, + account_password=self.account_password, + account_email=self.account_email, + account_role=AccountRole(self.account_role), + account_created_at=self.account_created_at, + ) diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py new file mode 100644 index 0000000..e6ae2dd --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_account_model.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from sqlalchemy import TIMESTAMP, Integer, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel + + +class AccountModel(SqlAlchemyModel): + """ + SQLAlchemy ORM model for the 'accounts' table. + + This class defines the database schema for user profiles, including + authentication credentials, contact information, and roles. + """ + + __tablename__ = "accounts" + + account_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + account_username: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + account_password: Mapped[str] = mapped_column(Text, nullable=False) + account_email: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + account_role: Mapped[str] = mapped_column(Text, nullable=False) + account_created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_registry.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_registry.py new file mode 100644 index 0000000..a20714c --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_registry.py @@ -0,0 +1,12 @@ +from sqlalchemy.orm import DeclarativeBase + + +class SqlAlchemyModel(DeclarativeBase): + """ + Centralized mapper registry for the infrastructure layer. + + This class serves as the shared DeclarativeBase for all SQLAlchemy + models in the hexagonal architecture, ensuring they reside within + the same metadata space for relationships and schema creation. + """ + pass diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py new file mode 100644 index 0000000..4b097e3 --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_account_adapter.py @@ -0,0 +1,97 @@ +from sqlalchemy.orm import Session + +from src.application.domain.account import Account +from src.application.output_ports.account_repository import AccountRepository +from src.infrastructure.output_adapters.dto.account_record import AccountRecord +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel + + +class SqlAlchemyAccountAdapter(AccountRepository): + """ + SQLAlchemy-based implementation of the AccountRepository port. + + This adapter manages the persistence and retrieval of Account domain entities + using SQLAlchemy ORM and the PostgreSQL database. + """ + + def __init__(self, session: Session): + """ + Initializes the adapter with a SQLAlchemy session. + + Args: + session (Session): An active SQLAlchemy database session. + """ + self._session = session + + def _to_domain(self, model: AccountModel) -> Account: + """ + Maps a SQLAlchemy ORM model to a Domain Entity via a DTO. + + Args: + model (AccountModel): The database record to convert. + + Returns: + Account: The converted Domain Entity. + """ + record = AccountRecord.model_validate(model) + return record.to_domain() + + def find_by_username(self, username: str) -> Account | None: + """ + Retrieves a domain account by its unique username. + + Args: + username (str): The username to search for. + + Returns: + Account | None: The domain account if found, otherwise None. + """ + model = self._session.query(AccountModel).filter_by(account_username=username).first() + if model is None: + return None + return self._to_domain(model) + + def get_by_id(self, account_id: int) -> Account | None: + """ + Retrieves a domain account by its primary key. + + Args: + account_id (int): The unique identifier of the account. + + Returns: + Account | None: The domain account if found, otherwise None. + """ + model = self._session.get(AccountModel, account_id) + if model is None: + return None + return self._to_domain(model) + + def find_by_email(self, email: str) -> Account | None: + """ + Retrieves a domain account by its unique email address. + + Args: + email (str): The email address to search for. + + Returns: + Account | None: The domain account if found, otherwise None. + """ + model = self._session.query(AccountModel).filter_by(account_email=email).first() + if model is None: + return None + return self._to_domain(model) + + def save(self, account: Account) -> None: + """ + Persists a domain account entity into the database. + + Args: + account (Account): The domain entity to save. + """ + model = AccountModel() + model.account_username = account.account_username + model.account_password = account.account_password + model.account_email = account.account_email + model.account_role = account.account_role.value + self._session.add(model) + self._session.commit() diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_setup_database.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_setup_database.py new file mode 100644 index 0000000..b9f90b1 --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_setup_database.py @@ -0,0 +1,24 @@ +import os +import sys + +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker + +from src.infrastructure.config import infra_config + +""" +Infrastructure-specific database configuration and session management. + +This module initializes the SQLAlchemy engine and provides a scoped session +factory for the hexagonal architecture, ensuring isolation from the legacy +database setup. It automatically switches between production and test databases. +""" + +if os.getenv("PYTEST_CURRENT_TEST") or "pytest" in sys.modules: + db_url = infra_config.test_database_url +else: + db_url = infra_config.database_url + +sqlalchemy_engine = create_engine(db_url) +sqlalchemy_session_factory = sessionmaker(bind=sqlalchemy_engine) +sqlalchemy_db_session = scoped_session(sqlalchemy_session_factory) diff --git a/tests_hexagonal/tests_infrastructure/dto/test_account_record.py b/tests_hexagonal/tests_infrastructure/dto/test_account_record.py new file mode 100644 index 0000000..7897172 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/dto/test_account_record.py @@ -0,0 +1,116 @@ +from datetime import datetime + +from src.application.domain.account import Account, AccountRole +from src.infrastructure.output_adapters.dto.account_record import AccountRecord + + +class TestAccountRecordCreation: + def test_create_record_with_valid_data(self): + record = AccountRecord( + account_id=1, + account_username="testuser", + account_password="hashed_password", + account_email="test@example.com", + account_role="user", + account_created_at=datetime(2024, 1, 1), + ) + + assert record.account_id == 1 + assert record.account_username == "testuser" + assert record.account_password == "hashed_password" + assert record.account_email == "test@example.com" + assert record.account_role == "user" + assert record.account_created_at == datetime(2024, 1, 1) + + def test_create_record_with_null_created_at(self): + record = AccountRecord( + account_id=1, + account_username="testuser", + account_password="hashed_password", + account_email="test@example.com", + account_role="admin", + account_created_at=None, + ) + + assert record.account_created_at is None + + def test_create_record_from_object_attributes(self): + class MockAccountModel: + def __init__(self): + self.account_id = 42 + self.account_username = "orm_user" + self.account_password = "orm_password" + self.account_email = "orm@example.com" + self.account_role = "author" + self.account_created_at = datetime(2024, 6, 15) + + record = AccountRecord.model_validate(MockAccountModel()) + assert record.account_id == 42 + assert record.account_username == "orm_user" + assert record.account_role == "author" + + +class TestAccountRecordToDomain: + def test_to_domain_returns_account_instance(self): + record = AccountRecord( + account_id=1, + account_username="testuser", + account_password="hashed_password", + account_email="test@example.com", + account_role="user", + account_created_at=datetime(2024, 1, 1), + ) + + domain_account = record.to_domain() + assert isinstance(domain_account, Account) + + def test_to_domain_maps_all_fields_correctly(self): + record = AccountRecord( + account_id=5, + account_username="admin_user", + account_password="secure_hash", + account_email="admin@blog.com", + account_role="admin", + account_created_at=datetime(2024, 3, 15, 10, 30), + ) + + domain_account = record.to_domain() + + assert domain_account.account_id == 5 + assert domain_account.account_username == "admin_user" + assert domain_account.account_password == "secure_hash" + assert domain_account.account_email == "admin@blog.com" + assert domain_account.account_created_at == datetime(2024, 3, 15, 10, 30) + + def test_to_domain_converts_role_string_to_enum(self): + role_mappings = [ + ("admin", AccountRole.ADMIN), + ("author", AccountRole.AUTHOR), + ("user", AccountRole.USER), + ] + + for role_string, expected_enum in role_mappings: + record = AccountRecord( + account_id=1, + account_username="testuser", + account_password="password", + account_email="test@example.com", + account_role=role_string, + account_created_at=None, + ) + + domain_account = record.to_domain() + assert domain_account.account_role == expected_enum + + def test_to_domain_preserves_null_created_at(self): + record = AccountRecord( + account_id=1, + account_username="testuser", + account_password="password", + account_email="test@example.com", + account_role="user", + account_created_at=None, + ) + + domain_account = record.to_domain() + assert domain_account.account_created_at is None diff --git a/tests_hexagonal/tests_infrastructure/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/test_sqlalchemy_account_adapter.py new file mode 100644 index 0000000..85e15ce --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/test_sqlalchemy_account_adapter.py @@ -0,0 +1,157 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from src.application.domain.account import Account, AccountRole +from src.infrastructure.config import infra_config +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel +from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_account_adapter import SqlAlchemyAccountAdapter + + +class SqlAlchemyAccountAdapterTestBase: + """ + Base class for SqlAlchemyAccountAdapter integration tests. + Connects to the PostgreSQL test database, creates tables before + each test and truncates data after each test for inspection. + """ + + def setup_method(self): + self.engine = create_engine(infra_config.test_database_url) + SqlAlchemyModel.metadata.create_all(self.engine) + + with self.engine.connect() as connection: + tables = SqlAlchemyModel.metadata.sorted_tables + table_names = ", ".join(f'"{t.name}"' for t in tables) + if table_names: + from sqlalchemy import text + connection.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE;")) + connection.commit() + + session_factory = sessionmaker(bind=self.engine) + self.session = session_factory() + self.repository = SqlAlchemyAccountAdapter(self.session) + + def teardown_method(self): + self.session.rollback() + self.session.close() + self.engine.dispose() + + def _insert_account( + self, + username="testuser", + password="password123", + email="test@example.com", + role="user", + ) -> AccountModel: + + model = AccountModel() + model.account_username = username + model.account_password = password + model.account_email = email + model.account_role = role + self.session.add(model) + self.session.commit() + return model + + +class TestFindByUsername(SqlAlchemyAccountAdapterTestBase): + def test_find_by_username_returns_domain_account(self): + self._insert_account(username="admin_user", role="admin") + result = self.repository.find_by_username("admin_user") + assert result is not None + assert isinstance(result, Account) + assert result.account_username == "admin_user" + assert result.account_role == AccountRole.ADMIN + + def test_find_by_username_not_found_returns_none(self): + result = self.repository.find_by_username("nonexistent") + assert result is None + + def test_find_by_username_returns_correct_password(self): + self._insert_account(username="login_test", password="secret123") + result = self.repository.find_by_username("login_test") + assert result is not None + assert result.account_password == "secret123" + + +class TestFindByEmail(SqlAlchemyAccountAdapterTestBase): + def test_find_by_email_returns_domain_account(self): + self._insert_account(email="found@example.com", role="author") + result = self.repository.find_by_email("found@example.com") + assert result is not None + assert isinstance(result, Account) + assert result.account_email == "found@example.com" + assert result.account_role == AccountRole.AUTHOR + + def test_find_by_email_not_found_returns_none(self): + result = self.repository.find_by_email("ghost@nowhere.com") + assert result is None + + def test_find_by_email_returns_correct_username(self): + self._insert_account(username="email_owner", email="owner@blog.com") + result = self.repository.find_by_email("owner@blog.com") + assert result is not None + assert result.account_username == "email_owner" + + +class TestGetById(SqlAlchemyAccountAdapterTestBase): + def test_get_by_id_returns_domain_account(self): + inserted = self._insert_account(username="id_user", role="user") + result = self.repository.get_by_id(inserted.account_id) + assert result is not None + assert isinstance(result, Account) + assert result.account_username == "id_user" + assert result.account_role == AccountRole.USER + + def test_get_by_id_not_found_returns_none(self): + result = self.repository.get_by_id(99999) + assert result is None + + +class TestSave(SqlAlchemyAccountAdapterTestBase): + def test_save_persists_account_to_database(self): + account = Account( + account_id=0, + account_username="new_user", + account_password="hashed_pwd", + account_email="new@example.com", + account_role=AccountRole.USER, + account_created_at=None, + ) + + self.repository.save(account) + model = self.session.query(AccountModel).filter_by(account_username="new_user").first() + assert model is not None + assert model.account_email == "new@example.com" + assert model.account_role == "user" + + def test_save_account_is_retrievable_via_adapter(self): + account = Account( + account_id=0, + account_username="round_trip", + account_password="secure", + account_email="round@trip.com", + account_role=AccountRole.AUTHOR, + account_created_at=None, + ) + + self.repository.save(account) + result = self.repository.find_by_username("round_trip") + assert result is not None + assert result.account_email == "round@trip.com" + assert result.account_role == AccountRole.AUTHOR + + def test_save_assigns_auto_generated_id(self): + account = Account( + account_id=0, + account_username="auto_id", + account_password="pass", + account_email="auto@id.com", + account_role=AccountRole.USER, + account_created_at=None, + ) + + self.repository.save(account) + result = self.repository.find_by_username("auto_id") + assert result is not None + assert result.account_id > 0 From 5cf45d26ac939fae84a986b9d33158037ab19cec Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Thu, 2 Apr 2026 19:56:21 +0200 Subject: [PATCH 28/81] MVCS to hexagonal architecture : The infrastructure tests were restructured to reflect the hexagonal architecture --- .../{ => tests_output_adapters}/dto/test_account_record.py | 0 .../test_sqlalchemy_account_adapter.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests_hexagonal/tests_infrastructure/{ => tests_output_adapters}/dto/test_account_record.py (100%) rename tests_hexagonal/tests_infrastructure/{ => tests_output_adapters}/test_sqlalchemy_account_adapter.py (100%) diff --git a/tests_hexagonal/tests_infrastructure/dto/test_account_record.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_account_record.py similarity index 100% rename from tests_hexagonal/tests_infrastructure/dto/test_account_record.py rename to tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_account_record.py diff --git a/tests_hexagonal/tests_infrastructure/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py similarity index 100% rename from tests_hexagonal/tests_infrastructure/test_sqlalchemy_account_adapter.py rename to tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py From 0ae38f6ce11964e9f76024904ec71e7ef60719a1 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Fri, 3 Apr 2026 07:20:48 +0200 Subject: [PATCH 29/81] MVCS to hexagonal architecture : Adds the missing infrastructure for comment handling with an ORM model, a DTO for domain mapping, a repository adapter, along with integration tests and DTO tests --- .../output_adapters/dto/article_record.py | 36 +++++ .../output_adapters/dto/comment_record.py | 38 +++++ .../models/sqlalchemy_article_model.py | 23 +++ .../models/sqlalchemy_comment_model.py | 49 +++++++ .../sqlalchemy/sqlalchemy_article_adapter.py | 124 +++++++++++++++++ .../sqlalchemy/sqlalchemy_comment_adapter.py | 98 +++++++++++++ .../dto/test_article_record.py | 59 ++++++++ .../dto/test_comment_record.py | 66 +++++++++ .../sqlalchemy_test_base.py | 33 +++++ .../test_sqlalchemy_account_adapter.py | 28 +--- .../test_sqlalchemy_article_adapter.py | 131 ++++++++++++++++++ .../test_sqlalchemy_comment_adapter.py | 125 +++++++++++++++++ 12 files changed, 785 insertions(+), 25 deletions(-) create mode 100644 src/infrastructure/output_adapters/dto/article_record.py create mode 100644 src/infrastructure/output_adapters/dto/comment_record.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py create mode 100644 src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_article_record.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_comment_record.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py diff --git a/src/infrastructure/output_adapters/dto/article_record.py b/src/infrastructure/output_adapters/dto/article_record.py new file mode 100644 index 0000000..048c668 --- /dev/null +++ b/src/infrastructure/output_adapters/dto/article_record.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from src.application.domain.article import Article + + +class ArticleRecord(BaseModel): + """ + Pydantic DTO (Data Transfer Object) for article database records. + + Provides validation when loading data from the persistence layer. + """ + + model_config = ConfigDict(from_attributes=True) + + article_id: int + article_author_id: int + article_title: str + article_content: str + article_published_at: datetime + + def to_domain(self) -> Article: + """ + Converts the database record into a domain Article entity. + + Returns: + Article: The corresponding domain entity. + """ + return Article( + article_id=self.article_id, + article_author_id=self.article_author_id, + article_title=self.article_title, + article_content=self.article_content, + article_published_at=self.article_published_at, + ) diff --git a/src/infrastructure/output_adapters/dto/comment_record.py b/src/infrastructure/output_adapters/dto/comment_record.py new file mode 100644 index 0000000..2d76ced --- /dev/null +++ b/src/infrastructure/output_adapters/dto/comment_record.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from src.application.domain.comment import Comment + + +class CommentRecord(BaseModel): + """ + Pydantic DTO (Data Transfer Object) for comment database records. + + Provides validation when loading data from the persistence layer. + """ + + model_config = ConfigDict(from_attributes=True) + + comment_id: int + comment_article_id: int + comment_written_account_id: int + comment_reply_to: int | None + comment_content: str + comment_posted_at: datetime + + def to_domain(self) -> Comment: + """ + Converts the database record into a domain Comment entity. + + Returns: + Comment: The corresponding domain entity. + """ + return Comment( + comment_id=self.comment_id, + comment_article_id=self.comment_article_id, + comment_written_account_id=self.comment_written_account_id, + comment_reply_to=self.comment_reply_to, + comment_content=self.comment_content, + comment_posted_at=self.comment_posted_at, + ) diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py new file mode 100644 index 0000000..521df79 --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_article_model.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import TIMESTAMP, ForeignKey, Integer, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel + + +class ArticleModel(SqlAlchemyModel): + """ + SQLAlchemy ORM model for the 'articles' table. + """ + + __tablename__ = "articles" + + article_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + article_author_id: Mapped[int] = mapped_column( + ForeignKey("accounts.account_id", ondelete="CASCADE"), + nullable=False, + ) + article_title: Mapped[str] = mapped_column(Text, nullable=False) + article_content: Mapped[str] = mapped_column(Text, nullable=False) + article_published_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now()) diff --git a/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py new file mode 100644 index 0000000..effae4c --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/models/sqlalchemy_comment_model.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from sqlalchemy import ( + TIMESTAMP, + ForeignKey, + Integer, + Text, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel + + +class CommentModel(SqlAlchemyModel): + """ + SQLAlchemy ORM model for comments used in the new architecture. + """ + + __tablename__ = "comments" + __table_args__ = {"extend_existing": True} + + comment_id: Mapped[int] = mapped_column( + name="comment_id", type_=Integer, primary_key=True, autoincrement=True + ) + comment_article_id: Mapped[int] = mapped_column( + ForeignKey("articles.article_id", ondelete="CASCADE"), + name="comment_article_id", + type_=Integer, + nullable=False, + ) + comment_written_account_id: Mapped[int] = mapped_column( + ForeignKey("accounts.account_id", ondelete="CASCADE"), + name="comment_written_account_id", + type_=Integer, + nullable=False, + ) + comment_reply_to: Mapped[int | None] = mapped_column( + ForeignKey("comments.comment_id"), + name="comment_reply_to", + type_=Integer, + nullable=True, + ) + comment_content: Mapped[str] = mapped_column( + name="comment_content", type_=Text, nullable=False + ) + comment_posted_at: Mapped[datetime] = mapped_column( + name="comment_posted_at", type_=TIMESTAMP, server_default=func.now() + ) diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py new file mode 100644 index 0000000..373112b --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_article_adapter.py @@ -0,0 +1,124 @@ +from sqlalchemy import desc +from sqlalchemy.orm import Session + +from src.application.domain.article import Article +from src.application.output_ports.article_repository import ArticleRepository +from src.infrastructure.output_adapters.dto.article_record import ArticleRecord +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel + + +class SqlAlchemyArticleAdapter(ArticleRepository): + """ + SQLAlchemy-based implementation of the ArticleRepository port. + + This adapter manages the persistence and retrieval of Article domain entities + using SQLAlchemy ORM and the PostgreSQL database. + """ + + def __init__(self, session: Session): + """ + Initializes the adapter with a SQLAlchemy session. + + Args: + session (Session): An active SQLAlchemy database session. + """ + self._session = session + + def _to_domain(self, model: ArticleModel) -> Article: + """ + Maps a SQLAlchemy ORM model to a Domain Entity via a DTO. + + Args: + model (ArticleModel): The database record to convert. + + Returns: + Article: The converted Domain Entity. + """ + record = ArticleRecord.model_validate(model) + return record.to_domain() + + def get_all_ordered_by_date_desc(self) -> list[Article]: + """ + Retrieves all articles ordered by publication date (descending). + + Returns: + list[Article]: A list of all Article domain entities. + """ + models = ( + self._session.query(ArticleModel) + .order_by(desc(ArticleModel.article_published_at)) + .all() + ) + return [self._to_domain(model) for model in models] + + def get_by_id(self, article_id: int) -> Article | None: + """ + Retrieves a single article by its ID. + + Args: + article_id (int): The unique identifier of the article. + + Returns: + Article | None: The Article domain entity if found, None otherwise. + """ + model = self._session.get(ArticleModel, article_id) + if model is None: + return None + return self._to_domain(model) + + def save(self, article: Article) -> None: + """ + Saves a new article to the database. + + Args: + article (Article): The Article domain entity to save. + """ + model = ArticleModel() + model.article_author_id = article.article_author_id + model.article_title = article.article_title + model.article_content = article.article_content + self._session.add(model) + self._session.commit() + + def delete(self, article: Article) -> None: + """ + Deletes a given article from the database. + + Args: + article (Article): The Article domain entity to delete. + """ + model = self._session.get(ArticleModel, article.article_id) + if model: + self._session.delete(model) + self._session.commit() + + def get_paginated(self, page: int, per_page: int) -> list[Article]: + """ + Retrieves a paginated list of articles. + + Args: + page (int): The requested page number (1-indexed). + per_page (int): The number of articles to return per page. + + Returns: + list[Article]: A list of Article domain entities for the specified page. + """ + offset = (page - 1) * per_page + models = ( + self._session.query(ArticleModel) + .order_by(desc(ArticleModel.article_published_at)) + .offset(offset) + .limit(per_page) + .all() + ) + + return [self._to_domain(model) for model in models] + + def count_all(self) -> int: + """ + Retrieves the total number of articles. + + Returns: + int: The total count of articles in the database. + """ + return self._session.query(ArticleModel).count() diff --git a/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py new file mode 100644 index 0000000..6dc1bff --- /dev/null +++ b/src/infrastructure/output_adapters/sqlalchemy/sqlalchemy_comment_adapter.py @@ -0,0 +1,98 @@ +from sqlalchemy.orm import Session + +from src.application.domain.comment import Comment +from src.application.output_ports.comment_repository import CommentRepository +from src.infrastructure.output_adapters.dto.comment_record import CommentRecord +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_comment_model import CommentModel + + +class SqlAlchemyCommentAdapter(CommentRepository): + """ + SQLAlchemy-based implementation of the CommentRepository port. + + This adapter manages the persistence and retrieval of Comment domain entities + using SQLAlchemy ORM and the database. + """ + + def __init__(self, session: Session): + """ + Initializes the adapter with a SQLAlchemy session. + + Args: + session (Session): An active SQLAlchemy database session. + """ + self._session = session + + def _to_domain(self, model: CommentModel) -> Comment: + """ + Maps a SQLAlchemy ORM model to a Domain Entity via a DTO. + + Args: + model (CommentModel): The database record to convert. + + Returns: + Comment: The converted Domain Entity. + """ + record = CommentRecord.model_validate(model) + return record.to_domain() + + def save(self, comment: Comment) -> None: + """ + Saves a new comment or updates an existing one. + + Args: + comment (Comment): The Comment domain entity to save. + """ + model = None + if comment.comment_id: + model = self._session.get(CommentModel, comment.comment_id) + + if not model: + model = CommentModel() + self._session.add(model) + + model.comment_article_id = comment.comment_article_id + model.comment_written_account_id = comment.comment_written_account_id + model.comment_reply_to = comment.comment_reply_to + model.comment_content = comment.comment_content + self._session.commit() + + def get_by_id(self, comment_id: int) -> Comment | None: + """ + Retrieves a single comment by its ID. + + Args: + comment_id (int): The unique identifier of the comment. + + Returns: + Comment | None: The Comment domain entity if found, None otherwise. + """ + model = self._session.get(CommentModel, comment_id) + if model is None: + return None + return self._to_domain(model) + + def get_all_by_article_id(self, article_id: int) -> list[Comment]: + """ + Retrieves all comments associated with a specific article. + + Args: + article_id (int): The unique identifier of the article. + + Returns: + list[Comment]: A list of Comment domain entities. + """ + models = self._session.query(CommentModel).filter_by(comment_article_id=article_id).all() + return [self._to_domain(model) for model in models] + + def delete(self, comment_id: int) -> None: + """ + Deletes a comment by its ID from the repository. + + Args: + comment_id (int): The unique identifier of the comment to delete. + """ + model = self._session.get(CommentModel, comment_id) + if model: + self._session.delete(model) + self._session.commit() diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_article_record.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_article_record.py new file mode 100644 index 0000000..4ea0e38 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_article_record.py @@ -0,0 +1,59 @@ +from datetime import datetime + +from src.application.domain.article import Article +from src.infrastructure.output_adapters.dto.article_record import ArticleRecord + + +class TestArticleRecordCreation: + def test_create_record_with_valid_data(self): + record = ArticleRecord( + article_id=1, + article_author_id=10, + article_title="Test Title", + article_content="Test Content", + article_published_at=datetime(2023, 1, 1, 12, 0, 0), + ) + assert record.article_id == 1 + assert record.article_title == "Test Title" + + def test_create_record_from_object_attributes(self): + class MockArticleModel: + def __init__(self): + self.article_id = 99 + self.article_author_id = 42 + self.article_title = "ORM Title" + self.article_content = "ORM Content" + self.article_published_at = datetime(2023, 1, 1, 12, 0, 0) + + record = ArticleRecord.model_validate(MockArticleModel()) + assert record.article_id == 99 + assert record.article_title == "ORM Title" + + +class TestArticleRecordToDomain: + def test_to_domain_returns_article_instance(self): + record = ArticleRecord( + article_id=1, + article_author_id=10, + article_title="Domain Test", + article_content="Content", + article_published_at=datetime(2023, 1, 1, 12, 0, 0), + ) + domain = record.to_domain() + assert isinstance(domain, Article) + + def test_to_domain_maps_all_fields_correctly(self): + dt = datetime(2023, 1, 1, 12, 0, 0) + record = ArticleRecord( + article_id=5, + article_author_id=8, + article_title="Mapped", + article_content="Properly", + article_published_at=dt, + ) + domain = record.to_domain() + assert domain.article_id == 5 + assert domain.article_author_id == 8 + assert domain.article_title == "Mapped" + assert domain.article_content == "Properly" + assert domain.article_published_at == dt diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_comment_record.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_comment_record.py new file mode 100644 index 0000000..3ae9ef8 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/dto/test_comment_record.py @@ -0,0 +1,66 @@ +from datetime import datetime + +from src.application.domain.comment import Comment +from src.infrastructure.output_adapters.dto.comment_record import CommentRecord + + +class TestCommentRecordCreation: + def test_create_record_with_valid_data(self): + record = CommentRecord( + comment_id=1, + comment_article_id=10, + comment_written_account_id=5, + comment_reply_to=None, + comment_content="Test Content", + comment_posted_at=datetime(2023, 1, 1, 12, 0, 0), + ) + assert record.comment_id == 1 + assert record.comment_content == "Test Content" + assert record.comment_reply_to is None + + def test_create_record_from_object_attributes(self): + class MockCommentModel: + def __init__(self): + self.comment_id = 99 + self.comment_article_id = 42 + self.comment_written_account_id = 21 + self.comment_reply_to = 5 + self.comment_content = "ORM Content" + self.comment_posted_at = datetime(2023, 1, 1, 12, 0, 0) + + record = CommentRecord.model_validate(MockCommentModel()) + assert record.comment_id == 99 + assert record.comment_content == "ORM Content" + assert record.comment_reply_to == 5 + + +class TestCommentRecordToDomain: + def test_to_domain_returns_comment_instance(self): + record = CommentRecord( + comment_id=1, + comment_article_id=10, + comment_written_account_id=5, + comment_reply_to=None, + comment_content="Content", + comment_posted_at=datetime(2023, 1, 1, 12, 0, 0), + ) + domain = record.to_domain() + assert isinstance(domain, Comment) + + def test_to_domain_maps_all_fields_correctly(self): + dt = datetime(2023, 1, 1, 12, 0, 0) + record = CommentRecord( + comment_id=5, + comment_article_id=8, + comment_written_account_id=3, + comment_reply_to=1, + comment_content="Mapped", + comment_posted_at=dt, + ) + domain = record.to_domain() + assert domain.comment_id == 5 + assert domain.comment_article_id == 8 + assert domain.comment_written_account_id == 3 + assert domain.comment_reply_to == 1 + assert domain.comment_content == "Mapped" + assert domain.comment_posted_at == dt diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py new file mode 100644 index 0000000..16e7046 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py @@ -0,0 +1,33 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from src.infrastructure.config import infra_config +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel + + +class SqlAlchemyTestBase: + """ + Shared Base class for SQLAlchemy integration tests. + Connects to the PostgreSQL test database, creates tables before + each test and truncates data after each test for inspection. + """ + + def setup_method(self): + self.engine = create_engine(infra_config.test_database_url) + SqlAlchemyModel.metadata.create_all(self.engine) + + with self.engine.connect() as connection: + tables = SqlAlchemyModel.metadata.sorted_tables + table_names = ", ".join(f'"{t.name}"' for t in tables) + if table_names: + from sqlalchemy import text + connection.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE;")) + connection.commit() + + session_factory = sessionmaker(bind=self.engine) + self.session = session_factory() + + def teardown_method(self): + self.session.rollback() + self.session.close() + self.engine.dispose() diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py index 85e15ce..a5dfe6f 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py @@ -1,41 +1,19 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker from src.application.domain.account import Account, AccountRole -from src.infrastructure.config import infra_config from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel -from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_account_adapter import SqlAlchemyAccountAdapter +from tests_hexagonal.tests_infrastructure.tests_output_adapters.sqlalchemy_test_base import SqlAlchemyTestBase -class SqlAlchemyAccountAdapterTestBase: +class SqlAlchemyAccountAdapterTestBase(SqlAlchemyTestBase): """ Base class for SqlAlchemyAccountAdapter integration tests. - Connects to the PostgreSQL test database, creates tables before - each test and truncates data after each test for inspection. """ def setup_method(self): - self.engine = create_engine(infra_config.test_database_url) - SqlAlchemyModel.metadata.create_all(self.engine) - - with self.engine.connect() as connection: - tables = SqlAlchemyModel.metadata.sorted_tables - table_names = ", ".join(f'"{t.name}"' for t in tables) - if table_names: - from sqlalchemy import text - connection.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE;")) - connection.commit() - - session_factory = sessionmaker(bind=self.engine) - self.session = session_factory() + super().setup_method() self.repository = SqlAlchemyAccountAdapter(self.session) - def teardown_method(self): - self.session.rollback() - self.session.close() - self.engine.dispose() - def _insert_account( self, username="testuser", diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py new file mode 100644 index 0000000..81b5ae5 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py @@ -0,0 +1,131 @@ +from src.application.domain.article import Article +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel +from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_article_adapter import SqlAlchemyArticleAdapter +from tests_hexagonal.tests_infrastructure.tests_output_adapters.sqlalchemy_test_base import SqlAlchemyTestBase + + +class SqlAlchemyArticleAdapterTestBase(SqlAlchemyTestBase): + """ + Base class for SqlAlchemyArticleAdapter integration tests. + """ + + def setup_method(self): + super().setup_method() + self.repository = SqlAlchemyArticleAdapter(self.session) + + def _insert_account(self) -> AccountModel: + account = AccountModel() + account.account_username = "test_author" + account.account_password = "password123" + account.account_email = "author@example.com" + account.account_role = "author" + self.session.add(account) + self.session.commit() + return account + + def _insert_article( + self, + author_id: int, + title: str = "Test Title", + content: str = "Polo", + published_at=None, + ) -> ArticleModel: + + model = ArticleModel() + model.article_author_id = author_id + model.article_title = title + model.article_content = content + if published_at: + model.article_published_at = published_at + + self.session.add(model) + self.session.commit() + return model + + +class TestGetById(SqlAlchemyArticleAdapterTestBase): + def test_get_by_id_returns_article(self): + account = self._insert_account() + inserted = self._insert_article(author_id=account.account_id) + result = self.repository.get_by_id(inserted.article_id) + assert result is not None + assert isinstance(result, Article) + assert result.article_title == "Test Title" + + def test_get_by_id_returns_none_if_not_found(self): + result = self.repository.get_by_id(9999) + assert result is None + + +class TestSave(SqlAlchemyArticleAdapterTestBase): + def test_save_persists_article_to_database(self): + account = self._insert_account() + + article = Article( + article_id=0, + article_author_id=account.account_id, + article_title="Saved Article", + article_content="New Content", + article_published_at=None, + ) + + self.repository.save(article) + model = self.session.query(ArticleModel).filter_by(article_title="Saved Article").first() + assert model is not None + assert model.article_content == "New Content" + assert model.article_author_id == account.account_id + + +class TestDelete(SqlAlchemyArticleAdapterTestBase): + def test_delete_removes_article_from_database(self): + account = self._insert_account() + inserted = self._insert_article(author_id=account.account_id) + target = self.repository.get_by_id(inserted.article_id) + assert target is not None + self.repository.delete(target) + check = self.repository.get_by_id(inserted.article_id) + assert check is None + + +class TestPagination(SqlAlchemyArticleAdapterTestBase): + def test_get_paginated_returns_correct_chunk(self): + account = self._insert_account() + + for i in range(1, 4): + self._insert_article(author_id=account.account_id, title=f"Title {i}") + + page1 = self.repository.get_paginated(page=1, per_page=2) + assert len(page1) == 2 + page2 = self.repository.get_paginated(page=2, per_page=2) + assert len(page2) == 1 + + def test_count_all_returns_total(self): + account = self._insert_account() + self._insert_article(author_id=account.account_id) + self._insert_article(author_id=account.account_id) + total = self.repository.count_all() + assert total == 2 + + +class TestGetAllOrderedByDateDesc(SqlAlchemyArticleAdapterTestBase): + def test_returns_all_articles_sorted_newest_first(self): + from datetime import datetime, timedelta + + account = self._insert_account() + base_time = datetime.now() + self._insert_article(author_id=account.account_id, title="Oldest", published_at=base_time - timedelta(hours=2)) + self._insert_article(author_id=account.account_id, title="Middle", published_at=base_time - timedelta(hours=1)) + self._insert_article(author_id=account.account_id, title="Newest", published_at=base_time) + results = self.repository.get_all_ordered_by_date_desc() + assert len(results) == 3 + first_article = results[0] + second_article = results[1] + third_article = results[2] + assert first_article.article_title == "Newest" + assert second_article.article_title == "Middle" + assert third_article.article_title == "Oldest" + + def test_returns_empty_list_when_no_articles(self): + results = self.repository.get_all_ordered_by_date_desc() + assert results == [] diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py new file mode 100644 index 0000000..fd4c5c2 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py @@ -0,0 +1,125 @@ +# tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py +from src.application.domain.comment import Comment +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_comment_model import CommentModel +from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_comment_adapter import SqlAlchemyCommentAdapter +from tests_hexagonal.tests_infrastructure.tests_output_adapters.sqlalchemy_test_base import SqlAlchemyTestBase + + +class SqlAlchemyCommentAdapterTestBase(SqlAlchemyTestBase): + """ + Base class for SqlAlchemyCommentAdapter integration tests. + """ + + def setup_method(self): + super().setup_method() + self.repository = SqlAlchemyCommentAdapter(self.session) + + def _insert_account(self) -> AccountModel: + account = AccountModel() + account.account_username = "test_author" + account.account_password = "password123" + account.account_email = "author@example.com" + account.account_role = "author" + self.session.add(account) + self.session.commit() + return account + + def _insert_article( + self, + author_id: int, + title: str = "Test Title", + content: str = "Test Content", + ) -> ArticleModel: + model = ArticleModel() + model.article_author_id = author_id + model.article_title = title + model.article_content = content + self.session.add(model) + self.session.commit() + return model + + def _insert_comment( + self, + article_id: int, + author_id: int, + content: str = "Test Comment", + reply_to: int | None = None, + ) -> CommentModel: + model = CommentModel() + model.comment_article_id = article_id + model.comment_written_account_id = author_id + model.comment_reply_to = reply_to + model.comment_content = content + self.session.add(model) + self.session.commit() + return model + + +class TestGetById(SqlAlchemyCommentAdapterTestBase): + def test_get_by_id_returns_comment(self): + account = self._insert_account() + article = self._insert_article(author_id=account.account_id) + inserted = self._insert_comment(article_id=article.article_id, author_id=account.account_id) + result = self.repository.get_by_id(inserted.comment_id) + assert result is not None + assert isinstance(result, Comment) + assert result.comment_content == "Test Comment" + + def test_get_by_id_returns_none_if_not_found(self): + result = self.repository.get_by_id(9999) + assert result is None + + +class TestSave(SqlAlchemyCommentAdapterTestBase): + def test_save_persists_comment_to_database(self): + account = self._insert_account() + article = self._insert_article(author_id=account.account_id) + + comment = Comment( + comment_id=0, + comment_article_id=article.article_id, + comment_written_account_id=account.account_id, + comment_reply_to=None, + comment_content="My new comment", + comment_posted_at=None, + ) + + self.repository.save(comment) + model = self.session.query(CommentModel).filter_by(comment_content="My new comment").first() + assert model is not None + assert model.comment_content == "My new comment" + assert model.comment_article_id == article.article_id + assert model.comment_written_account_id == account.account_id + + +class TestDelete(SqlAlchemyCommentAdapterTestBase): + def test_delete_removes_comment_from_database(self): + account = self._insert_account() + article = self._insert_article(author_id=account.account_id) + inserted = self._insert_comment(article_id=article.article_id, author_id=account.account_id) + self.repository.delete(inserted.comment_id) + check = self.repository.get_by_id(inserted.comment_id) + assert check is None + + +class TestGetAllByArticleId(SqlAlchemyCommentAdapterTestBase): + def test_get_all_by_article_id_returns_correct_comments(self): + account = self._insert_account() + article1 = self._insert_article(author_id=account.account_id, title="Article 1") + article2 = self._insert_article(author_id=account.account_id, title="Article 2") + self._insert_comment(article_id=article1.article_id, author_id=account.account_id, content="Comment 1 for Art 1") + self._insert_comment(article_id=article1.article_id, author_id=account.account_id, content="Comment 2 for Art 1") + self._insert_comment(article_id=article2.article_id, author_id=account.account_id, content="Comment 1 for Art 2") + results = self.repository.get_all_by_article_id(article1.article_id) + assert len(results) == 2 + assert any(c.comment_content == "Comment 1 for Art 1" for c in results) + assert any(c.comment_content == "Comment 2 for Art 1" for c in results) + assert all(c.comment_article_id == article1.article_id for c in results) + + def test_returns_empty_list_when_no_comments(self): + account = self._insert_account() + article = self._insert_article(author_id=account.account_id) + results = self.repository.get_all_by_article_id(article.article_id) + assert results == [] From 4fa62d33b82b8296a01d1a8c995ece9b4bc526b4 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sat, 4 Apr 2026 05:24:02 +0200 Subject: [PATCH 30/81] MVCS to hexagonal architecture : Centralizes SQL test data setup by moving account, article and comment insertion helpers into `SqlAlchemyTestBase`. This reduces duplication across adapter tests, removes redundant model imports in each test file --- .../infrastructure_test_utils.py | 105 ++++++++++++++++++ .../sqlalchemy_test_base.py | 33 ------ .../test_sqlalchemy_account_adapter.py | 34 ++---- .../test_sqlalchemy_article_adapter.py | 66 ++++------- .../test_sqlalchemy_comment_adapter.py | 85 +++++--------- 5 files changed, 161 insertions(+), 162 deletions(-) create mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/infrastructure_test_utils.py delete mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/infrastructure_test_utils.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/infrastructure_test_utils.py new file mode 100644 index 0000000..5a0feb1 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/infrastructure_test_utils.py @@ -0,0 +1,105 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +from src.infrastructure.config import infra_config +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_comment_model import CommentModel +from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel + + +class SqlAlchemyTestBase: + """ + Shared Base class for SQLAlchemy integration tests. + Connects to the PostgreSQL test database, creates tables before + each test and truncates data after each test for inspection. + """ + + def setup_method(self): + self.engine = create_engine(infra_config.test_database_url) + SqlAlchemyModel.metadata.create_all(self.engine) + + with self.engine.connect() as connection: + tables = SqlAlchemyModel.metadata.sorted_tables + table_names = ", ".join(f'"{t.name}"' for t in tables) + if table_names: + connection.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE;")) + connection.commit() + + session_factory = sessionmaker(bind=self.engine) + self.session = session_factory() + + def teardown_method(self): + self.session.rollback() + self.session.close() + self.engine.dispose() + + +class AccountDataBuilder: + """Specialized builder for creating Account records in test database.""" + + def __init__(self, session: Session): + self._session = session + + def create( + self, + username="testuser", + password="password123", + email="test@example.com", + role="user", + ) -> AccountModel: + model = AccountModel() + model.account_username = username + model.account_password = password + model.account_email = email + model.account_role = role + self._session.add(model) + self._session.commit() + return model + + +class ArticleDataBuilder: + """Specialized builder for creating Article records in test database.""" + + def __init__(self, session: Session): + self._session = session + + def create( + self, + author_id: int, + title: str = "Test Title", + content: str = "Test Content", + published_at=None, + ) -> ArticleModel: + model = ArticleModel() + model.article_author_id = author_id + model.article_title = title + model.article_content = content + if published_at: + model.article_published_at = published_at + self._session.add(model) + self._session.commit() + return model + + +class CommentDataBuilder: + """Specialized builder for creating Comment records in test database.""" + + def __init__(self, session: Session): + self._session = session + + def create( + self, + article_id: int, + author_id: int, + content: str = "Test Comment", + reply_to: int | None = None, + ) -> CommentModel: + model = CommentModel() + model.comment_article_id = article_id + model.comment_written_account_id = author_id + model.comment_reply_to = reply_to + model.comment_content = content + self._session.add(model) + self._session.commit() + return model diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py deleted file mode 100644 index 16e7046..0000000 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/sqlalchemy_test_base.py +++ /dev/null @@ -1,33 +0,0 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker - -from src.infrastructure.config import infra_config -from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_registry import SqlAlchemyModel - - -class SqlAlchemyTestBase: - """ - Shared Base class for SQLAlchemy integration tests. - Connects to the PostgreSQL test database, creates tables before - each test and truncates data after each test for inspection. - """ - - def setup_method(self): - self.engine = create_engine(infra_config.test_database_url) - SqlAlchemyModel.metadata.create_all(self.engine) - - with self.engine.connect() as connection: - tables = SqlAlchemyModel.metadata.sorted_tables - table_names = ", ".join(f'"{t.name}"' for t in tables) - if table_names: - from sqlalchemy import text - connection.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE;")) - connection.commit() - - session_factory = sessionmaker(bind=self.engine) - self.session = session_factory() - - def teardown_method(self): - self.session.rollback() - self.session.close() - self.engine.dispose() diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py index a5dfe6f..1b46671 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py @@ -1,8 +1,10 @@ - from src.application.domain.account import Account, AccountRole from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_account_adapter import SqlAlchemyAccountAdapter -from tests_hexagonal.tests_infrastructure.tests_output_adapters.sqlalchemy_test_base import SqlAlchemyTestBase +from tests_hexagonal.tests_infrastructure.tests_output_adapters.infrastructure_test_utils import ( + AccountDataBuilder, + SqlAlchemyTestBase, +) class SqlAlchemyAccountAdapterTestBase(SqlAlchemyTestBase): @@ -13,28 +15,12 @@ class SqlAlchemyAccountAdapterTestBase(SqlAlchemyTestBase): def setup_method(self): super().setup_method() self.repository = SqlAlchemyAccountAdapter(self.session) - - def _insert_account( - self, - username="testuser", - password="password123", - email="test@example.com", - role="user", - ) -> AccountModel: - - model = AccountModel() - model.account_username = username - model.account_password = password - model.account_email = email - model.account_role = role - self.session.add(model) - self.session.commit() - return model + self.account_builder = AccountDataBuilder(self.session) class TestFindByUsername(SqlAlchemyAccountAdapterTestBase): def test_find_by_username_returns_domain_account(self): - self._insert_account(username="admin_user", role="admin") + self.account_builder.create(username="admin_user", role="admin") result = self.repository.find_by_username("admin_user") assert result is not None assert isinstance(result, Account) @@ -46,7 +32,7 @@ def test_find_by_username_not_found_returns_none(self): assert result is None def test_find_by_username_returns_correct_password(self): - self._insert_account(username="login_test", password="secret123") + self.account_builder.create(username="login_test", password="secret123") result = self.repository.find_by_username("login_test") assert result is not None assert result.account_password == "secret123" @@ -54,7 +40,7 @@ def test_find_by_username_returns_correct_password(self): class TestFindByEmail(SqlAlchemyAccountAdapterTestBase): def test_find_by_email_returns_domain_account(self): - self._insert_account(email="found@example.com", role="author") + self.account_builder.create(email="found@example.com", role="author") result = self.repository.find_by_email("found@example.com") assert result is not None assert isinstance(result, Account) @@ -66,7 +52,7 @@ def test_find_by_email_not_found_returns_none(self): assert result is None def test_find_by_email_returns_correct_username(self): - self._insert_account(username="email_owner", email="owner@blog.com") + self.account_builder.create(username="email_owner", email="owner@blog.com") result = self.repository.find_by_email("owner@blog.com") assert result is not None assert result.account_username == "email_owner" @@ -74,7 +60,7 @@ def test_find_by_email_returns_correct_username(self): class TestGetById(SqlAlchemyAccountAdapterTestBase): def test_get_by_id_returns_domain_account(self): - inserted = self._insert_account(username="id_user", role="user") + inserted = self.account_builder.create(username="id_user", role="user") result = self.repository.get_by_id(inserted.account_id) assert result is not None assert isinstance(result, Account) diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py index 81b5ae5..9a54746 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py @@ -1,8 +1,11 @@ from src.application.domain.article import Article -from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_article_adapter import SqlAlchemyArticleAdapter -from tests_hexagonal.tests_infrastructure.tests_output_adapters.sqlalchemy_test_base import SqlAlchemyTestBase +from tests_hexagonal.tests_infrastructure.tests_output_adapters.infrastructure_test_utils import ( + AccountDataBuilder, + ArticleDataBuilder, + SqlAlchemyTestBase, +) class SqlAlchemyArticleAdapterTestBase(SqlAlchemyTestBase): @@ -13,41 +16,14 @@ class SqlAlchemyArticleAdapterTestBase(SqlAlchemyTestBase): def setup_method(self): super().setup_method() self.repository = SqlAlchemyArticleAdapter(self.session) - - def _insert_account(self) -> AccountModel: - account = AccountModel() - account.account_username = "test_author" - account.account_password = "password123" - account.account_email = "author@example.com" - account.account_role = "author" - self.session.add(account) - self.session.commit() - return account - - def _insert_article( - self, - author_id: int, - title: str = "Test Title", - content: str = "Polo", - published_at=None, - ) -> ArticleModel: - - model = ArticleModel() - model.article_author_id = author_id - model.article_title = title - model.article_content = content - if published_at: - model.article_published_at = published_at - - self.session.add(model) - self.session.commit() - return model + self.account_builder = AccountDataBuilder(self.session) + self.article_builder = ArticleDataBuilder(self.session) class TestGetById(SqlAlchemyArticleAdapterTestBase): def test_get_by_id_returns_article(self): - account = self._insert_account() - inserted = self._insert_article(author_id=account.account_id) + account = self.account_builder.create() + inserted = self.article_builder.create(author_id=account.account_id) result = self.repository.get_by_id(inserted.article_id) assert result is not None assert isinstance(result, Article) @@ -60,7 +36,7 @@ def test_get_by_id_returns_none_if_not_found(self): class TestSave(SqlAlchemyArticleAdapterTestBase): def test_save_persists_article_to_database(self): - account = self._insert_account() + account = self.account_builder.create() article = Article( article_id=0, @@ -79,8 +55,8 @@ def test_save_persists_article_to_database(self): class TestDelete(SqlAlchemyArticleAdapterTestBase): def test_delete_removes_article_from_database(self): - account = self._insert_account() - inserted = self._insert_article(author_id=account.account_id) + account = self.account_builder.create() + inserted = self.article_builder.create(author_id=account.account_id) target = self.repository.get_by_id(inserted.article_id) assert target is not None self.repository.delete(target) @@ -90,10 +66,10 @@ def test_delete_removes_article_from_database(self): class TestPagination(SqlAlchemyArticleAdapterTestBase): def test_get_paginated_returns_correct_chunk(self): - account = self._insert_account() + account = self.account_builder.create() for i in range(1, 4): - self._insert_article(author_id=account.account_id, title=f"Title {i}") + self.article_builder.create(author_id=account.account_id, title=f"Title {i}") page1 = self.repository.get_paginated(page=1, per_page=2) assert len(page1) == 2 @@ -101,9 +77,9 @@ def test_get_paginated_returns_correct_chunk(self): assert len(page2) == 1 def test_count_all_returns_total(self): - account = self._insert_account() - self._insert_article(author_id=account.account_id) - self._insert_article(author_id=account.account_id) + account = self.account_builder.create() + self.article_builder.create(author_id=account.account_id) + self.article_builder.create(author_id=account.account_id) total = self.repository.count_all() assert total == 2 @@ -112,11 +88,11 @@ class TestGetAllOrderedByDateDesc(SqlAlchemyArticleAdapterTestBase): def test_returns_all_articles_sorted_newest_first(self): from datetime import datetime, timedelta - account = self._insert_account() + account = self.account_builder.create() base_time = datetime.now() - self._insert_article(author_id=account.account_id, title="Oldest", published_at=base_time - timedelta(hours=2)) - self._insert_article(author_id=account.account_id, title="Middle", published_at=base_time - timedelta(hours=1)) - self._insert_article(author_id=account.account_id, title="Newest", published_at=base_time) + self.article_builder.create(author_id=account.account_id, title="Oldest", published_at=base_time - timedelta(hours=2)) + self.article_builder.create(author_id=account.account_id, title="Middle", published_at=base_time - timedelta(hours=1)) + self.article_builder.create(author_id=account.account_id, title="Newest", published_at=base_time) results = self.repository.get_all_ordered_by_date_desc() assert len(results) == 3 first_article = results[0] diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py index fd4c5c2..d37007c 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py @@ -1,10 +1,12 @@ -# tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py from src.application.domain.comment import Comment -from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel -from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_comment_model import CommentModel from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_comment_adapter import SqlAlchemyCommentAdapter -from tests_hexagonal.tests_infrastructure.tests_output_adapters.sqlalchemy_test_base import SqlAlchemyTestBase +from tests_hexagonal.tests_infrastructure.tests_output_adapters.infrastructure_test_utils import ( + AccountDataBuilder, + ArticleDataBuilder, + CommentDataBuilder, + SqlAlchemyTestBase, +) class SqlAlchemyCommentAdapterTestBase(SqlAlchemyTestBase): @@ -15,53 +17,16 @@ class SqlAlchemyCommentAdapterTestBase(SqlAlchemyTestBase): def setup_method(self): super().setup_method() self.repository = SqlAlchemyCommentAdapter(self.session) - - def _insert_account(self) -> AccountModel: - account = AccountModel() - account.account_username = "test_author" - account.account_password = "password123" - account.account_email = "author@example.com" - account.account_role = "author" - self.session.add(account) - self.session.commit() - return account - - def _insert_article( - self, - author_id: int, - title: str = "Test Title", - content: str = "Test Content", - ) -> ArticleModel: - model = ArticleModel() - model.article_author_id = author_id - model.article_title = title - model.article_content = content - self.session.add(model) - self.session.commit() - return model - - def _insert_comment( - self, - article_id: int, - author_id: int, - content: str = "Test Comment", - reply_to: int | None = None, - ) -> CommentModel: - model = CommentModel() - model.comment_article_id = article_id - model.comment_written_account_id = author_id - model.comment_reply_to = reply_to - model.comment_content = content - self.session.add(model) - self.session.commit() - return model + self.account_builder = AccountDataBuilder(self.session) + self.article_builder = ArticleDataBuilder(self.session) + self.comment_builder = CommentDataBuilder(self.session) class TestGetById(SqlAlchemyCommentAdapterTestBase): def test_get_by_id_returns_comment(self): - account = self._insert_account() - article = self._insert_article(author_id=account.account_id) - inserted = self._insert_comment(article_id=article.article_id, author_id=account.account_id) + account = self.account_builder.create() + article = self.article_builder.create(author_id=account.account_id) + inserted = self.comment_builder.create(article_id=article.article_id, author_id=account.account_id) result = self.repository.get_by_id(inserted.comment_id) assert result is not None assert isinstance(result, Comment) @@ -74,8 +39,8 @@ def test_get_by_id_returns_none_if_not_found(self): class TestSave(SqlAlchemyCommentAdapterTestBase): def test_save_persists_comment_to_database(self): - account = self._insert_account() - article = self._insert_article(author_id=account.account_id) + account = self.account_builder.create() + article = self.article_builder.create(author_id=account.account_id) comment = Comment( comment_id=0, @@ -96,9 +61,9 @@ def test_save_persists_comment_to_database(self): class TestDelete(SqlAlchemyCommentAdapterTestBase): def test_delete_removes_comment_from_database(self): - account = self._insert_account() - article = self._insert_article(author_id=account.account_id) - inserted = self._insert_comment(article_id=article.article_id, author_id=account.account_id) + account = self.account_builder.create() + article = self.article_builder.create(author_id=account.account_id) + inserted = self.comment_builder.create(article_id=article.article_id, author_id=account.account_id) self.repository.delete(inserted.comment_id) check = self.repository.get_by_id(inserted.comment_id) assert check is None @@ -106,12 +71,12 @@ def test_delete_removes_comment_from_database(self): class TestGetAllByArticleId(SqlAlchemyCommentAdapterTestBase): def test_get_all_by_article_id_returns_correct_comments(self): - account = self._insert_account() - article1 = self._insert_article(author_id=account.account_id, title="Article 1") - article2 = self._insert_article(author_id=account.account_id, title="Article 2") - self._insert_comment(article_id=article1.article_id, author_id=account.account_id, content="Comment 1 for Art 1") - self._insert_comment(article_id=article1.article_id, author_id=account.account_id, content="Comment 2 for Art 1") - self._insert_comment(article_id=article2.article_id, author_id=account.account_id, content="Comment 1 for Art 2") + account = self.account_builder.create() + article1 = self.article_builder.create(author_id=account.account_id, title="Article 1") + article2 = self.article_builder.create(author_id=account.account_id, title="Article 2") + self.comment_builder.create(article_id=article1.article_id, author_id=account.account_id, content="Comment 1 for Art 1") + self.comment_builder.create(article_id=article1.article_id, author_id=account.account_id, content="Comment 2 for Art 1") + self.comment_builder.create(article_id=article2.article_id, author_id=account.account_id, content="Comment 1 for Art 2") results = self.repository.get_all_by_article_id(article1.article_id) assert len(results) == 2 assert any(c.comment_content == "Comment 1 for Art 1" for c in results) @@ -119,7 +84,7 @@ def test_get_all_by_article_id_returns_correct_comments(self): assert all(c.comment_article_id == article1.article_id for c in results) def test_returns_empty_list_when_no_comments(self): - account = self._insert_account() - article = self._insert_article(author_id=account.account_id) + account = self.account_builder.create() + article = self.article_builder.create(author_id=account.account_id) results = self.repository.get_all_by_article_id(article.article_id) assert results == [] From 24346572820b37a3df7797b3e9fea9952a9f24f1 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sat, 4 Apr 2026 05:42:45 +0200 Subject: [PATCH 31/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Renames=20adapter=20test=20classes=20with=20clear=20entity?= =?UTF-8?q?=E2=80=91based=20prefixes=20and=20a=20consistent=20`Test[Entity?= =?UTF-8?q?][Method]`=20pattern=20to=20make=20each=20test=E2=80=99s=20purp?= =?UTF-8?q?ose=20easier=20to=20read=20and=20understand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_sqlalchemy_account_adapter.py | 8 ++++---- .../test_sqlalchemy_article_adapter.py | 10 +++++----- .../test_sqlalchemy_comment_adapter.py | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py index 1b46671..4d9f150 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py @@ -18,7 +18,7 @@ def setup_method(self): self.account_builder = AccountDataBuilder(self.session) -class TestFindByUsername(SqlAlchemyAccountAdapterTestBase): +class TestAccountFindByUsername(SqlAlchemyAccountAdapterTestBase): def test_find_by_username_returns_domain_account(self): self.account_builder.create(username="admin_user", role="admin") result = self.repository.find_by_username("admin_user") @@ -38,7 +38,7 @@ def test_find_by_username_returns_correct_password(self): assert result.account_password == "secret123" -class TestFindByEmail(SqlAlchemyAccountAdapterTestBase): +class TestAccountFindByEmail(SqlAlchemyAccountAdapterTestBase): def test_find_by_email_returns_domain_account(self): self.account_builder.create(email="found@example.com", role="author") result = self.repository.find_by_email("found@example.com") @@ -58,7 +58,7 @@ def test_find_by_email_returns_correct_username(self): assert result.account_username == "email_owner" -class TestGetById(SqlAlchemyAccountAdapterTestBase): +class TestAccountGetById(SqlAlchemyAccountAdapterTestBase): def test_get_by_id_returns_domain_account(self): inserted = self.account_builder.create(username="id_user", role="user") result = self.repository.get_by_id(inserted.account_id) @@ -72,7 +72,7 @@ def test_get_by_id_not_found_returns_none(self): assert result is None -class TestSave(SqlAlchemyAccountAdapterTestBase): +class TestAccountSave(SqlAlchemyAccountAdapterTestBase): def test_save_persists_account_to_database(self): account = Account( account_id=0, diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py index 9a54746..3831f88 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py @@ -20,7 +20,7 @@ def setup_method(self): self.article_builder = ArticleDataBuilder(self.session) -class TestGetById(SqlAlchemyArticleAdapterTestBase): +class TestArticleGetById(SqlAlchemyArticleAdapterTestBase): def test_get_by_id_returns_article(self): account = self.account_builder.create() inserted = self.article_builder.create(author_id=account.account_id) @@ -34,7 +34,7 @@ def test_get_by_id_returns_none_if_not_found(self): assert result is None -class TestSave(SqlAlchemyArticleAdapterTestBase): +class TestArticleSave(SqlAlchemyArticleAdapterTestBase): def test_save_persists_article_to_database(self): account = self.account_builder.create() @@ -53,7 +53,7 @@ def test_save_persists_article_to_database(self): assert model.article_author_id == account.account_id -class TestDelete(SqlAlchemyArticleAdapterTestBase): +class TestArticleDelete(SqlAlchemyArticleAdapterTestBase): def test_delete_removes_article_from_database(self): account = self.account_builder.create() inserted = self.article_builder.create(author_id=account.account_id) @@ -64,7 +64,7 @@ def test_delete_removes_article_from_database(self): assert check is None -class TestPagination(SqlAlchemyArticleAdapterTestBase): +class TestArticlePagination(SqlAlchemyArticleAdapterTestBase): def test_get_paginated_returns_correct_chunk(self): account = self.account_builder.create() @@ -84,7 +84,7 @@ def test_count_all_returns_total(self): assert total == 2 -class TestGetAllOrderedByDateDesc(SqlAlchemyArticleAdapterTestBase): +class TestArticleGetAllOrderedByDateDesc(SqlAlchemyArticleAdapterTestBase): def test_returns_all_articles_sorted_newest_first(self): from datetime import datetime, timedelta diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py index d37007c..2961d7f 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py @@ -22,7 +22,7 @@ def setup_method(self): self.comment_builder = CommentDataBuilder(self.session) -class TestGetById(SqlAlchemyCommentAdapterTestBase): +class TestCommentGetById(SqlAlchemyCommentAdapterTestBase): def test_get_by_id_returns_comment(self): account = self.account_builder.create() article = self.article_builder.create(author_id=account.account_id) @@ -37,7 +37,7 @@ def test_get_by_id_returns_none_if_not_found(self): assert result is None -class TestSave(SqlAlchemyCommentAdapterTestBase): +class TestCommentSave(SqlAlchemyCommentAdapterTestBase): def test_save_persists_comment_to_database(self): account = self.account_builder.create() article = self.article_builder.create(author_id=account.account_id) @@ -59,7 +59,7 @@ def test_save_persists_comment_to_database(self): assert model.comment_written_account_id == account.account_id -class TestDelete(SqlAlchemyCommentAdapterTestBase): +class TestCommentDelete(SqlAlchemyCommentAdapterTestBase): def test_delete_removes_comment_from_database(self): account = self.account_builder.create() article = self.article_builder.create(author_id=account.account_id) @@ -69,7 +69,7 @@ def test_delete_removes_comment_from_database(self): assert check is None -class TestGetAllByArticleId(SqlAlchemyCommentAdapterTestBase): +class TestCommentGetAllByArticleId(SqlAlchemyCommentAdapterTestBase): def test_get_all_by_article_id_returns_correct_comments(self): account = self.account_builder.create() article1 = self.article_builder.create(author_id=account.account_id, title="Article 1") From 36612d502ca98fc5db9dd830e147cd942414a16a Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sat, 4 Apr 2026 05:57:37 +0200 Subject: [PATCH 32/81] MVCS to hexagonal architecture : Merges login and registration logic into a single `AccountService` to simplify the application layer, unifying their tests into `test_account_service.py` and removing redundant service and test files for better cohesion and maintainability --- ...stration_service.py => account_service.py} | 26 +++++++++++- src/application/services/login_service.py | 40 ------------------- ...ion_service.py => test_account_service.py} | 36 +++++++++++++++-- .../tests_services/test_login_service.py | 40 ------------------- 4 files changed, 56 insertions(+), 86 deletions(-) rename src/application/services/{registration_service.py => account_service.py} (67%) delete mode 100644 src/application/services/login_service.py rename tests_hexagonal/tests_services/{test_registration_service.py => test_account_service.py} (63%) delete mode 100644 tests_hexagonal/tests_services/test_login_service.py diff --git a/src/application/services/registration_service.py b/src/application/services/account_service.py similarity index 67% rename from src/application/services/registration_service.py rename to src/application/services/account_service.py index 9988061..fc1e5e1 100644 --- a/src/application/services/registration_service.py +++ b/src/application/services/account_service.py @@ -2,9 +2,10 @@ from src.application.output_ports.account_repository import AccountRepository -class RegistrationService: +class AccountService: """ - Service responsible for handling user registration and account creation logic. + Service responsible for handling all account-related logic, + including authentication and registration. Depends on the AccountRepository output port for data access. """ @@ -18,6 +19,27 @@ def __init__(self, account_repository: AccountRepository): """ self.account_repository = account_repository + def authenticate_user(self, username: str, password: str) -> Account | None: + """ + Validates the user's credentials by retrieving the account + from the repository and comparing the password. + + Args: + username (str): The username provided by the user. + password (str): The plaintext password provided by the user. + + Returns: + Account | None: The authenticated Account instance if + credentials match, None otherwise. + """ + account = self.account_repository.find_by_username(username) + + if account and account.account_password == password: + return account + + # TODO: Raise InvalidCredentialsException + return None + def create_account(self, username: str, password: str, email: str) -> Account | str: """ Creates a new user account with the default 'user' role if the diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py deleted file mode 100644 index 411c4b2..0000000 --- a/src/application/services/login_service.py +++ /dev/null @@ -1,40 +0,0 @@ -from src.application.domain.account import Account -from src.application.output_ports.account_repository import AccountRepository - - -class LoginService: - """ - Use case responsible for handling user authentication logic. - Depends on the AccountRepository output port for data access. - """ - - def __init__(self, account_repository: AccountRepository): - """ - Initialize the service with an AccountRepository (Dependency Injection). - - Args: - account_repository (AccountRepository): The repository port - for account data access. - """ - self.account_repository = account_repository - - def authenticate_user(self, username: str, password: str) -> Account | None: - """ - Validates the user's credentials by retrieving the account - from the repository and comparing the password. - - Args: - username (str): The username provided by the user. - password (str): The plaintext password provided by the user. - - Returns: - Account | None: The authenticated Account instance if - credentials match, None otherwise. - """ - account = self.account_repository.find_by_username(username) - - if account and account.account_password == password: - return account - - # TODO: Raise InvalidCredentialsException - return None diff --git a/tests_hexagonal/tests_services/test_registration_service.py b/tests_hexagonal/tests_services/test_account_service.py similarity index 63% rename from tests_hexagonal/tests_services/test_registration_service.py rename to tests_hexagonal/tests_services/test_account_service.py index 6f58b02..6faefe5 100644 --- a/tests_hexagonal/tests_services/test_registration_service.py +++ b/tests_hexagonal/tests_services/test_account_service.py @@ -2,17 +2,45 @@ from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository -from src.application.services.registration_service import RegistrationService +from src.application.services.account_service import AccountService from tests_hexagonal.test_domain_factories import create_test_account -class RegistrationServiceTestBase: +class AccountServiceTestBase: def setup_method(self): self.mock_repo = MagicMock(spec=AccountRepository, autospec=True) - self.service = RegistrationService(account_repository=self.mock_repo) + self.service = AccountService(account_repository=self.mock_repo) -class TestCreateAccount(RegistrationServiceTestBase): +class TestAccountAuthentication(AccountServiceTestBase): + def test_authenticate_user_success(self): + fake_account = create_test_account() + self.mock_repo.find_by_username.return_value = fake_account + + result = self.service.authenticate_user( + username=fake_account.account_username, + password=fake_account.account_password + ) + + self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + assert result is not None + assert result.account_username == "leia" + + def test_authenticate_user_wrong_password(self): + fake_account = create_test_account() + self.mock_repo.find_by_username.return_value = fake_account + result = self.service.authenticate_user(username=fake_account.account_username, password="bad_password") + self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + assert result is None + + def test_authenticate_user_non_existent(self): + self.mock_repo.find_by_username.return_value = None + result = self.service.authenticate_user(username="phantom", password="nothing") + self.mock_repo.find_by_username.assert_called_once_with("phantom") + assert result is None + + +class TestAccountCreation(AccountServiceTestBase): def test_create_account_success(self): self.mock_repo.find_by_username.return_value = None self.mock_repo.find_by_email.return_value = None diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py deleted file mode 100644 index 20e621d..0000000 --- a/tests_hexagonal/tests_services/test_login_service.py +++ /dev/null @@ -1,40 +0,0 @@ -from unittest.mock import MagicMock - -from src.application.output_ports.account_repository import AccountRepository -from src.application.services.login_service import LoginService -from tests_hexagonal.test_domain_factories import create_test_account - - -class LoginServiceTestBase: - def setup_method(self): - self.mock_repo = MagicMock(spec=AccountRepository, autospec=True) - self.service = LoginService(account_repository=self.mock_repo) - - -class TestAuthenticateUser(LoginServiceTestBase): - def test_authenticate_user_success(self): - fake_account = create_test_account() - - self.mock_repo.find_by_username.return_value = fake_account - - result = self.service.authenticate_user( - username=fake_account.account_username, - password=fake_account.account_password - ) - - self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) - assert result is not None - assert result.account_username == "leia" - - def test_authenticate_user_wrong_password(self): - fake_account = create_test_account() - self.mock_repo.find_by_username.return_value = fake_account - result = self.service.authenticate_user(username=fake_account.account_username, password="bad_password") - self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) - assert result is None - - def test_authenticate_user_non_existent(self): - self.mock_repo.find_by_username.return_value = None - result = self.service.authenticate_user(username="phantom", password="nothing") - self.mock_repo.find_by_username.assert_called_once_with("phantom") - assert result is None From 7ae9f5c094b0debbbf7d5f12b3eb25d563337cb8 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sat, 4 Apr 2026 06:14:03 +0200 Subject: [PATCH 33/81] MVCS to hexagonal architecture : Adds missing documentation across the application and removes unnecessary docstrings in the tests --- src/application/domain/account.py | 11 +++++++++++ src/application/domain/article.py | 10 ++++++++++ src/application/domain/comment.py | 11 +++++++++++ src/application/services/article_service.py | 6 +++--- src/application/services/comment_service.py | 11 +++++++++++ .../test_sqlalchemy_account_adapter.py | 4 ---- .../test_sqlalchemy_article_adapter.py | 4 ---- .../test_sqlalchemy_comment_adapter.py | 4 ---- 8 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/application/domain/account.py b/src/application/domain/account.py index 458deb6..a0472c3 100644 --- a/src/application/domain/account.py +++ b/src/application/domain/account.py @@ -34,6 +34,17 @@ def __init__( account_role: AccountRole, account_created_at: datetime, ): + """ + Initialize a user account. + + Args: + account_id (int): Unique identifier for the account. + account_username (str): Unique username used for authentication. + account_password (str): Securely hashed password string. + account_email (str): Unique email address for the user. + account_role (AccountRole): Permissions role. + account_created_at (datetime): Timestamp of account creation. + """ self.account_id = account_id self.account_username = account_username self.account_password = account_password diff --git a/src/application/domain/article.py b/src/application/domain/article.py index dcea78a..1dc8675 100644 --- a/src/application/domain/article.py +++ b/src/application/domain/article.py @@ -21,6 +21,16 @@ def __init__( article_content: str, article_published_at: datetime, ): + """ + Initialize a blog article. + + Args: + article_id (int): Unique identifier for the article. + article_author_id (int): Reference to the author's Account. + article_title (str): Title of the article. + article_content (str): Full text content of the article. + article_published_at (datetime): Timestamp of publication. + """ self.article_id = article_id self.article_author_id = article_author_id self.article_title = article_title diff --git a/src/application/domain/comment.py b/src/application/domain/comment.py index 78189b3..bc62b7c 100644 --- a/src/application/domain/comment.py +++ b/src/application/domain/comment.py @@ -23,6 +23,17 @@ def __init__( comment_content: str, comment_posted_at: datetime, ): + """ + Initialize a comment or reply. + + Args: + comment_id (int): Unique identifier for the comment. + comment_article_id (int): Reference to the associated article. + comment_written_account_id (int): Reference to the author's account. + comment_reply_to (int | None): Reference to a parent comment (for replies). + comment_content (str): Text content of the comment. + comment_posted_at (datetime): Timestamp of when the comment was posted. + """ self.comment_id = comment_id self.comment_article_id = comment_article_id self.comment_written_account_id = comment_written_account_id diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index e419ba6..90305bf 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -12,11 +12,11 @@ class ArticleService: def __init__(self, article_repository: ArticleRepository, account_repository: AccountRepository): """ - Initialize the service with repositories (Dependency Injection). + Initialize the service via Dependency Injection. Args: - article_repository (ArticleRepository): Port for article data. - account_repository (AccountRepository): Port for account data. + article_repository (ArticleRepository): Port for article data access. + account_repository (AccountRepository): Port for account data access. """ self.article_repository = article_repository self.account_repository = account_repository diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index 25b28e0..0442705 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -23,6 +23,11 @@ def __init__( ): """ Initialize the service via Dependency Injection. + + Args: + comment_repository (CommentRepository): Port for comment data access. + article_repository (ArticleRepository): Port for article data access. + account_repository (AccountRepository): Port for account data access. """ self.comment_repository = comment_repository self.article_repository = article_repository @@ -31,6 +36,12 @@ def __init__( def _get_account_if_exists(self, user_id: int) -> Account | str: """ Helper method to retrieve and validate an account. + + Args: + user_id (int): The unique identifier of the user to check. + + Returns: + Account | str: The Account domain entity if found, or an error message string. """ account = self.account_repository.get_by_id(user_id) if not account: diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py index 4d9f150..c150622 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py @@ -8,10 +8,6 @@ class SqlAlchemyAccountAdapterTestBase(SqlAlchemyTestBase): - """ - Base class for SqlAlchemyAccountAdapter integration tests. - """ - def setup_method(self): super().setup_method() self.repository = SqlAlchemyAccountAdapter(self.session) diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py index 3831f88..b0e8e31 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py @@ -9,10 +9,6 @@ class SqlAlchemyArticleAdapterTestBase(SqlAlchemyTestBase): - """ - Base class for SqlAlchemyArticleAdapter integration tests. - """ - def setup_method(self): super().setup_method() self.repository = SqlAlchemyArticleAdapter(self.session) diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py index 2961d7f..49f09d6 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py @@ -10,10 +10,6 @@ class SqlAlchemyCommentAdapterTestBase(SqlAlchemyTestBase): - """ - Base class for SqlAlchemyCommentAdapter integration tests. - """ - def setup_method(self): super().setup_method() self.repository = SqlAlchemyCommentAdapter(self.session) From 885ace478993a6f1c50526299809d0748726472e Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sun, 5 Apr 2026 06:04:59 +0200 Subject: [PATCH 34/81] MVCS to hexagonal architecture : Defines `AccountManagementPort` and updates `AccountService` to implement it --- .../input_ports/account_management.py | 41 +++++++++++++++++++ src/application/services/account_service.py | 4 +- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/application/input_ports/account_management.py diff --git a/src/application/input_ports/account_management.py b/src/application/input_ports/account_management.py new file mode 100644 index 0000000..e78f4ba --- /dev/null +++ b/src/application/input_ports/account_management.py @@ -0,0 +1,41 @@ +from abc import ABC, abstractmethod + +from src.application.domain.account import Account + + +class AccountManagementPort(ABC): + """ + Input port (interface) defining the business operations for account management. + This serves as the API for the Core, to be used by input adapters (Web, CLI, etc.). + """ + + @abstractmethod + def authenticate_user(self, username: str, password: str) -> Account | None: + """ + Validates the user's credentials. + + Args: + username (str): The username provided by the user. + password (str): The plaintext password provided by the user. + + Returns: + Account | None: The authenticated Account instance if + credentials match, None otherwise. + """ + pass + + @abstractmethod + def create_account(self, username: str, password: str, email: str) -> Account | str: + """ + Creates a new user account. + + Args: + username (str): The username for the new account. + password (str): The plaintext password for the new account. + email (str): The email address for the new account. + + Returns: + Account | str: The newly created Account domain entity, or an + error message string if creation fails. + """ + pass diff --git a/src/application/services/account_service.py b/src/application/services/account_service.py index fc1e5e1..e941359 100644 --- a/src/application/services/account_service.py +++ b/src/application/services/account_service.py @@ -1,11 +1,13 @@ from src.application.domain.account import Account, AccountRole +from src.application.input_ports.account_management import AccountManagementPort from src.application.output_ports.account_repository import AccountRepository -class AccountService: +class AccountService(AccountManagementPort): """ Service responsible for handling all account-related logic, including authentication and registration. + Implements the AccountManagementPort. Depends on the AccountRepository output port for data access. """ From 442c8a3fafb0a253f536851a79bcc7308d855298 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sun, 5 Apr 2026 06:38:27 +0200 Subject: [PATCH 35/81] MVCS to hexagonal architecture : Adds `ArticleManagementPort` as the input port for all article operations and updates `ArticleService` to implement it --- .../input_ports/article_management.py | 105 ++++++++++++++++++ src/application/services/article_service.py | 9 +- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src/application/input_ports/article_management.py diff --git a/src/application/input_ports/article_management.py b/src/application/input_ports/article_management.py new file mode 100644 index 0000000..fb7e3db --- /dev/null +++ b/src/application/input_ports/article_management.py @@ -0,0 +1,105 @@ +from abc import ABC, abstractmethod + +from src.application.domain.article import Article + + +class ArticleManagementPort(ABC): + """ + Input port (interface) defining the business operations for article management. + This serves as the API of the Core, to be used by input adapters (Web, CLI, etc.). + """ + + @abstractmethod + def create_article(self, title: str, content: str, author_id: int, author_role: str) -> Article | str: + """ + Creates a new article if the user has sufficient permissions. + + Args: + title (str): The title of the new article. + content (str): The body content of the new article. + author_id (int): The unique identifier of the user creating the article. + author_role (str): The role of the user. + + Returns: + Article | str: The newly created Article domain entity, + or an error message string if unauthorized or account not found. + """ + pass + + @abstractmethod + def get_all_ordered_by_date_desc(self) -> list[Article]: + """ + Retrieves all articles ordered by their publication date in descending order. + + Returns: + list[Article]: A list of Article domain entities. + """ + pass + + @abstractmethod + def get_by_id(self, article_id: int) -> Article | None: + """ + Retrieves a single article by its unique identifier. + + Args: + article_id (int): The unique identifier of the article. + + Returns: + Article | None: The Article domain entity if found, None otherwise. + """ + pass + + @abstractmethod + def update_article(self, article_id: int, user_id: int, title: str, content: str) -> Article | str: + """ + Updates an existing article ensuring the requester is the original author. + + Args: + article_id (int): ID of the article to update. + user_id (int): ID of the user requesting the update. + title (str): New title for the article. + content (str): New content for the article. + + Returns: + Article | str: The updated Article domain entity, + or an error message string if not found or unauthorized. + """ + pass + + @abstractmethod + def delete_article(self, article_id: int, user_id: int) -> bool | str: + """ + Deletes an article. Only the original author or an admin can delete it. + + Args: + article_id (int): ID of the article to delete. + user_id (int): ID of the user requesting the deletion. + + Returns: + bool | str: True if deletion was successful, or an error message string. + """ + pass + + @abstractmethod + def get_paginated_articles(self, page: int, per_page: int) -> list[Article]: + """ + Retrieves a paginated list of articles. + + Args: + page (int): The page number requested (1-indexed). + per_page (int): The number of items to display per page. + + Returns: + list[Article]: A list of Article domain entities for the requested page. + """ + pass + + @abstractmethod + def get_total_count(self) -> int: + """ + Retrieves the total number of articles. + + Returns: + int: The total count of all articles. + """ + pass diff --git a/src/application/services/article_service.py b/src/application/services/article_service.py index 90305bf..2fac048 100644 --- a/src/application/services/article_service.py +++ b/src/application/services/article_service.py @@ -1,13 +1,16 @@ from src.application.domain.account import Account, AccountRole from src.application.domain.article import Article +from src.application.input_ports.article_management import ArticleManagementPort from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository -class ArticleService: +class ArticleService(ArticleManagementPort): """ - Service responsible for business logic operations related to Articles. - Depends on the ArticleRepository and AccountRepository output ports. + Implements the ArticleManagementPort input port. + Handles all business logic operations related to Articles. + Depends on the ArticleRepository and AccountRepository output ports + for data persistence, injected via the constructor. """ def __init__(self, article_repository: ArticleRepository, account_repository: AccountRepository): From a2627da090210361e6974e74129849e43e2b1ee3 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sun, 5 Apr 2026 06:41:26 +0200 Subject: [PATCH 36/81] MVCS to hexagonal architecture : Adds `CommentManagementPort` as the input port for all comment operations and updates `CommentService` to implement it --- .../input_ports/comment_management.py | 77 +++++++++++++++++++ src/application/services/comment_service.py | 9 ++- 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/application/input_ports/comment_management.py diff --git a/src/application/input_ports/comment_management.py b/src/application/input_ports/comment_management.py new file mode 100644 index 0000000..61b6943 --- /dev/null +++ b/src/application/input_ports/comment_management.py @@ -0,0 +1,77 @@ +from abc import ABC, abstractmethod + +from src.application.domain.comment import Comment + + +class CommentManagementPort(ABC): + """ + Input port (interface) defining the business operations for comment management. + This serves as the API of the Core, to be used by input adapters (Web, CLI, etc.). + """ + + @abstractmethod + def create_comment(self, article_id: int, user_id: int, content: str) -> Comment | str: + """ + Creates a top-level comment on an article. + + Args: + article_id (int): ID of the article being commented on. + user_id (int): ID of the user creating the comment. + content (str): Text content of the comment. + + Returns: + Comment | str: The created Comment entity, or an error message string. + """ + pass + + @abstractmethod + def create_reply(self, parent_comment_id: int, user_id: int, content: str) -> Comment | str: + """ + Creates a reply to an existing comment. + A reply is linked to the thread's top-level comment (threading logic). + + Args: + parent_comment_id (int): The ID of the comment being replied to. + user_id (int): The identifier of the user creating the reply. + content (str): The text content of the reply. + + Returns: + Comment | str: The new Comment domain entity if successful, + or an error message string if unauthorized or parent not found. + """ + pass + + @abstractmethod + def get_comments_for_article(self, article_id: int) -> dict[str | int, list[Comment]] | str: + """ + Retrieves all comments for a specific article and structures them + into a threaded dictionary for display. + + Args: + article_id (int): ID of the article. + + Returns: + dict[str | int, list[Comment]] | str: A dictionary containing the threaded comments, + or an error message string if the article is not found. + Structure: + { + "root": [Comment1, Comment2], + comment_id_1: [Reply1, Reply2], + comment_id_2: [Reply3] + } + """ + pass + + @abstractmethod + def delete_comment(self, comment_id: int, user_id: int) -> bool | str: + """ + Deletes a comment. Only an admin can delete a comment. + + Args: + comment_id (int): ID of the comment to delete. + user_id (int): ID of the user requesting the deletion. + + Returns: + bool | str: True if deletion was successful, or an error message string. + """ + pass diff --git a/src/application/services/comment_service.py b/src/application/services/comment_service.py index 0442705..0192a55 100644 --- a/src/application/services/comment_service.py +++ b/src/application/services/comment_service.py @@ -4,15 +4,18 @@ from src.application.domain.account import Account, AccountRole from src.application.domain.comment import Comment +from src.application.input_ports.comment_management import CommentManagementPort from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.article_repository import ArticleRepository from src.application.output_ports.comment_repository import CommentRepository -class CommentService: +class CommentService(CommentManagementPort): """ - Service responsible for business logic operations related to Comments. - Depends on CommentRepository, ArticleRepository, and AccountRepository output ports. + Implements the CommentManagementPort input port. + Handles all business logic operations related to Comments. + Depends on CommentRepository, ArticleRepository, and AccountRepository output ports + for data persistence, injected via the constructor. """ def __init__( From 70022a0cbc0513d0050d82a7729796256a7eda95 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sun, 5 Apr 2026 07:17:55 +0200 Subject: [PATCH 37/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Adds=20account=20input=20DTOs=20(`LoginRequest`,=20`Registra?= =?UTF-8?q?tionRequest`)=20with=20basic=20validation=20and=20introduces=20?= =?UTF-8?q?dedicated=20tests=20to=20verify=20login=20fields,=20email=20for?= =?UTF-8?q?mat,=20and=20password=E2=80=91matching=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../input_adapters/dto/login_request.py | 13 +++++++ .../dto/registration_request.py | 31 +++++++++++++++ .../dto/test_login_request.py | 15 ++++++++ .../dto/test_registration_request.py | 38 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 src/infrastructure/input_adapters/dto/login_request.py create mode 100644 src/infrastructure/input_adapters/dto/registration_request.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_login_request.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_registration_request.py diff --git a/src/infrastructure/input_adapters/dto/login_request.py b/src/infrastructure/input_adapters/dto/login_request.py new file mode 100644 index 0000000..f1a0a69 --- /dev/null +++ b/src/infrastructure/input_adapters/dto/login_request.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + """ + Pydantic DTO (Data Transfer Object) for a login request. + + Validates the data received at the authentication endpoint before + it is passed to the AccountManagementPort. + """ + + username: str = Field(..., description="The account username.") + password: str = Field(..., description="The account password.") diff --git a/src/infrastructure/input_adapters/dto/registration_request.py b/src/infrastructure/input_adapters/dto/registration_request.py new file mode 100644 index 0000000..b0eb00e --- /dev/null +++ b/src/infrastructure/input_adapters/dto/registration_request.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, EmailStr, Field, model_validator + + +class RegistrationRequest(BaseModel): + """ + Pydantic DTO (Data Transfer Object) for an account registration request. + + Validates the data received at the registration endpoint before + it is passed to the AccountManagementPort. Enforces strict rules + on email format, password length, and password confirmation. + """ + + username: str = Field(..., description="The desired username.") + email: EmailStr = Field(..., description="A valid email address.") + password: str = Field(..., description="The account password.") + confirm_password: str = Field(..., description="Must match the password field.") + + @model_validator(mode="after") + def passwords_must_match(self) -> "RegistrationRequest": + """ + Cross-field validator that ensures 'password' and 'confirm_password' are identical. + + Returns: + RegistrationRequest: The validated model instance. + + Raises: + ValueError: If 'password' and 'confirm_password' do not match. + """ + if self.password != self.confirm_password: + raise ValueError("Passwords do not match.") + return self diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_login_request.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_login_request.py new file mode 100644 index 0000000..dbda0ab --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_login_request.py @@ -0,0 +1,15 @@ +import pytest +from pydantic import ValidationError + +from src.infrastructure.input_adapters.dto.login_request import LoginRequest + + +def test_login_request_valid(): + request = LoginRequest(username="testuser", password="password123") + assert request.username == "testuser" + assert request.password == "password123" + + +def test_login_request_missing_fields(): + with pytest.raises(ValidationError): + LoginRequest(username="testuser") diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_registration_request.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_registration_request.py new file mode 100644 index 0000000..2fbeccd --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_registration_request.py @@ -0,0 +1,38 @@ +import pytest +from pydantic import ValidationError + +from src.infrastructure.input_adapters.dto.registration_request import RegistrationRequest + + +def test_registration_request_valid(): + request = RegistrationRequest( + username="testuser", + email="test@example.com", + password="password123", + confirm_password="password123", + ) + assert request.username == "testuser" + assert str(request.email) == "test@example.com" + assert request.password == "password123" + + +def test_registration_request_invalid_email(): + with pytest.raises(ValidationError) as exc_info: + RegistrationRequest( + username="testuser", + email="not-an-email", + password="password123", + confirm_password="password123", + ) + assert "value is not a valid email address" in str(exc_info.value) + + +def test_registration_request_passwords_do_not_match(): + with pytest.raises(ValidationError) as exc_info: + RegistrationRequest( + username="testuser", + email="test@example.com", + password="password123", + confirm_password="differentpassword", + ) + assert "Passwords do not match." in str(exc_info.value) From caf2ced9d94f55c50b91d4a3c71ac6de9ac1d3cf Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Sun, 5 Apr 2026 07:22:34 +0200 Subject: [PATCH 38/81] MVCS to hexagonal architecture : Adds `AccountResponse` DTO to safely expose account data to the Web layer and introduces tests ensuring correct mapping while guaranteeing that password fields are never included in the output --- .../input_adapters/dto/account_response.py | 41 +++++++++++++++++++ .../dto/test_account_response.py | 31 ++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/infrastructure/input_adapters/dto/account_response.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py diff --git a/src/infrastructure/input_adapters/dto/account_response.py b/src/infrastructure/input_adapters/dto/account_response.py new file mode 100644 index 0000000..821439e --- /dev/null +++ b/src/infrastructure/input_adapters/dto/account_response.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from src.application.domain.account import Account + + +class AccountResponse(BaseModel): + """ + Pydantic DTO (Data Transfer Object) for account Web responses. + + This class securely exposes account information to the client, + intentionally omitting the password field. + """ + + model_config = ConfigDict(from_attributes=True) + + account_id: int + account_username: str + account_email: str + account_role: str + account_created_at: datetime | None + + @classmethod + def from_domain(cls, account: Account) -> "AccountResponse": + """ + Maps a domain Account entity to a secure AccountResponse DTO. + + Args: + account (Account): The domain entity. + + Returns: + AccountResponse: The safe representation for Web output. + """ + return cls( + account_id=account.account_id, + account_username=account.account_username, + account_email=account.account_email, + account_role=account.account_role.value if account.account_role else "user", + account_created_at=account.account_created_at, + ) diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py new file mode 100644 index 0000000..319abfb --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from src.application.domain.account import Account, AccountRole +from src.infrastructure.input_adapters.dto.account_response import AccountResponse + + +def test_account_response_from_domain_excludes_password(): + """ + Test that mapping from an Account domain entity correctly populates + all public fields and completely excludes the password. + """ + domain_account = Account( + account_id=1, + account_username="testuser", + account_password="secret_password_123", + account_email="test@example.com", + account_role=AccountRole.USER, + account_created_at=datetime(2023, 1, 1, 12, 0, 0), + ) + + response_dto = AccountResponse.from_domain(domain_account) + assert response_dto.account_id == 1 + assert response_dto.account_username == "testuser" + assert response_dto.account_email == "test@example.com" + assert response_dto.account_role == "user" + assert response_dto.account_created_at == datetime(2023, 1, 1, 12, 0, 0) + assert not hasattr(response_dto, "account_password") + assert not hasattr(response_dto, "password") + response_dict = response_dto.model_dump() + assert "account_password" not in response_dict + assert "password" not in response_dict From 0e21dfa160ba0ba075354c05c5e7f0162eed1f2c Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Mon, 6 Apr 2026 15:10:57 +0200 Subject: [PATCH 39/81] MVCS to hexagonal architecture : Splits account management into dedicated login and registration components by introducing separate ports and services, updating the related DTOs, reorganizing the test suite and removing the old consolidated implementation --- .../input_ports/login_management.py | 24 ++++++++ ...nagement.py => registration_management.py} | 20 +------ src/application/services/login_service.py | 40 +++++++++++++ ...unt_service.py => registration_service.py} | 31 ++-------- .../input_adapters/dto/login_request.py | 2 +- .../dto/registration_request.py | 2 +- .../tests_services/test_login_service.py | 60 +++++++++++++++++++ ...ervice.py => test_registration_service.py} | 40 ++----------- 8 files changed, 137 insertions(+), 82 deletions(-) create mode 100644 src/application/input_ports/login_management.py rename src/application/input_ports/{account_management.py => registration_management.py} (53%) create mode 100644 src/application/services/login_service.py rename src/application/services/{account_service.py => registration_service.py} (61%) create mode 100644 tests_hexagonal/tests_services/test_login_service.py rename tests_hexagonal/tests_services/{test_account_service.py => test_registration_service.py} (60%) diff --git a/src/application/input_ports/login_management.py b/src/application/input_ports/login_management.py new file mode 100644 index 0000000..241a0ab --- /dev/null +++ b/src/application/input_ports/login_management.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod + +from src.application.domain.account import Account + + +class LoginManagementPort(ABC): + """ + Input port (interface) defining the business operations for user authentication. + """ + + @abstractmethod + def authenticate_user(self, username: str, password: str) -> Account | None: + """ + Validates the user's credentials. + + Args: + username (str): The username provided by the user. + password (str): The plaintext password provided by the user. + + Returns: + Account | None: The authenticated Account instance if + credentials match, None otherwise. + """ + pass diff --git a/src/application/input_ports/account_management.py b/src/application/input_ports/registration_management.py similarity index 53% rename from src/application/input_ports/account_management.py rename to src/application/input_ports/registration_management.py index e78f4ba..ba8db90 100644 --- a/src/application/input_ports/account_management.py +++ b/src/application/input_ports/registration_management.py @@ -3,27 +3,11 @@ from src.application.domain.account import Account -class AccountManagementPort(ABC): +class RegistrationManagementPort(ABC): """ - Input port (interface) defining the business operations for account management. - This serves as the API for the Core, to be used by input adapters (Web, CLI, etc.). + Input port (interface) defining the business operations for user registration. """ - @abstractmethod - def authenticate_user(self, username: str, password: str) -> Account | None: - """ - Validates the user's credentials. - - Args: - username (str): The username provided by the user. - password (str): The plaintext password provided by the user. - - Returns: - Account | None: The authenticated Account instance if - credentials match, None otherwise. - """ - pass - @abstractmethod def create_account(self, username: str, password: str, email: str) -> Account | str: """ diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py new file mode 100644 index 0000000..1b6d1fc --- /dev/null +++ b/src/application/services/login_service.py @@ -0,0 +1,40 @@ +from src.application.domain.account import Account +from src.application.input_ports.login_management import LoginManagementPort +from src.application.output_ports.account_repository import AccountRepository + + +class LoginService(LoginManagementPort): + """ + Service responsible for handling user authentication. + Implements the LoginManagementPort. + """ + + def __init__(self, account_repository: AccountRepository): + """ + Initialize the service with an AccountRepository (Dependency Injection). + + Args: + account_repository (AccountRepository): The repository port + for account data access. + """ + self.account_repository = account_repository + + def authenticate_user(self, username: str, password: str) -> Account | None: + """ + Validates the user's credentials. + + Args: + username (str): The username provided by the user. + password (str): The plaintext password provided by the user. + + Returns: + Account | None: The authenticated Account instance if + credentials match, None otherwise. + """ + account = self.account_repository.find_by_username(username) + + if account and account.account_password == password: + return account + + # TODO: Raise InvalidCredentialsException + return None diff --git a/src/application/services/account_service.py b/src/application/services/registration_service.py similarity index 61% rename from src/application/services/account_service.py rename to src/application/services/registration_service.py index e941359..448622a 100644 --- a/src/application/services/account_service.py +++ b/src/application/services/registration_service.py @@ -1,14 +1,12 @@ from src.application.domain.account import Account, AccountRole -from src.application.input_ports.account_management import AccountManagementPort +from src.application.input_ports.registration_management import RegistrationManagementPort from src.application.output_ports.account_repository import AccountRepository -class AccountService(AccountManagementPort): +class RegistrationService(RegistrationManagementPort): """ - Service responsible for handling all account-related logic, - including authentication and registration. - Implements the AccountManagementPort. - Depends on the AccountRepository output port for data access. + Service responsible for handling user registration. + Implements the RegistrationManagementPort. """ def __init__(self, account_repository: AccountRepository): @@ -21,27 +19,6 @@ def __init__(self, account_repository: AccountRepository): """ self.account_repository = account_repository - def authenticate_user(self, username: str, password: str) -> Account | None: - """ - Validates the user's credentials by retrieving the account - from the repository and comparing the password. - - Args: - username (str): The username provided by the user. - password (str): The plaintext password provided by the user. - - Returns: - Account | None: The authenticated Account instance if - credentials match, None otherwise. - """ - account = self.account_repository.find_by_username(username) - - if account and account.account_password == password: - return account - - # TODO: Raise InvalidCredentialsException - return None - def create_account(self, username: str, password: str, email: str) -> Account | str: """ Creates a new user account with the default 'user' role if the diff --git a/src/infrastructure/input_adapters/dto/login_request.py b/src/infrastructure/input_adapters/dto/login_request.py index f1a0a69..f519c9c 100644 --- a/src/infrastructure/input_adapters/dto/login_request.py +++ b/src/infrastructure/input_adapters/dto/login_request.py @@ -6,7 +6,7 @@ class LoginRequest(BaseModel): Pydantic DTO (Data Transfer Object) for a login request. Validates the data received at the authentication endpoint before - it is passed to the AccountManagementPort. + it is passed to the LoginManagementPort. """ username: str = Field(..., description="The account username.") diff --git a/src/infrastructure/input_adapters/dto/registration_request.py b/src/infrastructure/input_adapters/dto/registration_request.py index b0eb00e..014bbe4 100644 --- a/src/infrastructure/input_adapters/dto/registration_request.py +++ b/src/infrastructure/input_adapters/dto/registration_request.py @@ -6,7 +6,7 @@ class RegistrationRequest(BaseModel): Pydantic DTO (Data Transfer Object) for an account registration request. Validates the data received at the registration endpoint before - it is passed to the AccountManagementPort. Enforces strict rules + it is passed to the RegistrationManagementPort. Enforces strict rules on email format, password length, and password confirmation. """ diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py new file mode 100644 index 0000000..0a4ea24 --- /dev/null +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock + +from src.application.output_ports.account_repository import AccountRepository +from src.application.services.login_service import LoginService +from tests_hexagonal.test_domain_factories import create_test_account + + +class TestLoginService: + """ + Tests for the LoginService to ensure authentication logic is correct. + """ + + def setup_method(self): + """ + Setup the test environment by mocking the AccountRepository. + """ + self.mock_repo = MagicMock(spec=AccountRepository, autospec=True) + self.service = LoginService(account_repository=self.mock_repo) + + def test_authenticate_user_success(self): + """ + Ensures that correct credentials return the corresponding account. + """ + fake_account = create_test_account() + self.mock_repo.find_by_username.return_value = fake_account + + result = self.service.authenticate_user( + username=fake_account.account_username, + password=fake_account.account_password + ) + + self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + assert result is not None + assert result.account_username == "leia" + + def test_authenticate_user_wrong_password(self): + """ + Ensures that incorrect password returns None. + """ + fake_account = create_test_account() + self.mock_repo.find_by_username.return_value = fake_account + + result = self.service.authenticate_user( + username=fake_account.account_username, + password="bad_password" + ) + + self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + assert result is None + + def test_authenticate_user_non_existent(self): + """ + Ensures that non-existent username returns None. + """ + self.mock_repo.find_by_username.return_value = None + + result = self.service.authenticate_user(username="phantom", password="nothing") + + self.mock_repo.find_by_username.assert_called_once_with("phantom") + assert result is None diff --git a/tests_hexagonal/tests_services/test_account_service.py b/tests_hexagonal/tests_services/test_registration_service.py similarity index 60% rename from tests_hexagonal/tests_services/test_account_service.py rename to tests_hexagonal/tests_services/test_registration_service.py index 6faefe5..5792d9a 100644 --- a/tests_hexagonal/tests_services/test_account_service.py +++ b/tests_hexagonal/tests_services/test_registration_service.py @@ -2,45 +2,15 @@ from src.application.domain.account import Account, AccountRole from src.application.output_ports.account_repository import AccountRepository -from src.application.services.account_service import AccountService +from src.application.services.registration_service import RegistrationService from tests_hexagonal.test_domain_factories import create_test_account -class AccountServiceTestBase: +class TestRegistrationService: def setup_method(self): self.mock_repo = MagicMock(spec=AccountRepository, autospec=True) - self.service = AccountService(account_repository=self.mock_repo) + self.service = RegistrationService(account_repository=self.mock_repo) - -class TestAccountAuthentication(AccountServiceTestBase): - def test_authenticate_user_success(self): - fake_account = create_test_account() - self.mock_repo.find_by_username.return_value = fake_account - - result = self.service.authenticate_user( - username=fake_account.account_username, - password=fake_account.account_password - ) - - self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) - assert result is not None - assert result.account_username == "leia" - - def test_authenticate_user_wrong_password(self): - fake_account = create_test_account() - self.mock_repo.find_by_username.return_value = fake_account - result = self.service.authenticate_user(username=fake_account.account_username, password="bad_password") - self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) - assert result is None - - def test_authenticate_user_non_existent(self): - self.mock_repo.find_by_username.return_value = None - result = self.service.authenticate_user(username="phantom", password="nothing") - self.mock_repo.find_by_username.assert_called_once_with("phantom") - assert result is None - - -class TestAccountCreation(AccountServiceTestBase): def test_create_account_success(self): self.mock_repo.find_by_username.return_value = None self.mock_repo.find_by_email.return_value = None @@ -74,7 +44,7 @@ def test_create_account_username_taken(self): email="new@galaxy.com" ) - self.mock_repo.find_by_username.assert_called_once_with(existing_account.account_username) + self.mock_repo.find_by_username.assert_called_once_with("leia") self.mock_repo.find_by_email.assert_not_called() self.mock_repo.save.assert_not_called() assert result == "This username is already taken." @@ -96,6 +66,6 @@ def test_create_account_email_taken(self): ) self.mock_repo.find_by_username.assert_called_once_with("new_user") - self.mock_repo.find_by_email.assert_called_once_with(existing_account.account_email) + self.mock_repo.find_by_email.assert_called_once_with("leia@galaxy.com") self.mock_repo.save.assert_not_called() assert result == "This email is already taken." From 640be560864ffec21969876403f762899cd70585 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Mon, 6 Apr 2026 17:18:34 +0200 Subject: [PATCH 40/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Implements=20hexagonal=20account=20session=20management=20wi?= =?UTF-8?q?th=20unit=20test=20coverage=20by=20adding=20session=20storage?= =?UTF-8?q?=20ports,=20an=20`AccountSessionService`=20and=20a=20`FlaskSess?= =?UTF-8?q?ionAdapter`,=20enabling=20session=20persistence=20after=20login?= =?UTF-8?q?=20by=20storing=20and=20retrieving=20the=20user=E2=80=99s=20ses?= =?UTF-8?q?sion=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../input_ports/account_session_management.py | 39 ++++++++++++ .../account_session_repository.py | 43 +++++++++++++ .../services/account_session_service.py | 63 +++++++++++++++++++ src/application/services/login_service.py | 15 +++-- .../session/flask_session_adapter.py | 40 ++++++++++++ .../test_account_session_service.py | 41 ++++++++++++ .../tests_services/test_login_service.py | 28 +++------ 7 files changed, 246 insertions(+), 23 deletions(-) create mode 100644 src/application/input_ports/account_session_management.py create mode 100644 src/application/output_ports/account_session_repository.py create mode 100644 src/application/services/account_session_service.py create mode 100644 src/infrastructure/output_adapters/session/flask_session_adapter.py create mode 100644 tests_hexagonal/tests_services/test_account_session_service.py diff --git a/src/application/input_ports/account_session_management.py b/src/application/input_ports/account_session_management.py new file mode 100644 index 0000000..7c0c7a4 --- /dev/null +++ b/src/application/input_ports/account_session_management.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod + +from src.application.domain.account import Account + + +class AccountSessionManagement(ABC): + """ + Interface for handling account session lifecycle. + + This port is used by the application to manage or verify the + current user's presence without relying on a specific framework. + """ + + @abstractmethod + def start_session(self, account: Account) -> None: + """ + Starts a managed session for the given account. + + Args: + account (Account): The domain entity to associate with the current session. + """ + pass + + @abstractmethod + def get_current_account(self) -> Account | None: + """ + Retrieves the authenticated domain Account for the current session. + + Returns: + Account | None: The domain account if a session is active, otherwise None. + """ + pass + + @abstractmethod + def terminate_session(self) -> None: + """ + Clears the current session, logging the user out. + """ + pass diff --git a/src/application/output_ports/account_session_repository.py b/src/application/output_ports/account_session_repository.py new file mode 100644 index 0000000..2c52585 --- /dev/null +++ b/src/application/output_ports/account_session_repository.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod + + +class AccountSessionRepository(ABC): + """ + Interface for persistence of session data. + + This encapsulates how session information is physically stored, + allowing the application to remain decoupled from infrastructure details. + By specifying native types (str, int, etc.), it ensures that only + serializable primitives cross the boundary. + """ + + @abstractmethod + def set(self, key: str, value: str | int | float | bool | dict | list) -> None: + """ + Stores a primitive value in the current session. + + Args: + key (str): The string identifier for the session variable. + value (str | int | float | bool | dict | list): The primitive data to store. + """ + pass + + @abstractmethod + def get(self, key: str) -> str | int | float | bool | dict | list | None: + """ + Retrieves a primitive value from the current session. + + Args: + key (str): The string identifier for the session variable. + + Returns: + str | int | float | bool | dict | list | None: The stored data if found, otherwise None. + """ + pass + + @abstractmethod + def clear(self) -> None: + """ + Wipes all current session data from storage. + """ + pass diff --git a/src/application/services/account_session_service.py b/src/application/services/account_session_service.py new file mode 100644 index 0000000..2871da4 --- /dev/null +++ b/src/application/services/account_session_service.py @@ -0,0 +1,63 @@ +from src.application.domain.account import Account +from src.application.input_ports.account_session_management import AccountSessionManagement +from src.application.output_ports.account_repository import AccountRepository +from src.application.output_ports.account_session_repository import AccountSessionRepository + + +class AccountSessionService(AccountSessionManagement): + """ + Service providing session-related business logic. + + Decouples the system's identity management from a physical storage implementation. + This service is the only component that knows the specific storage keys used + for maintaining a session. + """ + + USER_ID_KEY = "user_id" + USERNAME_KEY = "username" + ROLE_KEY = "role" + + def __init__( + self, + session_repository: AccountSessionRepository, + account_repository: AccountRepository + ): + """ + Initializes the service with both session storage and account data access. + + Args: + session_repository (AccountSessionRepository): The storage-level port. + account_repository (AccountRepository): The repository for retrieving account entities. + """ + self._session_repository = session_repository + self._account_repository = account_repository + + def start_session(self, account: Account) -> None: + """ + Populates the session storage with identification data extracted from the account. + + Args: + account (Account): The domain entity being logged in. + """ + self._session_repository.set(self.USER_ID_KEY, account.account_id) + self._session_repository.set(self.USERNAME_KEY, account.account_username) + self._session_repository.set(self.ROLE_KEY, account.account_role.value) + + def get_current_account(self) -> Account | None: + """ + Reconstructs the full domain Account for the presently active session. + + Returns: + Account | None: The domain entity if its ID is found in session, otherwise None. + """ + account_id = self._session_repository.get(self.USER_ID_KEY) + if not account_id: + return None + + return self._account_repository.get_by_id(int(account_id)) + + def terminate_session(self) -> None: + """ + Wipes the identification data from the session storage, effectively logging out. + """ + self._session_repository.clear() diff --git a/src/application/services/login_service.py b/src/application/services/login_service.py index 1b6d1fc..a15e0ce 100644 --- a/src/application/services/login_service.py +++ b/src/application/services/login_service.py @@ -1,4 +1,5 @@ from src.application.domain.account import Account +from src.application.input_ports.account_session_management import AccountSessionManagement from src.application.input_ports.login_management import LoginManagementPort from src.application.output_ports.account_repository import AccountRepository @@ -9,15 +10,20 @@ class LoginService(LoginManagementPort): Implements the LoginManagementPort. """ - def __init__(self, account_repository: AccountRepository): + def __init__( + self, + account_repository: AccountRepository, + session_service: AccountSessionManagement + ): """ - Initialize the service with an AccountRepository (Dependency Injection). + Initializes the service with both account repository and session management. Args: - account_repository (AccountRepository): The repository port - for account data access. + account_repository (AccountRepository): The repository for account data access. + session_service (AccountSessionManagement): The service for session state coordination. """ self.account_repository = account_repository + self.session_service = session_service def authenticate_user(self, username: str, password: str) -> Account | None: """ @@ -34,6 +40,7 @@ def authenticate_user(self, username: str, password: str) -> Account | None: account = self.account_repository.find_by_username(username) if account and account.account_password == password: + self.session_service.start_session(account) return account # TODO: Raise InvalidCredentialsException diff --git a/src/infrastructure/output_adapters/session/flask_session_adapter.py b/src/infrastructure/output_adapters/session/flask_session_adapter.py new file mode 100644 index 0000000..5f04baa --- /dev/null +++ b/src/infrastructure/output_adapters/session/flask_session_adapter.py @@ -0,0 +1,40 @@ +from flask import session as flask_session + +from src.application.output_ports.account_session_repository import AccountSessionRepository + + +class FlaskSessionAdapter(AccountSessionRepository): + """ + Implementation of the AccountSessionRepository using Flask's internal cookies. + + This adapter translates abstract key-value operations into concrete + flask.session dictionary manipulations, using only native Python types. + """ + + def set(self, key: str, value: str | int | float | bool | dict | list) -> None: + """ + Assigns a primitive value to a session key using Flask's session object. + + Args: + key (str): Identifier for the storage key. + value (str | int | float | bool | dict | list): Data to store in the session. + """ + flask_session[key] = value + + def get(self, key: str) -> str | int | float | bool | dict | list | None: + """ + Gets a value from a session key using Flask's session object. + + Args: + key (str): Identifier for the storage key. + + Returns: + str | int | float | bool | dict | list | None: The stored session data or None if missing. + """ + return flask_session.get(key) + + def clear(self) -> None: + """ + Wipes the entire Flask session cookie, removing all stored data. + """ + flask_session.clear() diff --git a/tests_hexagonal/tests_services/test_account_session_service.py b/tests_hexagonal/tests_services/test_account_session_service.py new file mode 100644 index 0000000..9467788 --- /dev/null +++ b/tests_hexagonal/tests_services/test_account_session_service.py @@ -0,0 +1,41 @@ +from unittest.mock import Mock + +from src.application.output_ports.account_repository import AccountRepository +from src.application.output_ports.account_session_repository import AccountSessionRepository +from src.application.services.account_session_service import AccountSessionService +from tests_hexagonal.test_domain_factories import create_test_account + + +class TestAccountSessionService: + def setup_method(self): + self.session_repo = Mock(spec=AccountSessionRepository) + self.account_repo = Mock(spec=AccountRepository) + self.service = AccountSessionService( + session_repository=self.session_repo, + account_repository=self.account_repo + ) + + def test_start_session(self): + account = create_test_account() + self.service.start_session(account) + self.session_repo.set.assert_any_call("user_id", account.account_id) + self.session_repo.set.assert_any_call("username", account.account_username) + self.session_repo.set.assert_any_call("role", account.account_role.value) + + def test_get_current_account_success(self): + account = create_test_account() + self.session_repo.get.return_value = account.account_id + self.account_repo.get_by_id.return_value = account + result = self.service.get_current_account() + assert result is not None + assert result.account_id == account.account_id + self.account_repo.get_by_id.assert_called_once_with(account.account_id) + + def test_get_current_account_no_session(self): + self.session_repo.get.return_value = None + result = self.service.get_current_account() + assert result is None + + def test_terminate_session(self): + self.service.terminate_session() + self.session_repo.clear.assert_called_once() diff --git a/tests_hexagonal/tests_services/test_login_service.py b/tests_hexagonal/tests_services/test_login_service.py index 0a4ea24..a188707 100644 --- a/tests_hexagonal/tests_services/test_login_service.py +++ b/tests_hexagonal/tests_services/test_login_service.py @@ -1,26 +1,21 @@ from unittest.mock import MagicMock +from src.application.input_ports.account_session_management import AccountSessionManagement from src.application.output_ports.account_repository import AccountRepository from src.application.services.login_service import LoginService from tests_hexagonal.test_domain_factories import create_test_account class TestLoginService: - """ - Tests for the LoginService to ensure authentication logic is correct. - """ - def setup_method(self): - """ - Setup the test environment by mocking the AccountRepository. - """ self.mock_repo = MagicMock(spec=AccountRepository, autospec=True) - self.service = LoginService(account_repository=self.mock_repo) + self.mock_session = MagicMock(spec=AccountSessionManagement, autospec=True) + self.service = LoginService( + account_repository=self.mock_repo, + session_service=self.mock_session + ) def test_authenticate_user_success(self): - """ - Ensures that correct credentials return the corresponding account. - """ fake_account = create_test_account() self.mock_repo.find_by_username.return_value = fake_account @@ -30,13 +25,11 @@ def test_authenticate_user_success(self): ) self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + self.mock_session.start_session.assert_called_once_with(fake_account) assert result is not None assert result.account_username == "leia" def test_authenticate_user_wrong_password(self): - """ - Ensures that incorrect password returns None. - """ fake_account = create_test_account() self.mock_repo.find_by_username.return_value = fake_account @@ -46,15 +39,12 @@ def test_authenticate_user_wrong_password(self): ) self.mock_repo.find_by_username.assert_called_once_with(fake_account.account_username) + self.mock_session.start_session.assert_not_called() assert result is None def test_authenticate_user_non_existent(self): - """ - Ensures that non-existent username returns None. - """ self.mock_repo.find_by_username.return_value = None - result = self.service.authenticate_user(username="phantom", password="nothing") - self.mock_repo.find_by_username.assert_called_once_with("phantom") + self.mock_session.start_session.assert_not_called() assert result is None From 05104a98a1377965d9673e20999473b15f4dd887 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 7 Apr 2026 02:35:54 +0200 Subject: [PATCH 41/81] MVCS to hexagonal architecture : Centralizes and renames account session keys by introducing the `AccountSessionKey` Enum, updating `AccountSessionService` to rely on it and adjusting the corresponding tests accordingly --- src/application/domain/account.py | 10 ++++++++++ .../services/account_session_service.py | 18 +++++------------- .../test_account_session_service.py | 7 ++++--- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/application/domain/account.py b/src/application/domain/account.py index a0472c3..1b1bc76 100644 --- a/src/application/domain/account.py +++ b/src/application/domain/account.py @@ -12,6 +12,16 @@ class AccountRole(str, Enum): USER = "user" +class AccountSessionKey(str, Enum): + """ + Available keys for storing account information in the session. + Using an Enum ensures consistency across the application and prevents typos. + """ + USER_ID = "user_id" + USERNAME = "username" + ROLE = "role" + + class Account: """ Represents a user account in the system. diff --git a/src/application/services/account_session_service.py b/src/application/services/account_session_service.py index 2871da4..d0be03f 100644 --- a/src/application/services/account_session_service.py +++ b/src/application/services/account_session_service.py @@ -1,4 +1,4 @@ -from src.application.domain.account import Account +from src.application.domain.account import Account, AccountSessionKey from src.application.input_ports.account_session_management import AccountSessionManagement from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.account_session_repository import AccountSessionRepository @@ -7,16 +7,8 @@ class AccountSessionService(AccountSessionManagement): """ Service providing session-related business logic. - - Decouples the system's identity management from a physical storage implementation. - This service is the only component that knows the specific storage keys used - for maintaining a session. """ - USER_ID_KEY = "user_id" - USERNAME_KEY = "username" - ROLE_KEY = "role" - def __init__( self, session_repository: AccountSessionRepository, @@ -39,9 +31,9 @@ def start_session(self, account: Account) -> None: Args: account (Account): The domain entity being logged in. """ - self._session_repository.set(self.USER_ID_KEY, account.account_id) - self._session_repository.set(self.USERNAME_KEY, account.account_username) - self._session_repository.set(self.ROLE_KEY, account.account_role.value) + self._session_repository.set(AccountSessionKey.USER_ID, account.account_id) + self._session_repository.set(AccountSessionKey.USERNAME, account.account_username) + self._session_repository.set(AccountSessionKey.ROLE, account.account_role.value) def get_current_account(self) -> Account | None: """ @@ -50,7 +42,7 @@ def get_current_account(self) -> Account | None: Returns: Account | None: The domain entity if its ID is found in session, otherwise None. """ - account_id = self._session_repository.get(self.USER_ID_KEY) + account_id = self._session_repository.get(AccountSessionKey.USER_ID) if not account_id: return None diff --git a/tests_hexagonal/tests_services/test_account_session_service.py b/tests_hexagonal/tests_services/test_account_session_service.py index 9467788..a1ddf0d 100644 --- a/tests_hexagonal/tests_services/test_account_session_service.py +++ b/tests_hexagonal/tests_services/test_account_session_service.py @@ -1,5 +1,6 @@ from unittest.mock import Mock +from src.application.domain.account import AccountSessionKey from src.application.output_ports.account_repository import AccountRepository from src.application.output_ports.account_session_repository import AccountSessionRepository from src.application.services.account_session_service import AccountSessionService @@ -18,9 +19,9 @@ def setup_method(self): def test_start_session(self): account = create_test_account() self.service.start_session(account) - self.session_repo.set.assert_any_call("user_id", account.account_id) - self.session_repo.set.assert_any_call("username", account.account_username) - self.session_repo.set.assert_any_call("role", account.account_role.value) + self.session_repo.set.assert_any_call(AccountSessionKey.USER_ID, account.account_id) + self.session_repo.set.assert_any_call(AccountSessionKey.USERNAME, account.account_username) + self.session_repo.set.assert_any_call(AccountSessionKey.ROLE, account.account_role.value) def test_get_current_account_success(self): account = create_test_account() From 926d3d1fb92d92f8b4254c398fe0dfb057e9795a Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 7 Apr 2026 19:35:13 +0200 Subject: [PATCH 42/81] MVCS to hexagonal architecture : Implements hexagonal input adapters for account management by introducing Login, Registration, and Session adapters and adds the corresponding adapter test suite --- .../input_adapters/account_session_adapter.py | 52 +++++ .../input_adapters/login_adapter.py | 61 ++++++ .../input_adapters/registration_adapter.py | 65 ++++++ .../input_adapters/templates/login.html | 176 +++++++++++++++ .../input_adapters/templates/profile.html | 201 ++++++++++++++++++ .../templates/registration.html | 184 ++++++++++++++++ .../test_account_session_adapter.py | 71 +++++++ .../test_login_adapter.py | 74 +++++++ .../test_registration_adapter.py | 83 ++++++++ 9 files changed, 967 insertions(+) create mode 100644 src/infrastructure/input_adapters/account_session_adapter.py create mode 100644 src/infrastructure/input_adapters/login_adapter.py create mode 100644 src/infrastructure/input_adapters/registration_adapter.py create mode 100644 src/infrastructure/input_adapters/templates/login.html create mode 100644 src/infrastructure/input_adapters/templates/profile.html create mode 100644 src/infrastructure/input_adapters/templates/registration.html create mode 100644 tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py create mode 100644 tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py diff --git a/src/infrastructure/input_adapters/account_session_adapter.py b/src/infrastructure/input_adapters/account_session_adapter.py new file mode 100644 index 0000000..bbe9f0b --- /dev/null +++ b/src/infrastructure/input_adapters/account_session_adapter.py @@ -0,0 +1,52 @@ +from flask import flash, redirect, render_template, url_for +from flask.views import MethodView + +from src.application.input_ports.account_session_management import AccountSessionManagement +from src.infrastructure.input_adapters.dto.account_response import AccountResponse + + +class AccountSessionAdapter(MethodView): + """ + Unified adapter for session-related operations (Logout and Profile). + It bridges HTTP requests to the AccountSessionManagement port. + + This class adheres to Rule #6 (Adapter Uniqueness) by centralizing + all session-related Web actions into a single infrastructure component. + """ + + def __init__(self, session_service: AccountSessionManagement): + """ + Initializes the AccountSessionAdapter with the required session service. + + Args: + session_service (AccountSessionManagement): The domain service for session management. + """ + self.session_service = session_service + + def logout(self): + """ + Terminates the current user session and redirects to the articles list. + + Returns: + Response: A Flask redirect response. + """ + self.session_service.terminate_session() + flash("You have been logged out.") + return redirect(url_for("article.list_articles")) + + def display_profile(self): + """ + Renders the current user's profile if an active session is found. + Redirects to the login page otherwise. + + Returns: + str | Response: The rendered profile HTML or a redirect response. + """ + account = self.session_service.get_current_account() + + if not account: + flash("Please sign in to view your profile.") + return redirect(url_for("auth.login")) + + user_dto = AccountResponse.from_domain(account) + return render_template("profile.html", user=user_dto) diff --git a/src/infrastructure/input_adapters/login_adapter.py b/src/infrastructure/input_adapters/login_adapter.py new file mode 100644 index 0000000..edf7068 --- /dev/null +++ b/src/infrastructure/input_adapters/login_adapter.py @@ -0,0 +1,61 @@ +from flask import flash, redirect, render_template, request, url_for +from flask.views import MethodView +from pydantic import ValidationError + +from src.application.input_ports.login_management import LoginManagementPort +from src.infrastructure.input_adapters.dto.login_request import LoginRequest + + +class LoginAdapter(MethodView): + """ + Adapter responsible for handling user authentication via the Web interface. + It bridges HTTP requests to the LoginManagementPort. + """ + + def __init__(self, login_service: LoginManagementPort): + """ + Initializes the LoginAdapter with the required login service. + + Args: + login_service (LoginManagementPort): The domain service for authentication. + """ + self.login_service = login_service + + def render_login_page(self): + """ + Renders the login page to the user. + + Returns: + str: The rendered HTML for the login page. + """ + return render_template("login.html") + + def authenticate(self): + """ + Processes the login form submission. + Validates the input using LoginRequest DTO and calls the authentication service. + + Returns: + Response: Redirects to the articles list on success, or back to login on failure. + """ + try: + login_data = LoginRequest( + username=request.form.get("username", ""), + password=request.form.get("password", "") + ) + except ValidationError as e: + for error in e.errors(): + location = str(error["loc"][0]) if error["loc"] else "Request" + flash(f"{location}: {error['msg']}") + return redirect(url_for("auth.login")) + + account = self.login_service.authenticate_user( + username=login_data.username, + password=login_data.password + ) + + if account: + return redirect(url_for("article.list_articles")) + + flash("Invalid username or password.") + return redirect(url_for("auth.login")) diff --git a/src/infrastructure/input_adapters/registration_adapter.py b/src/infrastructure/input_adapters/registration_adapter.py new file mode 100644 index 0000000..903f0d2 --- /dev/null +++ b/src/infrastructure/input_adapters/registration_adapter.py @@ -0,0 +1,65 @@ +from flask import flash, redirect, render_template, request, url_for +from flask.views import MethodView +from pydantic import ValidationError + +from src.application.input_ports.registration_management import RegistrationManagementPort +from src.infrastructure.input_adapters.dto.registration_request import RegistrationRequest + + +class RegistrationAdapter(MethodView): + """ + Adapter responsible for handling account registration via the Web interface. + It bridges HTTP requests to the RegistrationManagementPort. + """ + + def __init__(self, registration_service: RegistrationManagementPort): + """ + Initializes the RegistrationAdapter with the required registration service. + + Args: + registration_service (RegistrationManagementPort): The domain service for account creation. + """ + self.registration_service = registration_service + + def render_registration_page(self): + """ + Renders the registration page to the user. + + Returns: + str: The rendered HTML for the registration page. + """ + return render_template("registration.html") + + def register(self): + """ + Processes the account registration form submission. + Validates the input using RegistrationRequest DTO and calls the registration service. + + Returns: + Response: Redirects to login on success, or back to registration on failure. + """ + try: + reg_data = RegistrationRequest( + username=request.form.get("username", ""), + email=request.form.get("email", ""), + password=request.form.get("password", ""), + confirm_password=request.form.get("confirm_password", "") + ) + except ValidationError as e: + for error in e.errors(): + location = str(error["loc"][0]) if error["loc"] else "Request" + flash(f"{location}: {error['msg']}") + return redirect(url_for("registration.register")) + + result = self.registration_service.create_account( + username=reg_data.username, + password=reg_data.password, + email=reg_data.email + ) + + if isinstance(result, str): + flash(result) + return redirect(url_for("registration.register")) + + flash("Registration successful. Please sign in.") + return redirect(url_for("auth.login")) diff --git a/src/infrastructure/input_adapters/templates/login.html b/src/infrastructure/input_adapters/templates/login.html new file mode 100644 index 0000000..2a515cb --- /dev/null +++ b/src/infrastructure/input_adapters/templates/login.html @@ -0,0 +1,176 @@ + + + + + + Login - Blog Management + + + + + + + diff --git a/src/infrastructure/input_adapters/templates/profile.html b/src/infrastructure/input_adapters/templates/profile.html new file mode 100644 index 0000000..db4d79b --- /dev/null +++ b/src/infrastructure/input_adapters/templates/profile.html @@ -0,0 +1,201 @@ + + + + + + Profile - Blog Management + + + + +
+
+
+ {{ user.account_username[0]|upper }} +
+

{{ user.account_username }}

+ {{ user.account_role }} +
+ +
+
+ Email + {{ user.account_email }} +
+
+ Member Since + + {% if user.account_created_at %} + {{ user.account_created_at.strftime('%B %d, %Y') }} + {% else %} + N/A + {% endif %} + +
+
+ Status + Active +
+
+ + + + Return to articles list +
+ + diff --git a/src/infrastructure/input_adapters/templates/registration.html b/src/infrastructure/input_adapters/templates/registration.html new file mode 100644 index 0000000..8fe83bf --- /dev/null +++ b/src/infrastructure/input_adapters/templates/registration.html @@ -0,0 +1,184 @@ + + + + + + Register - Blog Management + + + + +
+

Join the Blog

+

Create your account to start writing

+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py new file mode 100644 index 0000000..0e1b18a --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py @@ -0,0 +1,71 @@ +import os +from unittest.mock import Mock + +from flask import Flask, get_flashed_messages + +from src.application.input_ports.account_session_management import AccountSessionManagement +from src.infrastructure.input_adapters.account_session_adapter import AccountSessionAdapter +from tests_hexagonal.test_domain_factories import create_test_account + + +class TestAccountSessionAdapter: + def setup_method(self): + template_dir = os.path.abspath("src/infrastructure/input_adapters/templates") + self.app = Flask(__name__, template_folder=template_dir) + self.app.config["SECRET_KEY"] = "test_secret" + self.app.config["SERVER_NAME"] = "localhost" + self.app.config["TESTING"] = True + self.mock_service = Mock(spec=AccountSessionManagement) + self.adapter = AccountSessionAdapter(session_service=self.mock_service) + + self.app.add_url_rule( + "/logout", + view_func=self.adapter.logout, + endpoint="logout.logout" + ) + + self.app.add_url_rule( + "/profile", + view_func=self.adapter.display_profile, + endpoint="account_session.profile" + ) + + @self.app.route("/articles") + def articles_dummy(): + return f"articles {get_flashed_messages()}" + self.app.add_url_rule("/articles", endpoint="article.list_articles") + + @self.app.route("/login") + def login_dummy(): + return f"login {get_flashed_messages()}" + self.app.add_url_rule("/login", endpoint="auth.login") + + def test_logout_clears_session(self): + with self.app.test_request_context(): + with self.app.test_client() as client: + response = client.get("/logout", follow_redirects=True) + + assert b"You have been logged out." in response.data + self.mock_service.terminate_session.assert_called_once() + + def test_get_profile_success(self): + fake_user = create_test_account() + self.mock_service.get_current_account.return_value = fake_user + + with self.app.test_request_context(): + with self.app.test_client() as client: + response = client.get("/profile") + + assert response.status_code == 200 + assert b"leia" in response.data + assert b"leia@galaxy.com" in response.data + + def test_get_profile_unauthenticated(self): + self.mock_service.get_current_account.return_value = None + + with self.app.test_request_context(): + with self.app.test_client() as client: + response = client.get("/profile", follow_redirects=True) + + assert b"Please sign in to view your profile." in response.data + assert b"login" in response.data diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py new file mode 100644 index 0000000..7ee80d6 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py @@ -0,0 +1,74 @@ +from unittest.mock import Mock + +from flask import Flask + +from src.application.input_ports.login_management import LoginManagementPort +from src.infrastructure.input_adapters.login_adapter import LoginAdapter +from tests_hexagonal.test_domain_factories import create_test_account + + +class TestLoginAdapter: + def setup_method(self): + import os + template_dir = os.path.abspath("src/infrastructure/input_adapters/templates") + self.app = Flask(__name__, template_folder=template_dir) + self.app.config["SECRET_KEY"] = "test_secret" + self.app.config["SERVER_NAME"] = "localhost" + self.mock_service = Mock(spec=LoginManagementPort) + self.adapter = LoginAdapter(login_service=self.mock_service) + self.app.add_url_rule("/login", view_func=self.adapter.render_login_page, methods=["GET"], endpoint="auth.login") + self.app.add_url_rule("/login", view_func=self.adapter.authenticate, methods=["POST"], endpoint="auth.login_post") + + @self.app.route("/articles") + def list_articles(): + from flask import get_flashed_messages + return f"articles {get_flashed_messages()}" + self.app.add_url_rule("/articles", endpoint="article.list_articles") + + def test_get_login_page(self): + with self.app.test_request_context(): + with self.app.test_client() as client: + @self.app.route("/register") + def reg_dummy(): return "reg" + self.app.add_url_rule("/register", endpoint="registration.register") + response = client.get("/login") + assert response.status_code == 200 + assert b"Welcome Back" in response.data + + def test_post_login_success(self): + account = create_test_account() + self.mock_service.authenticate_user.return_value = account + + with self.app.test_request_context(): + with self.app.test_client() as client: + @self.app.route("/register") + def reg_dummy(): return "reg" + self.app.add_url_rule("/register", endpoint="registration.register") + + response = client.post("/login", data={ + "username": "leia", + "password": "force_is_with_her" + }) + + assert response.status_code == 302 + assert response.location.endswith("/articles") + self.mock_service.authenticate_user.assert_called_once_with( + username="leia", + password="force_is_with_her" + ) + + def test_post_login_invalid_credentials(self): + self.mock_service.authenticate_user.return_value = None + with self.app.test_request_context(): + with self.app.test_client() as client: + @self.app.route("/register") + def reg_dummy(): return "reg" + self.app.add_url_rule("/register", endpoint="registration.register") + + response = client.post("/login", data={ + "username": "wrong", + "password": "wrong" + }, follow_redirects=True) + + assert b"Invalid username or password." in response.data + self.mock_service.authenticate_user.assert_called_once() diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py new file mode 100644 index 0000000..c105e9c --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py @@ -0,0 +1,83 @@ +from unittest.mock import Mock + +from flask import Flask + +from src.application.input_ports.registration_management import RegistrationManagementPort +from src.infrastructure.input_adapters.registration_adapter import RegistrationAdapter +from tests_hexagonal.test_domain_factories import create_test_account + + +class TestRegistrationAdapter: + """ + Tests for the RegistrationAdapter to ensure correct registration flow. + """ + + def setup_method(self): + import os + template_dir = os.path.abspath("src/infrastructure/input_adapters/templates") + + self.app = Flask(__name__, template_folder=template_dir) + self.app.config["SECRET_KEY"] = "test_secret" + self.app.config["SERVER_NAME"] = "localhost" + self.app.config["TESTING"] = True + self.app.config["PROPAGATE_EXCEPTIONS"] = True + self.mock_service = Mock(spec=RegistrationManagementPort) + self.adapter = RegistrationAdapter(registration_service=self.mock_service) + self.app.add_url_rule("/register", view_func=self.adapter.render_registration_page, methods=["GET"], endpoint="registration.register") + self.app.add_url_rule("/register", view_func=self.adapter.register, methods=["POST"], endpoint="registration.register_post") + + @self.app.route("/login") + def login_dummy(): + from flask import get_flashed_messages + return f"login_page {get_flashed_messages()}" + self.app.add_url_rule("/login", endpoint="auth.login") + + def test_get_registration_page(self): + with self.app.test_request_context(): + with self.app.test_client() as client: + response = client.get("/register") + assert response.status_code == 200 + assert b"Join the Blog" in response.data + + def test_post_registration_success(self): + fake_account = create_test_account() + self.mock_service.create_account.return_value = fake_account + with self.app.test_request_context(): + with self.app.test_client() as client: + response = client.post("/register", data={ + "username": "leia", + "email": "leia@rebels.com", + "password": "password123", + "confirm_password": "password123" + }, follow_redirects=True) + + assert b"Registration successful. Please sign in." in response.data + assert response.request.path == "/login" + self.mock_service.create_account.assert_called_once() + + def test_post_registration_password_mismatch(self): + with self.app.test_request_context(): + with self.app.test_client() as client: + response = client.post("/register", data={ + "username": "leia", + "email": "leia@rebels.com", + "password": "password123", + "confirm_password": "wrong_confirm" + }, follow_redirects=True) + + assert b"Passwords do not match." in response.data + self.mock_service.create_account.assert_not_called() + + def test_post_registration_email_taken(self): + self.mock_service.create_account.return_value = "This email is already taken." + with self.app.test_request_context(): + with self.app.test_client() as client: + response = client.post("/register", data={ + "username": "leia", + "email": "leia@rebels.com", + "password": "password123", + "confirm_password": "password123" + }, follow_redirects=True) + + assert b"This email is already taken." in response.data + self.mock_service.create_account.assert_called_once() From 0a39f779efb1a6f1b745d13425cb3352668c9ddc Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 7 Apr 2026 19:39:08 +0200 Subject: [PATCH 43/81] MVCS to hexagonal architecture : Improves the session output adapter by giving `AccountSessionRepository` clearer action names, adding the `FlaskSessionAdapter` that follows this contract and updating `AccountSessionService` and its tests to match the new naming --- .../account_session_repository.py | 6 ++-- .../services/account_session_service.py | 10 +++--- .../session/flask_session_adapter.py | 6 ++-- .../test_flask_session_adapter.py | 36 +++++++++++++++++++ .../test_account_session_service.py | 12 +++---- 5 files changed, 53 insertions(+), 17 deletions(-) create mode 100644 tests_hexagonal/tests_infrastructure/tests_output_adapters/test_flask_session_adapter.py diff --git a/src/application/output_ports/account_session_repository.py b/src/application/output_ports/account_session_repository.py index 2c52585..33b20c7 100644 --- a/src/application/output_ports/account_session_repository.py +++ b/src/application/output_ports/account_session_repository.py @@ -12,7 +12,7 @@ class AccountSessionRepository(ABC): """ @abstractmethod - def set(self, key: str, value: str | int | float | bool | dict | list) -> None: + def store_value(self, key: str, value: str | int | float | bool | dict | list) -> None: """ Stores a primitive value in the current session. @@ -23,7 +23,7 @@ def set(self, key: str, value: str | int | float | bool | dict | list) -> None: pass @abstractmethod - def get(self, key: str) -> str | int | float | bool | dict | list | None: + def retrieve_value(self, key: str) -> str | int | float | bool | dict | list | None: """ Retrieves a primitive value from the current session. @@ -36,7 +36,7 @@ def get(self, key: str) -> str | int | float | bool | dict | list | None: pass @abstractmethod - def clear(self) -> None: + def invalidate(self) -> None: """ Wipes all current session data from storage. """ diff --git a/src/application/services/account_session_service.py b/src/application/services/account_session_service.py index d0be03f..475c620 100644 --- a/src/application/services/account_session_service.py +++ b/src/application/services/account_session_service.py @@ -31,9 +31,9 @@ def start_session(self, account: Account) -> None: Args: account (Account): The domain entity being logged in. """ - self._session_repository.set(AccountSessionKey.USER_ID, account.account_id) - self._session_repository.set(AccountSessionKey.USERNAME, account.account_username) - self._session_repository.set(AccountSessionKey.ROLE, account.account_role.value) + self._session_repository.store_value(AccountSessionKey.USER_ID, account.account_id) + self._session_repository.store_value(AccountSessionKey.USERNAME, account.account_username) + self._session_repository.store_value(AccountSessionKey.ROLE, account.account_role.value) def get_current_account(self) -> Account | None: """ @@ -42,7 +42,7 @@ def get_current_account(self) -> Account | None: Returns: Account | None: The domain entity if its ID is found in session, otherwise None. """ - account_id = self._session_repository.get(AccountSessionKey.USER_ID) + account_id = self._session_repository.retrieve_value(AccountSessionKey.USER_ID) if not account_id: return None @@ -52,4 +52,4 @@ def terminate_session(self) -> None: """ Wipes the identification data from the session storage, effectively logging out. """ - self._session_repository.clear() + self._session_repository.invalidate() diff --git a/src/infrastructure/output_adapters/session/flask_session_adapter.py b/src/infrastructure/output_adapters/session/flask_session_adapter.py index 5f04baa..f28b2a2 100644 --- a/src/infrastructure/output_adapters/session/flask_session_adapter.py +++ b/src/infrastructure/output_adapters/session/flask_session_adapter.py @@ -11,7 +11,7 @@ class FlaskSessionAdapter(AccountSessionRepository): flask.session dictionary manipulations, using only native Python types. """ - def set(self, key: str, value: str | int | float | bool | dict | list) -> None: + def store_value(self, key: str, value: str | int | float | bool | dict | list) -> None: """ Assigns a primitive value to a session key using Flask's session object. @@ -21,7 +21,7 @@ def set(self, key: str, value: str | int | float | bool | dict | list) -> None: """ flask_session[key] = value - def get(self, key: str) -> str | int | float | bool | dict | list | None: + def retrieve_value(self, key: str) -> str | int | float | bool | dict | list | None: """ Gets a value from a session key using Flask's session object. @@ -33,7 +33,7 @@ def get(self, key: str) -> str | int | float | bool | dict | list | None: """ return flask_session.get(key) - def clear(self) -> None: + def invalidate(self) -> None: """ Wipes the entire Flask session cookie, removing all stored data. """ diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_flask_session_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_flask_session_adapter.py new file mode 100644 index 0000000..0ef8cef --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_flask_session_adapter.py @@ -0,0 +1,36 @@ +from flask import Flask + +from src.infrastructure.output_adapters.session.flask_session_adapter import FlaskSessionAdapter + + +class TestFlaskSessionAdapter: + def setup_method(self): + self.app = Flask(__name__) + self.app.config["SECRET_KEY"] = "test_secret" + self.adapter = FlaskSessionAdapter() + + def test_store_and_retrieve_value(self): + with self.app.test_request_context(): + self.adapter.store_value("user_id", 42) + assert self.adapter.retrieve_value("user_id") == 42 + + def test_retrieve_missing_value_returns_none(self): + with self.app.test_request_context(): + assert self.adapter.retrieve_value("non_existent") is None + + def test_invalidate_clears_all_data(self): + with self.app.test_request_context(): + self.adapter.store_value("key1", "value1") + self.adapter.store_value("key2", "value2") + self.adapter.invalidate() + assert self.adapter.retrieve_value("key1") is None + assert self.adapter.retrieve_value("key2") is None + + def test_store_complex_types(self): + with self.app.test_request_context(): + data_list = [1, 2, 3] + data_dict = {"a": 1, "b": 2} + self.adapter.store_value("my_list", data_list) + self.adapter.store_value("my_dict", data_dict) + assert self.adapter.retrieve_value("my_list") == data_list + assert self.adapter.retrieve_value("my_dict") == data_dict diff --git a/tests_hexagonal/tests_services/test_account_session_service.py b/tests_hexagonal/tests_services/test_account_session_service.py index a1ddf0d..387f739 100644 --- a/tests_hexagonal/tests_services/test_account_session_service.py +++ b/tests_hexagonal/tests_services/test_account_session_service.py @@ -19,13 +19,13 @@ def setup_method(self): def test_start_session(self): account = create_test_account() self.service.start_session(account) - self.session_repo.set.assert_any_call(AccountSessionKey.USER_ID, account.account_id) - self.session_repo.set.assert_any_call(AccountSessionKey.USERNAME, account.account_username) - self.session_repo.set.assert_any_call(AccountSessionKey.ROLE, account.account_role.value) + self.session_repo.store_value.assert_any_call(AccountSessionKey.USER_ID, account.account_id) + self.session_repo.store_value.assert_any_call(AccountSessionKey.USERNAME, account.account_username) + self.session_repo.store_value.assert_any_call(AccountSessionKey.ROLE, account.account_role.value) def test_get_current_account_success(self): account = create_test_account() - self.session_repo.get.return_value = account.account_id + self.session_repo.retrieve_value.return_value = account.account_id self.account_repo.get_by_id.return_value = account result = self.service.get_current_account() assert result is not None @@ -33,10 +33,10 @@ def test_get_current_account_success(self): self.account_repo.get_by_id.assert_called_once_with(account.account_id) def test_get_current_account_no_session(self): - self.session_repo.get.return_value = None + self.session_repo.retrieve_value.return_value = None result = self.service.get_current_account() assert result is None def test_terminate_session(self): self.service.terminate_session() - self.session_repo.clear.assert_called_once() + self.session_repo.invalidate.assert_called_once() From 59075c3f0e07016e01a7ba3e967135191bcc58eb Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Tue, 7 Apr 2026 19:47:29 +0200 Subject: [PATCH 44/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Moves=20the=20SQLAlchemy=20tests=20into=20a=20dedicated=20fo?= =?UTF-8?q?lder=20to=20clearly=20separate=20the=20different=20output=20ada?= =?UTF-8?q?pters,=20especially=20between=20Flask=20sessions=20and=20the=20?= =?UTF-8?q?SQLAlchemy=E2=80=91based=20database?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{ => tests_sqlalchemy}/test_sqlalchemy_account_adapter.py | 0 .../{ => tests_sqlalchemy}/test_sqlalchemy_article_adapter.py | 0 .../{ => tests_sqlalchemy}/test_sqlalchemy_comment_adapter.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests_hexagonal/tests_infrastructure/tests_output_adapters/{ => tests_sqlalchemy}/test_sqlalchemy_account_adapter.py (100%) rename tests_hexagonal/tests_infrastructure/tests_output_adapters/{ => tests_sqlalchemy}/test_sqlalchemy_article_adapter.py (100%) rename tests_hexagonal/tests_infrastructure/tests_output_adapters/{ => tests_sqlalchemy}/test_sqlalchemy_comment_adapter.py (100%) diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py similarity index 100% rename from tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_account_adapter.py rename to tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_article_adapter.py similarity index 100% rename from tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_article_adapter.py rename to tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_article_adapter.py diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_comment_adapter.py similarity index 100% rename from tests_hexagonal/tests_infrastructure/tests_output_adapters/test_sqlalchemy_comment_adapter.py rename to tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_comment_adapter.py From 1fccd1c7612a21176fa9b2c5c84f5d3784de16d5 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 8 Apr 2026 02:41:57 +0200 Subject: [PATCH 45/81] MVCS to hexagonal architecture : Refactors test data generation by unifying Account, Article and Comment creation through domain factories, standardizing their use across SQLAlchemy adapter and DTO tests --- .../dto/test_account_response.py | 55 ++++++++++++------- .../test_sqlalchemy_account_adapter.py | 15 ++--- .../test_sqlalchemy_article_adapter.py | 6 +- .../test_sqlalchemy_comment_adapter.py | 6 +- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py index 319abfb..f620bf7 100644 --- a/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/dto/test_account_response.py @@ -1,31 +1,46 @@ from datetime import datetime -from src.application.domain.account import Account, AccountRole +from src.application.domain.account import AccountRole from src.infrastructure.input_adapters.dto.account_response import AccountResponse +from tests_hexagonal.test_domain_factories import create_test_account -def test_account_response_from_domain_excludes_password(): - """ - Test that mapping from an Account domain entity correctly populates - all public fields and completely excludes the password. - """ - domain_account = Account( +def test_account_response_creation(): + fixed_date = datetime(2026, 4, 7, 10, 0, 0) + + response = AccountResponse( + account_id=1, + account_username="leia", + account_email="leia@galaxy.com", + account_role="user", + account_created_at=fixed_date + ) + + assert response.account_id == 1 + assert response.account_username == "leia" + assert response.account_role == "user" + assert response.account_created_at == fixed_date + + +def test_from_domain(): + fixed_date = datetime(2026, 4, 7, 10, 0, 0) + + domain_account = create_test_account( account_id=1, - account_username="testuser", - account_password="secret_password_123", - account_email="test@example.com", + account_username="leia", + account_email="leia@galaxy.com", account_role=AccountRole.USER, - account_created_at=datetime(2023, 1, 1, 12, 0, 0), + account_created_at=fixed_date ) - response_dto = AccountResponse.from_domain(domain_account) - assert response_dto.account_id == 1 - assert response_dto.account_username == "testuser" - assert response_dto.account_email == "test@example.com" - assert response_dto.account_role == "user" - assert response_dto.account_created_at == datetime(2023, 1, 1, 12, 0, 0) - assert not hasattr(response_dto, "account_password") - assert not hasattr(response_dto, "password") - response_dict = response_dto.model_dump() + response = AccountResponse.from_domain(domain_account) + assert response.account_id == 1 + assert response.account_username == "leia" + assert response.account_email == "leia@galaxy.com" + assert response.account_role == "user" + assert response.account_created_at == fixed_date + assert not hasattr(response, "account_password") + assert not hasattr(response, "password") + response_dict = response.model_dump() assert "account_password" not in response_dict assert "password" not in response_dict diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py index c150622..8075f99 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_account_adapter.py @@ -1,6 +1,7 @@ -from src.application.domain.account import Account, AccountRole +from src.application.domain.account import AccountRole from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_account_model import AccountModel from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_account_adapter import SqlAlchemyAccountAdapter +from tests_hexagonal.test_domain_factories import create_test_account from tests_hexagonal.tests_infrastructure.tests_output_adapters.infrastructure_test_utils import ( AccountDataBuilder, SqlAlchemyTestBase, @@ -19,6 +20,7 @@ def test_find_by_username_returns_domain_account(self): self.account_builder.create(username="admin_user", role="admin") result = self.repository.find_by_username("admin_user") assert result is not None + from src.application.domain.account import Account assert isinstance(result, Account) assert result.account_username == "admin_user" assert result.account_role == AccountRole.ADMIN @@ -39,6 +41,7 @@ def test_find_by_email_returns_domain_account(self): self.account_builder.create(email="found@example.com", role="author") result = self.repository.find_by_email("found@example.com") assert result is not None + from src.application.domain.account import Account assert isinstance(result, Account) assert result.account_email == "found@example.com" assert result.account_role == AccountRole.AUTHOR @@ -59,6 +62,7 @@ def test_get_by_id_returns_domain_account(self): inserted = self.account_builder.create(username="id_user", role="user") result = self.repository.get_by_id(inserted.account_id) assert result is not None + from src.application.domain.account import Account assert isinstance(result, Account) assert result.account_username == "id_user" assert result.account_role == AccountRole.USER @@ -70,13 +74,12 @@ def test_get_by_id_not_found_returns_none(self): class TestAccountSave(SqlAlchemyAccountAdapterTestBase): def test_save_persists_account_to_database(self): - account = Account( + account = create_test_account( account_id=0, account_username="new_user", account_password="hashed_pwd", account_email="new@example.com", account_role=AccountRole.USER, - account_created_at=None, ) self.repository.save(account) @@ -86,13 +89,12 @@ def test_save_persists_account_to_database(self): assert model.account_role == "user" def test_save_account_is_retrievable_via_adapter(self): - account = Account( + account = create_test_account( account_id=0, account_username="round_trip", account_password="secure", account_email="round@trip.com", account_role=AccountRole.AUTHOR, - account_created_at=None, ) self.repository.save(account) @@ -102,13 +104,12 @@ def test_save_account_is_retrievable_via_adapter(self): assert result.account_role == AccountRole.AUTHOR def test_save_assigns_auto_generated_id(self): - account = Account( + account = create_test_account( account_id=0, account_username="auto_id", account_password="pass", account_email="auto@id.com", account_role=AccountRole.USER, - account_created_at=None, ) self.repository.save(account) diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_article_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_article_adapter.py index b0e8e31..4150ecd 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_article_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_article_adapter.py @@ -1,6 +1,6 @@ -from src.application.domain.article import Article from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_article_model import ArticleModel from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_article_adapter import SqlAlchemyArticleAdapter +from tests_hexagonal.test_domain_factories import create_test_article from tests_hexagonal.tests_infrastructure.tests_output_adapters.infrastructure_test_utils import ( AccountDataBuilder, ArticleDataBuilder, @@ -22,6 +22,7 @@ def test_get_by_id_returns_article(self): inserted = self.article_builder.create(author_id=account.account_id) result = self.repository.get_by_id(inserted.article_id) assert result is not None + from src.application.domain.article import Article assert isinstance(result, Article) assert result.article_title == "Test Title" @@ -34,12 +35,11 @@ class TestArticleSave(SqlAlchemyArticleAdapterTestBase): def test_save_persists_article_to_database(self): account = self.account_builder.create() - article = Article( + article = create_test_article( article_id=0, article_author_id=account.account_id, article_title="Saved Article", article_content="New Content", - article_published_at=None, ) self.repository.save(article) diff --git a/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_comment_adapter.py b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_comment_adapter.py index 49f09d6..e3f75fd 100644 --- a/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_comment_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_output_adapters/tests_sqlalchemy/test_sqlalchemy_comment_adapter.py @@ -1,6 +1,6 @@ -from src.application.domain.comment import Comment from src.infrastructure.output_adapters.sqlalchemy.models.sqlalchemy_comment_model import CommentModel from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_comment_adapter import SqlAlchemyCommentAdapter +from tests_hexagonal.test_domain_factories import create_test_comment from tests_hexagonal.tests_infrastructure.tests_output_adapters.infrastructure_test_utils import ( AccountDataBuilder, ArticleDataBuilder, @@ -25,6 +25,7 @@ def test_get_by_id_returns_comment(self): inserted = self.comment_builder.create(article_id=article.article_id, author_id=account.account_id) result = self.repository.get_by_id(inserted.comment_id) assert result is not None + from src.application.domain.comment import Comment assert isinstance(result, Comment) assert result.comment_content == "Test Comment" @@ -38,13 +39,12 @@ def test_save_persists_comment_to_database(self): account = self.account_builder.create() article = self.article_builder.create(author_id=account.account_id) - comment = Comment( + comment = create_test_comment( comment_id=0, comment_article_id=article.article_id, comment_written_account_id=account.account_id, comment_reply_to=None, comment_content="My new comment", - comment_posted_at=None, ) self.repository.save(comment) From 8d9971c02ae05ba9d8a8e9d5c40edc4141658e78 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 8 Apr 2026 03:45:55 +0200 Subject: [PATCH 46/81] MVCS to hexagonal architecture : Simplifies input adapter tests by extracting `FlaskInputAdapterTestBase`, adding shared helpers in `input_adapter_test_utils.py`, updating all input adapter tests to use the new base class and centralizing template path handling --- .../input_adapter_test_utils.py | 37 ++++++++++++++++ .../test_account_session_adapter.py | 25 ++++------- .../test_login_adapter.py | 32 ++++---------- .../test_registration_adapter.py | 42 +++++++++---------- 4 files changed, 71 insertions(+), 65 deletions(-) create mode 100644 tests_hexagonal/tests_infrastructure/tests_input_adapters/input_adapter_test_utils.py diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/input_adapter_test_utils.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/input_adapter_test_utils.py new file mode 100644 index 0000000..0b3c6b6 --- /dev/null +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/input_adapter_test_utils.py @@ -0,0 +1,37 @@ +import os + +from flask import Flask, get_flashed_messages + + +class FlaskInputAdapterTestBase: + """ + Shared base class for Flask input adapter tests. + Centralizes Flask application setup, template path resolution, + and common test configuration to eliminate duplication. + """ + + TEMPLATE_DIR = os.path.abspath("src/infrastructure/input_adapters/templates") + + def setup_method(self): + self.app = Flask(__name__, template_folder=self.TEMPLATE_DIR) + self.app.config["SECRET_KEY"] = "test_secret" + self.app.config["SERVER_NAME"] = "localhost" + self.app.config["TESTING"] = True + + def _register_dummy_route(self, rule, endpoint, label=None): + """ + Registers a dummy route that returns flashed messages. + Useful for testing redirects to routes owned by other adapters. + + Args: + rule (str): The URL rule (e.g. "/articles"). + endpoint (str): The Flask endpoint name (e.g. "article.list_articles"). + label (str): Optional label included in the response body. + """ + if label is None: + label = endpoint + + def dummy_view(): + return f"{label} {get_flashed_messages()}" + + self.app.add_url_rule(rule, view_func=dummy_view, endpoint=endpoint) diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py index 0e1b18a..8f41661 100644 --- a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py @@ -1,20 +1,16 @@ -import os from unittest.mock import Mock -from flask import Flask, get_flashed_messages - from src.application.input_ports.account_session_management import AccountSessionManagement from src.infrastructure.input_adapters.account_session_adapter import AccountSessionAdapter from tests_hexagonal.test_domain_factories import create_test_account +from tests_hexagonal.tests_infrastructure.tests_input_adapters.input_adapter_test_utils import ( + FlaskInputAdapterTestBase, +) -class TestAccountSessionAdapter: +class TestAccountSessionAdapter(FlaskInputAdapterTestBase): def setup_method(self): - template_dir = os.path.abspath("src/infrastructure/input_adapters/templates") - self.app = Flask(__name__, template_folder=template_dir) - self.app.config["SECRET_KEY"] = "test_secret" - self.app.config["SERVER_NAME"] = "localhost" - self.app.config["TESTING"] = True + super().setup_method() self.mock_service = Mock(spec=AccountSessionManagement) self.adapter = AccountSessionAdapter(session_service=self.mock_service) @@ -30,15 +26,8 @@ def setup_method(self): endpoint="account_session.profile" ) - @self.app.route("/articles") - def articles_dummy(): - return f"articles {get_flashed_messages()}" - self.app.add_url_rule("/articles", endpoint="article.list_articles") - - @self.app.route("/login") - def login_dummy(): - return f"login {get_flashed_messages()}" - self.app.add_url_rule("/login", endpoint="auth.login") + self._register_dummy_route("/articles", "article.list_articles", "articles") + self._register_dummy_route("/login", "auth.login", "login") def test_logout_clears_session(self): with self.app.test_request_context(): diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py index 7ee80d6..860a6ec 100644 --- a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py @@ -1,36 +1,26 @@ from unittest.mock import Mock -from flask import Flask - from src.application.input_ports.login_management import LoginManagementPort from src.infrastructure.input_adapters.login_adapter import LoginAdapter from tests_hexagonal.test_domain_factories import create_test_account +from tests_hexagonal.tests_infrastructure.tests_input_adapters.input_adapter_test_utils import ( + FlaskInputAdapterTestBase, +) -class TestLoginAdapter: +class TestLoginAdapter(FlaskInputAdapterTestBase): def setup_method(self): - import os - template_dir = os.path.abspath("src/infrastructure/input_adapters/templates") - self.app = Flask(__name__, template_folder=template_dir) - self.app.config["SECRET_KEY"] = "test_secret" - self.app.config["SERVER_NAME"] = "localhost" + super().setup_method() self.mock_service = Mock(spec=LoginManagementPort) self.adapter = LoginAdapter(login_service=self.mock_service) self.app.add_url_rule("/login", view_func=self.adapter.render_login_page, methods=["GET"], endpoint="auth.login") self.app.add_url_rule("/login", view_func=self.adapter.authenticate, methods=["POST"], endpoint="auth.login_post") - - @self.app.route("/articles") - def list_articles(): - from flask import get_flashed_messages - return f"articles {get_flashed_messages()}" - self.app.add_url_rule("/articles", endpoint="article.list_articles") + self._register_dummy_route("/articles", "article.list_articles", "articles") + self._register_dummy_route("/register", "registration.register", "reg") def test_get_login_page(self): with self.app.test_request_context(): with self.app.test_client() as client: - @self.app.route("/register") - def reg_dummy(): return "reg" - self.app.add_url_rule("/register", endpoint="registration.register") response = client.get("/login") assert response.status_code == 200 assert b"Welcome Back" in response.data @@ -41,10 +31,6 @@ def test_post_login_success(self): with self.app.test_request_context(): with self.app.test_client() as client: - @self.app.route("/register") - def reg_dummy(): return "reg" - self.app.add_url_rule("/register", endpoint="registration.register") - response = client.post("/login", data={ "username": "leia", "password": "force_is_with_her" @@ -61,10 +47,6 @@ def test_post_login_invalid_credentials(self): self.mock_service.authenticate_user.return_value = None with self.app.test_request_context(): with self.app.test_client() as client: - @self.app.route("/register") - def reg_dummy(): return "reg" - self.app.add_url_rule("/register", endpoint="registration.register") - response = client.post("/login", data={ "username": "wrong", "password": "wrong" diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py index c105e9c..95187b5 100644 --- a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py @@ -1,36 +1,34 @@ from unittest.mock import Mock -from flask import Flask - from src.application.input_ports.registration_management import RegistrationManagementPort from src.infrastructure.input_adapters.registration_adapter import RegistrationAdapter from tests_hexagonal.test_domain_factories import create_test_account +from tests_hexagonal.tests_infrastructure.tests_input_adapters.input_adapter_test_utils import ( + FlaskInputAdapterTestBase, +) -class TestRegistrationAdapter: - """ - Tests for the RegistrationAdapter to ensure correct registration flow. - """ - +class TestRegistrationAdapter(FlaskInputAdapterTestBase): def setup_method(self): - import os - template_dir = os.path.abspath("src/infrastructure/input_adapters/templates") - - self.app = Flask(__name__, template_folder=template_dir) - self.app.config["SECRET_KEY"] = "test_secret" - self.app.config["SERVER_NAME"] = "localhost" - self.app.config["TESTING"] = True - self.app.config["PROPAGATE_EXCEPTIONS"] = True + super().setup_method() self.mock_service = Mock(spec=RegistrationManagementPort) self.adapter = RegistrationAdapter(registration_service=self.mock_service) - self.app.add_url_rule("/register", view_func=self.adapter.render_registration_page, methods=["GET"], endpoint="registration.register") - self.app.add_url_rule("/register", view_func=self.adapter.register, methods=["POST"], endpoint="registration.register_post") - @self.app.route("/login") - def login_dummy(): - from flask import get_flashed_messages - return f"login_page {get_flashed_messages()}" - self.app.add_url_rule("/login", endpoint="auth.login") + self.app.add_url_rule( + "/register", + view_func=self.adapter.render_registration_page, + methods=["GET"], + endpoint="registration.register" + ) + + self.app.add_url_rule( + "/register", + iew_func=self.adapter.register, + methods=["POST"], + endpoint="registration.register_post" + ) + + self._register_dummy_route("/login", "auth.login", "login_page") def test_get_registration_page(self): with self.app.test_request_context(): From c8fcd141af7dbc38f7b4213eab5f57ffc7634c35 Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 8 Apr 2026 05:42:33 +0200 Subject: [PATCH 47/81] MVCS to hexagonal architecture : Adds lightweight integration for the input adapters by updating the Login, Registration and Session adapters to use real Services instead of mocks --- .../test_account_session_adapter.py | 22 ++++++++++---- .../test_login_adapter.py | 29 ++++++++++++------- .../test_registration_adapter.py | 26 ++++++++++------- 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py index 8f41661..b5d132b 100644 --- a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_account_session_adapter.py @@ -1,6 +1,8 @@ from unittest.mock import Mock -from src.application.input_ports.account_session_management import AccountSessionManagement +from src.application.output_ports.account_repository import AccountRepository +from src.application.output_ports.account_session_repository import AccountSessionRepository +from src.application.services.account_session_service import AccountSessionService from src.infrastructure.input_adapters.account_session_adapter import AccountSessionAdapter from tests_hexagonal.test_domain_factories import create_test_account from tests_hexagonal.tests_infrastructure.tests_input_adapters.input_adapter_test_utils import ( @@ -11,8 +13,15 @@ class TestAccountSessionAdapter(FlaskInputAdapterTestBase): def setup_method(self): super().setup_method() - self.mock_service = Mock(spec=AccountSessionManagement) - self.adapter = AccountSessionAdapter(session_service=self.mock_service) + self.mock_repo = Mock(spec=AccountRepository, autospec=True) + self.mock_session_repo = Mock(spec=AccountSessionRepository, autospec=True) + + self.service = AccountSessionService( + session_repository=self.mock_session_repo, + account_repository=self.mock_repo + ) + + self.adapter = AccountSessionAdapter(session_service=self.service) self.app.add_url_rule( "/logout", @@ -35,11 +44,12 @@ def test_logout_clears_session(self): response = client.get("/logout", follow_redirects=True) assert b"You have been logged out." in response.data - self.mock_service.terminate_session.assert_called_once() + self.mock_session_repo.invalidate.assert_called_once() def test_get_profile_success(self): fake_user = create_test_account() - self.mock_service.get_current_account.return_value = fake_user + self.mock_session_repo.retrieve_value.return_value = fake_user.account_id + self.mock_repo.get_by_id.return_value = fake_user with self.app.test_request_context(): with self.app.test_client() as client: @@ -50,7 +60,7 @@ def test_get_profile_success(self): assert b"leia@galaxy.com" in response.data def test_get_profile_unauthenticated(self): - self.mock_service.get_current_account.return_value = None + self.mock_session_repo.retrieve_value.return_value = None with self.app.test_request_context(): with self.app.test_client() as client: diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py index 860a6ec..5ac23fa 100644 --- a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_login_adapter.py @@ -1,6 +1,8 @@ from unittest.mock import Mock -from src.application.input_ports.login_management import LoginManagementPort +from src.application.input_ports.account_session_management import AccountSessionManagement +from src.application.output_ports.account_repository import AccountRepository +from src.application.services.login_service import LoginService from src.infrastructure.input_adapters.login_adapter import LoginAdapter from tests_hexagonal.test_domain_factories import create_test_account from tests_hexagonal.tests_infrastructure.tests_input_adapters.input_adapter_test_utils import ( @@ -11,8 +13,15 @@ class TestLoginAdapter(FlaskInputAdapterTestBase): def setup_method(self): super().setup_method() - self.mock_service = Mock(spec=LoginManagementPort) - self.adapter = LoginAdapter(login_service=self.mock_service) + self.mock_repo = Mock(spec=AccountRepository, autospec=True) + self.mock_session = Mock(spec=AccountSessionManagement, autospec=True) + + self.service = LoginService( + account_repository=self.mock_repo, + session_service=self.mock_session + ) + + self.adapter = LoginAdapter(login_service=self.service) self.app.add_url_rule("/login", view_func=self.adapter.render_login_page, methods=["GET"], endpoint="auth.login") self.app.add_url_rule("/login", view_func=self.adapter.authenticate, methods=["POST"], endpoint="auth.login_post") self._register_dummy_route("/articles", "article.list_articles", "articles") @@ -27,24 +36,22 @@ def test_get_login_page(self): def test_post_login_success(self): account = create_test_account() - self.mock_service.authenticate_user.return_value = account + self.mock_repo.find_by_username.return_value = account with self.app.test_request_context(): with self.app.test_client() as client: response = client.post("/login", data={ "username": "leia", - "password": "force_is_with_her" + "password": "password123" }) assert response.status_code == 302 assert response.location.endswith("/articles") - self.mock_service.authenticate_user.assert_called_once_with( - username="leia", - password="force_is_with_her" - ) + self.mock_repo.find_by_username.assert_called_once_with("leia") + self.mock_session.start_session.assert_called_once_with(account) def test_post_login_invalid_credentials(self): - self.mock_service.authenticate_user.return_value = None + self.mock_repo.find_by_username.return_value = None with self.app.test_request_context(): with self.app.test_client() as client: response = client.post("/login", data={ @@ -53,4 +60,4 @@ def test_post_login_invalid_credentials(self): }, follow_redirects=True) assert b"Invalid username or password." in response.data - self.mock_service.authenticate_user.assert_called_once() + self.mock_repo.find_by_username.assert_called_once() diff --git a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py index 95187b5..1984db6 100644 --- a/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py +++ b/tests_hexagonal/tests_infrastructure/tests_input_adapters/test_registration_adapter.py @@ -1,6 +1,7 @@ from unittest.mock import Mock -from src.application.input_ports.registration_management import RegistrationManagementPort +from src.application.output_ports.account_repository import AccountRepository +from src.application.services.registration_service import RegistrationService from src.infrastructure.input_adapters.registration_adapter import RegistrationAdapter from tests_hexagonal.test_domain_factories import create_test_account from tests_hexagonal.tests_infrastructure.tests_input_adapters.input_adapter_test_utils import ( @@ -11,8 +12,9 @@ class TestRegistrationAdapter(FlaskInputAdapterTestBase): def setup_method(self): super().setup_method() - self.mock_service = Mock(spec=RegistrationManagementPort) - self.adapter = RegistrationAdapter(registration_service=self.mock_service) + self.mock_repo = Mock(spec=AccountRepository, autospec=True) + self.service = RegistrationService(account_repository=self.mock_repo) + self.adapter = RegistrationAdapter(registration_service=self.service) self.app.add_url_rule( "/register", @@ -23,7 +25,7 @@ def setup_method(self): self.app.add_url_rule( "/register", - iew_func=self.adapter.register, + view_func=self.adapter.register, methods=["POST"], endpoint="registration.register_post" ) @@ -38,8 +40,9 @@ def test_get_registration_page(self): assert b"Join the Blog" in response.data def test_post_registration_success(self): - fake_account = create_test_account() - self.mock_service.create_account.return_value = fake_account + self.mock_repo.find_by_username.return_value = None + self.mock_repo.find_by_email.return_value = None + with self.app.test_request_context(): with self.app.test_client() as client: response = client.post("/register", data={ @@ -51,7 +54,7 @@ def test_post_registration_success(self): assert b"Registration successful. Please sign in." in response.data assert response.request.path == "/login" - self.mock_service.create_account.assert_called_once() + self.mock_repo.save.assert_called_once() def test_post_registration_password_mismatch(self): with self.app.test_request_context(): @@ -64,10 +67,13 @@ def test_post_registration_password_mismatch(self): }, follow_redirects=True) assert b"Passwords do not match." in response.data - self.mock_service.create_account.assert_not_called() + self.mock_repo.save.assert_not_called() def test_post_registration_email_taken(self): - self.mock_service.create_account.return_value = "This email is already taken." + existing_account = create_test_account(account_email="leia@rebels.com") + self.mock_repo.find_by_username.return_value = None + self.mock_repo.find_by_email.return_value = existing_account + with self.app.test_request_context(): with self.app.test_client() as client: response = client.post("/register", data={ @@ -78,4 +84,4 @@ def test_post_registration_email_taken(self): }, follow_redirects=True) assert b"This email is already taken." in response.data - self.mock_service.create_account.assert_called_once() + self.mock_repo.save.assert_not_called() From 7d2dfddd5b9986546610b13b7b3f9455fa080cde Mon Sep 17 00:00:00 2001 From: raydeveloppeur-admin Date: Wed, 8 Apr 2026 06:10:11 +0200 Subject: [PATCH 48/81] =?UTF-8?q?MVCS=20to=20hexagonal=20architecture=20:?= =?UTF-8?q?=20Extracts=20inline=20CSS=20into=20static=20files=20and=20intr?= =?UTF-8?q?oduces=20a=20shared=20base=20template=20by=20moving=20global=20?= =?UTF-8?q?styles=20to=20`base.css`,=20adding=20view=E2=80=91specific=20CS?= =?UTF-8?q?S,=20creating=20`base.html`=20and=20updating=20all=20views=20to?= =?UTF-8?q?=20extend=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../input_adapters/static/css/base.css | 109 +++++++++ .../input_adapters/static/css/login.css | 27 ++ .../input_adapters/static/css/profile.css | 108 ++++++++ .../static/css/registration.css | 33 +++ .../input_adapters/templates/base.html | 14 ++ .../input_adapters/templates/login.html | 200 +++------------ .../input_adapters/templates/profile.html | 230 +++--------------- .../templates/registration.html | 212 +++------------- 8 files changed, 400 insertions(+), 533 deletions(-) create mode 100644 src/infrastructure/input_adapters/static/css/base.css create mode 100644 src/infrastructure/input_adapters/static/css/login.css create mode 100644 src/infrastructure/input_adapters/static/css/profile.css create mode 100644 src/infrastructure/input_adapters/static/css/registration.css create mode 100644 src/infrastructure/input_adapters/templates/base.html diff --git a/src/infrastructure/input_adapters/static/css/base.css b/src/infrastructure/input_adapters/static/css/base.css new file mode 100644 index 0000000..34ac809 --- /dev/null +++ b/src/infrastructure/input_adapters/static/css/base.css @@ -0,0 +1,109 @@ +:root { + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --glass-bg: rgba(255, 255, 255, 0.9); + --text-color: #2d3748; + --input-border: #e2e8f0; + --accent-color: #667eea; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: 'Inter', sans-serif; +} + +body { + background: var(--primary-gradient); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +h1 { + color: var(--text-color); + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.5rem; + text-align: center; +} + +.subtitle { + color: #718096; + font-size: 0.875rem; + text-align: center; + margin-bottom: 2rem; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Flash Messages & Alerts */ +.flash-messages { + margin-bottom: 1.5rem; +} + +.alert { + background-color: #fff5f5; + color: #c53030; + padding: 1rem; + border-left: 4px solid #fc8181; + border-radius: 0.5rem; + font-size: 0.875rem; +} + +/* Forms */ +.form-group { + margin-bottom: 1.25rem; +} + +label { + display: block; + color: #4a5568; + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +input { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--input-border); + border-radius: 0.75rem; + font-size: 1rem; + transition: all 0.2s; + outline: none; +} + +input:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); +} + +/* Buttons */ +button, .btn { + width: 100%; + padding: 0.875rem; + background: var(--primary-gradient); + color: white; + border: none; + border-radius: 0.75rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s; + margin-top: 1rem; + display: inline-block; +} + +button:hover, .btn:hover { + transform: scale(1.02); +} + +button:active, .btn:active { + transform: scale(0.98); +} diff --git a/src/infrastructure/input_adapters/static/css/login.css b/src/infrastructure/input_adapters/static/css/login.css new file mode 100644 index 0000000..72b6ddd --- /dev/null +++ b/src/infrastructure/input_adapters/static/css/login.css @@ -0,0 +1,27 @@ +.login-card { + background: var(--glass-bg); + backdrop-filter: blur(10px); + max-width: 400px; + width: 100%; + border-radius: 1.5rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + padding: 2.5rem; + animation: fadeIn 0.6s ease-out; +} + +.footer { + margin-top: 2rem; + text-align: center; + font-size: 0.875rem; + color: #718096; +} + +.footer a { + color: var(--accent-color); + text-decoration: none; + font-weight: 600; +} + +.footer a:hover { + text-decoration: underline; +} diff --git a/src/infrastructure/input_adapters/static/css/profile.css b/src/infrastructure/input_adapters/static/css/profile.css new file mode 100644 index 0000000..2ea9e02 --- /dev/null +++ b/src/infrastructure/input_adapters/static/css/profile.css @@ -0,0 +1,108 @@ +.profile-card { + background: var(--glass-bg); + backdrop-filter: blur(10px); + max-width: 500px; + width: 100%; + border-radius: 1.5rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + padding: 3rem; + position: relative; + animation: fadeIn 0.6s ease-out; +} + +.profile-header { + text-align: center; + margin-bottom: 2.5rem; +} + +.avatar-placeholder { + width: 80px; + height: 80px; + background: var(--primary-gradient); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 700; + margin: 0 auto 1rem; +} + +.role-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + background: #edf2f7; + color: #4a5568; + border-radius: 1rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-section { + margin-bottom: 2rem; +} + +.info-row { + display: flex; + justify-content: space-between; + padding: 1rem 0; + border-bottom: 1px solid #e2e8f0; +} + +.info-label { + color: #718096; + font-size: 0.875rem; + font-weight: 500; +} + +.info-value { + color: var(--text-color); + font-size: 0.875rem; + font-weight: 600; +} + +.actions { + display: flex; + gap: 1rem; + margin-top: 2rem; +} + +.btn { + flex: 1; + text-align: center; + text-decoration: none; + margin-top: 0; +} + +.btn-primary { + background: var(--primary-gradient); + color: white; + border: none; +} + +.btn-outline { + background: white; + color: #e53e3e; + border: 2px solid #fed7d7; +} + +.btn-outline:hover { + background: #fff5f5; +} + +.back-link { + display: block; + text-align: center; + margin-top: 1.5rem; + color: #718096; + font-size: 0.875rem; + text-decoration: none; +} + +.back-link:hover { + color: var(--accent-color); + text-decoration: underline; +} diff --git a/src/infrastructure/input_adapters/static/css/registration.css b/src/infrastructure/input_adapters/static/css/registration.css new file mode 100644 index 0000000..c4094da --- /dev/null +++ b/src/infrastructure/input_adapters/static/css/registration.css @@ -0,0 +1,33 @@ +.reg-card { + background: var(--glass-bg); + backdrop-filter: blur(10px); + max-width: 450px; + width: 100%; + border-radius: 1.5rem; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); + padding: 2.5rem; + animation: fadeIn 0.6s ease-out; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.footer { + margin-top: 1.5rem; + text-align: center; + font-size: 0.875rem; + color: #718096; +} + +.footer a { + color: var(--accent-color); + text-decoration: none; + font-weight: 600; +} + +.footer a:hover { + text-decoration: underline; +} diff --git a/src/infrastructure/input_adapters/templates/base.html b/src/infrastructure/input_adapters/templates/base.html new file mode 100644 index 0000000..60db6d7 --- /dev/null +++ b/src/infrastructure/input_adapters/templates/base.html @@ -0,0 +1,14 @@ + + + + + + {% block title %}Blog Management{% endblock %} + + + {% block extra_css %}{% endblock %} + + + {% block content %}{% endblock %} + + diff --git a/src/infrastructure/input_adapters/templates/login.html b/src/infrastructure/input_adapters/templates/login.html index 2a515cb..18f96a3 100644 --- a/src/infrastructure/input_adapters/templates/login.html +++ b/src/infrastructure/input_adapters/templates/login.html @@ -1,176 +1,40 @@ - - - - - - Login - Blog Management - - - - -