From 29cdaed6dadcb0e3f941a78b01b601d391c2d9a0 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Tue, 27 Feb 2024 08:47:59 +0200 Subject: [PATCH 01/18] HF-43: Implemented custom types for User's module --- src/users/types.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/users/types.py diff --git a/src/users/types.py b/src/users/types.py new file mode 100644 index 0000000..797107e --- /dev/null +++ b/src/users/types.py @@ -0,0 +1,35 @@ +import re +from typing import NewType +from uuid import UUID + +UserID = NewType("UserID", UUID) + +HashedPassword = NewType("HashedPassword", bytes) + + +class Email(str): + __slots__ = () + + _EMAIL_PATTERN = r"^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$" + _INVALID_EMAIL_ERROR_TEXT = "Invalid email address" + + def __new__(cls, address: str) -> "Email": + if not re.match(cls._EMAIL_PATTERN, address): + raise ValueError(cls._INVALID_EMAIL_ERROR_TEXT) + return super().__new__(cls, address) + + +class Password(str): + __slots__ = () + + _MIN_LENGTH = 4 + _MAX_LENGTH = 100 + _MIN_LENGTH_ERROR_TEXT = f"Ensure this value has at least {_MIN_LENGTH} characters" + _MAX_LENGTH_ERROR_TEXT = f"Ensure this value has at most {_MAX_LENGTH} characters" + + def __new__(cls, password: str) -> "Password": + if len(password) < cls._MIN_LENGTH: + raise ValueError(cls._MIN_LENGTH_ERROR_TEXT) + if len(password) > cls._MAX_LENGTH: + raise ValueError(cls._MAX_LENGTH_ERROR_TEXT) + return super().__new__(cls, password) From 0ed7033966f22d8ebd674ff3897e9f72222c03c7 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Tue, 27 Feb 2024 08:48:57 +0200 Subject: [PATCH 02/18] HF-43: Added User's custom types to User entity --- src/users/entities/user.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/users/entities/user.py b/src/users/entities/user.py index 0ba8cdc..f2ec8dd 100644 --- a/src/users/entities/user.py +++ b/src/users/entities/user.py @@ -2,11 +2,13 @@ from dataclasses import dataclass, field from datetime import datetime +from src.users.types import Email, HashedPassword, UserID + @dataclass class User: - email: str - password: bytes - id: uuid.UUID = field(default_factory=uuid.uuid4) + email: Email + password: HashedPassword + id: UserID = field(default_factory=lambda: UserID(uuid.uuid4())) created_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now) From a058b6a1e7a59ec1c3265086025d936a2abecd1a Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Tue, 27 Feb 2024 08:50:22 +0200 Subject: [PATCH 03/18] HF-43: Simlified User's UseCases and added User's custom types to these UseCases --- src/users/usecases/__init__.py | 2 - src/users/usecases/create.py | 67 ++++++++-------------------------- src/users/usecases/exist.py | 8 ++-- 3 files changed, 20 insertions(+), 57 deletions(-) diff --git a/src/users/usecases/__init__.py b/src/users/usecases/__init__.py index 5b7e20f..c15849f 100644 --- a/src/users/usecases/__init__.py +++ b/src/users/usecases/__init__.py @@ -3,7 +3,6 @@ UserCreateInput, UserCreateRepoInterface, UserCreateUseCase, - UserCreateUseCaseValidationRules, ) from .exist import ( UserExistRepositoryInterface, @@ -15,7 +14,6 @@ "UserCreateInput", "UserCreateRepoInterface", "UserCreateHashingProviderInterface", - "UserCreateUseCaseValidationRules", "UserExistUseCase", "UserExistRepositoryInterface", ] diff --git a/src/users/usecases/create.py b/src/users/usecases/create.py index d3960da..bd9d52a 100644 --- a/src/users/usecases/create.py +++ b/src/users/usecases/create.py @@ -1,14 +1,14 @@ import abc -import re from dataclasses import dataclass from src.exceptions.core import ValidationError from src.users.entities import User +from src.users.types import Email, HashedPassword, Password class UserCreateRepoInterface(abc.ABC): @abc.abstractmethod - async def exists(self, email: str) -> bool: + async def exists(self, email: Email) -> bool: ... @abc.abstractmethod @@ -18,72 +18,35 @@ async def save(self, user: User) -> None: class UserCreateHashingProviderInterface(abc.ABC): @abc.abstractmethod - def hash_password(self, password: str) -> bytes: + def hash_password(self, password: Password) -> HashedPassword: ... -@dataclass -class UserCreateUseCaseValidationRules: - password_min_length: int - - @dataclass class UserCreateInput: - email: str - password: str + email: Email + password: Password class UserCreateUseCase: def __init__( - self, - user_repo: UserCreateRepoInterface, - hashing_provider: UserCreateHashingProviderInterface, - validation_rules: UserCreateUseCaseValidationRules, - ) -> None: + self, + user_repo: UserCreateRepoInterface, + hashing_provider: UserCreateHashingProviderInterface, + ) -> None: self._user_repo = user_repo self._hashing_provider = hashing_provider - self._validation_rules = validation_rules async def execute(self, input_: UserCreateInput) -> User: - await self._validate(input_=input_) - - user = self._create_domain(input_=input_) - - await self._user_repo.save(user=user) - - return user - - async def _validate(self, input_: UserCreateInput) -> None: - await self._validate_email(email=input_.email) - self._validate_password(password=input_.password) - - async def _validate_email(self, email: str) -> None: - if not self._is_correct_email_format(email=email): - raise ValidationError(field="email", message="Invalid email") - - if await self._user_repo.exists(email=email): + if await self._user_repo.exists(email=input_.email): raise ValidationError(field="email", message="Email already exists") - def _is_correct_email_format(self, email: str) -> bool: - if ( - not email - or not re.match(r"[^@]+@[^@]+\.[^@]+", email) - ): - return False - return True - - def _validate_password(self, password: str) -> None: - if not password: - raise ValidationError(field="password", message="Field is required") - if len(password) < self._validation_rules.password_min_length: - raise ValidationError( - field="password", - message=f"Password must be at least {self._validation_rules.password_min_length} characters" - ) - - def _create_domain(self, input_: UserCreateInput) -> User: hashed_password = self._hashing_provider.hash_password(input_.password) - return User( + user = User( email=input_.email, password=hashed_password, ) + + await self._user_repo.save(user) + + return user diff --git a/src/users/usecases/exist.py b/src/users/usecases/exist.py index b19c6a5..d90bd20 100644 --- a/src/users/usecases/exist.py +++ b/src/users/usecases/exist.py @@ -1,15 +1,17 @@ import abc -import uuid + +from src.users.types import UserID class UserExistRepositoryInterface(abc.ABC): @abc.abstractmethod - def exist(self, user_id: uuid.UUID) -> bool: + def exist(self, user_id: UserID) -> bool: ... + class UserExistUseCase: def __init__(self, user_repo: UserExistRepositoryInterface) -> None: self._user_repo = user_repo - async def execute(self, user_id: uuid.UUID) -> bool: + async def execute(self, user_id: UserID) -> bool: return self._user_repo.exist(user_id=user_id) From e068b2c580bd80f85017156c0537c984c3e15ef4 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Tue, 27 Feb 2024 08:51:18 +0200 Subject: [PATCH 04/18] HF-43: Added dependencies to `src.users.__init__.py` --- src/users/__init__.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/users/__init__.py b/src/users/__init__.py index e69de29..0538400 100644 --- a/src/users/__init__.py +++ b/src/users/__init__.py @@ -0,0 +1,27 @@ +from .entities import User +from .types import Email, HashedPassword, Password, UserID +from .usecases import ( + UserCreateHashingProviderInterface, + UserCreateInput, + UserCreateRepoInterface, + UserCreateUseCase, + UserExistRepositoryInterface, + UserExistUseCase, +) + +__all__ = [ + # entities + "User", + # types + "Email", + "HashedPassword", + "Password", + "UserID", + # usecases + "UserCreateHashingProviderInterface", + "UserCreateInput", + "UserCreateRepoInterface", + "UserCreateUseCase", + "UserExistRepositoryInterface", + "UserExistUseCase", +] From 96e6b53445b8de8a4be55793ec39ed641b9129ef Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Tue, 27 Feb 2024 08:52:04 +0200 Subject: [PATCH 05/18] HF-41: Added tests for User's custom types --- tests/test_users/test_types/__init__.py | 0 tests/test_users/test_types/test_email.py | 8 ++++++++ tests/test_users/test_types/test_password.py | 13 +++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 tests/test_users/test_types/__init__.py create mode 100644 tests/test_users/test_types/test_email.py create mode 100644 tests/test_users/test_types/test_password.py diff --git a/tests/test_users/test_types/__init__.py b/tests/test_users/test_types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_users/test_types/test_email.py b/tests/test_users/test_types/test_email.py new file mode 100644 index 0000000..93a8348 --- /dev/null +++ b/tests/test_users/test_types/test_email.py @@ -0,0 +1,8 @@ +import pytest + +from src.users import Email + + +def test_error_if_email_is_invalid() -> None: + with pytest.raises(ValueError, match=Email._INVALID_EMAIL_ERROR_TEXT): + Email("invalid-email") diff --git a/tests/test_users/test_types/test_password.py b/tests/test_users/test_types/test_password.py new file mode 100644 index 0000000..4b8b76a --- /dev/null +++ b/tests/test_users/test_types/test_password.py @@ -0,0 +1,13 @@ +import pytest + +from src.users import Password + + +def test_error_if_password_length_is_less_than_min_length() -> None: + with pytest.raises(ValueError, match=Password._MIN_LENGTH_ERROR_TEXT): + Password("1" * (Password._MIN_LENGTH - 1)) + + +def test_error_if_password_length_is_more_than_max_length() -> None: + with pytest.raises(ValueError, match=Password._MAX_LENGTH_ERROR_TEXT): + Password("1" * (Password._MAX_LENGTH + 1)) From dcd8fb57d84ce904ec82aefd46645db88e316c76 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Tue, 27 Feb 2024 08:52:58 +0200 Subject: [PATCH 06/18] HF-41: Updated User's UseCases' tests --- .../test_usecases/test_create/conftest.py | 13 ++-- .../test_create/test__create_domain.py | 10 --- .../test__is_correct_email_format.py | 12 ---- .../test_create/test__validate.py | 28 -------- .../test_create/test__validate_email.py | 69 ------------------- .../test_create/test__validate_password.py | 25 ------- .../test_usecases/test_create/test_execute.py | 49 +++++++------ .../test_usecases/test_exist/test_execute.py | 8 +-- 8 files changed, 32 insertions(+), 182 deletions(-) delete mode 100644 tests/test_users/test_usecases/test_create/test__create_domain.py delete mode 100644 tests/test_users/test_usecases/test_create/test__is_correct_email_format.py delete mode 100644 tests/test_users/test_usecases/test_create/test__validate.py delete mode 100644 tests/test_users/test_usecases/test_create/test__validate_email.py delete mode 100644 tests/test_users/test_usecases/test_create/test__validate_password.py diff --git a/tests/test_users/test_usecases/test_create/conftest.py b/tests/test_users/test_usecases/test_create/conftest.py index 4a88a90..94e45c2 100644 --- a/tests/test_users/test_usecases/test_create/conftest.py +++ b/tests/test_users/test_usecases/test_create/conftest.py @@ -1,12 +1,13 @@ import pytest from pytest_mock import MockerFixture -from src.users.usecases import ( +from src.users import ( + Email, + Password, UserCreateHashingProviderInterface, UserCreateInput, UserCreateRepoInterface, UserCreateUseCase, - UserCreateUseCaseValidationRules, ) @@ -15,15 +16,9 @@ def usecase(mocker: MockerFixture) -> UserCreateUseCase: return UserCreateUseCase( user_repo=mocker.Mock(spec=UserCreateRepoInterface), hashing_provider=mocker.Mock(spec=UserCreateHashingProviderInterface), - validation_rules=UserCreateUseCaseValidationRules( - password_min_length=4, - ), ) @pytest.fixture() def input_() -> UserCreateInput: - return UserCreateInput( - email="example@email.com", - password="example_password", - ) + return UserCreateInput(email=Email("example@example.com"), password=Password("example1234")) diff --git a/tests/test_users/test_usecases/test_create/test__create_domain.py b/tests/test_users/test_usecases/test_create/test__create_domain.py deleted file mode 100644 index bd12919..0000000 --- a/tests/test_users/test_usecases/test_create/test__create_domain.py +++ /dev/null @@ -1,10 +0,0 @@ -from src.users.entities import User -from src.users.usecases import UserCreateInput, UserCreateUseCase - - -async def test_domain_entity_is_created(usecase: UserCreateUseCase, input_: UserCreateInput) -> None: - user = usecase._create_domain(input_) - - assert isinstance(user, User) - assert user.email == input_.email - assert user.password == usecase._hashing_provider.hash_password.return_value # type: ignore diff --git a/tests/test_users/test_usecases/test_create/test__is_correct_email_format.py b/tests/test_users/test_usecases/test_create/test__is_correct_email_format.py deleted file mode 100644 index 9be19fb..0000000 --- a/tests/test_users/test_usecases/test_create/test__is_correct_email_format.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest - -from src.users.usecases import UserCreateUseCase - - -@pytest.mark.parametrize(("email", "valid"), [ - ("invalid_email", False), - ("", False), - ("example@gmail.com", True), -]) -def test_error_if_email_is_invalid(usecase: UserCreateUseCase, email: str, valid: bool) -> None: - assert usecase._is_correct_email_format(email) == valid diff --git a/tests/test_users/test_usecases/test_create/test__validate.py b/tests/test_users/test_usecases/test_create/test__validate.py deleted file mode 100644 index ddaef5e..0000000 --- a/tests/test_users/test_usecases/test_create/test__validate.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest -from pytest_mock import MockerFixture - -from src.users.usecases import UserCreateInput, UserCreateUseCase - - -@pytest.fixture(autouse=True) -def necessary_mocks(mocker: MockerFixture, usecase: UserCreateUseCase) -> None: - mocker.patch.object(usecase, "_validate_email") - mocker.patch.object(usecase, "_validate_password") - - -async def test__validate_email_is_called( - usecase: UserCreateUseCase, input_: UserCreateInput, mocker: MockerFixture) -> None: - spy = mocker.spy(usecase, "_validate_email") - - await usecase._validate(input_) - - spy.assert_called_once_with(email=input_.email) - - -async def test__validate_password_is_called( - usecase: UserCreateUseCase, input_: UserCreateInput, mocker: MockerFixture) -> None: - spy = mocker.spy(usecase, "_validate_password") - - await usecase._validate(input_) - - spy.assert_called_once_with(password=input_.password) diff --git a/tests/test_users/test_usecases/test_create/test__validate_email.py b/tests/test_users/test_usecases/test_create/test__validate_email.py deleted file mode 100644 index 3844046..0000000 --- a/tests/test_users/test_usecases/test_create/test__validate_email.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest -from pytest_mock import MockerFixture - -from src.exceptions import ValidationError -from src.users.usecases import UserCreateUseCase - - -@pytest.fixture(autouse=True) -def necessary_mocks(mocker: MockerFixture, usecase: UserCreateUseCase) -> None: - mocker.patch.object(usecase, "_is_correct_email_format", return_value=True) - mocker.patch.object(usecase._user_repo, "exists", return_value=False) - - -class TestValidatingEmailFormat: - async def test__is_correct_email_format_called( - self, usecase: UserCreateUseCase, mocker: MockerFixture) -> None: - email = "example@email.com" - spy = mocker.spy(usecase, "_is_correct_email_format") - - await usecase._validate_email(email=email) - - spy.assert_called_once_with(email=email) - - async def test_error_if_email_is_invalid( - self, usecase: UserCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase, "_is_correct_email_format", return_value=False) - - with pytest.raises(ValidationError, match="email: Invalid email") as exc: - await usecase._validate_email(email="example@email.com") - assert exc.value.field == "email" - assert exc.value.error == "Invalid email" - - async def test_no_error_if_email_is_valid( - self, usecase: UserCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase, "_is_correct_email_format", return_value=True) - - try: - await usecase._validate_email(email="example@gmail.com") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") - - -class TestValidatingEmailUniqueness: - async def test_repo_exists_called( - self, usecase: UserCreateUseCase, mocker: MockerFixture) -> None: - email = "example@email.com" - spy = mocker.spy(usecase._user_repo, "exists") - - await usecase._validate_email(email=email) - - spy.assert_called_once_with(email=email) - - async def test_error_if_email_already_exists( - self, usecase: UserCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._user_repo, "exists", return_value=True) - - with pytest.raises(ValidationError, match="email: Email already exists") as exc: - await usecase._validate_email(email="example@email.com") - assert exc.value.error == "Email already exists" - assert exc.value.field == "email" - - async def test_no_error_if_email_does_not_exist( - self, usecase: UserCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._user_repo, "exists", return_value=False) - - try: - await usecase._validate_email(email="example@email.com") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") diff --git a/tests/test_users/test_usecases/test_create/test__validate_password.py b/tests/test_users/test_usecases/test_create/test__validate_password.py deleted file mode 100644 index d52d6cb..0000000 --- a/tests/test_users/test_usecases/test_create/test__validate_password.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from src.exceptions import ValidationError -from src.users.usecases import UserCreateUseCase - - -@pytest.mark.parametrize(("password", "error_message"), [ - ("", "Field is required"), - ("123", "Password must be at least 4 characters"), -]) -def test_error_if_password_is_invalid(usecase: UserCreateUseCase, password: str, error_message: str) -> None: - with pytest.raises(ValidationError, match=f"password: {error_message}") as exc: - usecase._validate_password(password) - assert exc.value.field == "password" - assert exc.value.error == error_message - - -@pytest.mark.parametrize("password", [ - "1234", -]) -def test_no_error_if_password_is_valid(usecase: UserCreateUseCase, password: str) -> None: - try: - usecase._validate_password(password) - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") diff --git a/tests/test_users/test_usecases/test_create/test_execute.py b/tests/test_users/test_usecases/test_create/test_execute.py index 89c3be0..b3681a6 100644 --- a/tests/test_users/test_usecases/test_create/test_execute.py +++ b/tests/test_users/test_usecases/test_create/test_execute.py @@ -1,46 +1,45 @@ import pytest from pytest_mock import MockerFixture -from src.users.entities import User -from src.users.usecases import UserCreateInput, UserCreateUseCase +from src.exceptions import ValidationError +from src.users import User, UserCreateInput, UserCreateUseCase @pytest.fixture(autouse=True) -def necessary_mocks(usecase: UserCreateUseCase, mocker: MockerFixture, user: User) -> None: - mocker.patch.object(usecase, "_validate") - mocker.patch.object(usecase, "_create_domain", return_value=user) +def necessary_mocks(usecase: UserCreateUseCase, mocker: MockerFixture) -> None: + mocker.patch.object(usecase._user_repo, "exists", return_value=False) -async def test__validate_is_called( - usecase: UserCreateUseCase, input_: UserCreateInput, mocker: MockerFixture) -> None: - spy = mocker.spy(usecase, "_validate") +async def test_error_if_email_already_exists( + usecase: UserCreateUseCase, + input_: UserCreateInput, + mocker: MockerFixture, +) -> None: + mocker.patch.object(usecase._user_repo, "exists", return_value=True) - await usecase.execute(input_) - - spy.assert_called_once_with(input_=input_) - - -async def test_create_domain_entity_is_called( - usecase: UserCreateUseCase, input_: UserCreateInput, mocker: MockerFixture) -> None: - spy = mocker.spy(usecase, "_create_domain") - - await usecase.execute(input_) - - spy.assert_called_once_with(input_=input_) + with pytest.raises(ValidationError, match="email: Email already exists"): + await usecase.execute(input_) -async def test_repo_save_is_called( - usecase: UserCreateUseCase, input_: UserCreateInput, mocker: MockerFixture) -> None: +async def test_user_is_saved_to_repository( + usecase: UserCreateUseCase, input_: UserCreateInput, mocker: MockerFixture, +) -> None: spy = mocker.spy(usecase._user_repo, "save") await usecase.execute(input_) + saved_user = User( + id=mocker.ANY, + email=input_.email, + password=usecase._hashing_provider.hash_password.return_value, # type: ignore + created_at=mocker.ANY, + updated_at=mocker.ANY, + ) - spy.assert_called_once_with(user=usecase._create_domain.return_value) # type: ignore + spy.assert_called_once_with(saved_user) -async def test_domain_is_returned(usecase: UserCreateUseCase, input_: UserCreateInput) -> None: +async def test_user_entity_is_returned(usecase: UserCreateUseCase, input_: UserCreateInput) -> None: user = await usecase.execute(input_) - assert user == usecase._create_domain.return_value # type: ignore assert isinstance(user, User) diff --git a/tests/test_users/test_usecases/test_exist/test_execute.py b/tests/test_users/test_usecases/test_exist/test_execute.py index 4e9e4a8..382af8a 100644 --- a/tests/test_users/test_usecases/test_exist/test_execute.py +++ b/tests/test_users/test_usecases/test_exist/test_execute.py @@ -2,11 +2,11 @@ from pytest_mock import MockerFixture -from src.users.usecases import UserExistUseCase +from src.users import UserExistUseCase, UserID async def test_repo_exists_is_called(usecase: UserExistUseCase, mocker: MockerFixture) -> None: - user_id = uuid.uuid4() + user_id = UserID(uuid.uuid4()) spy = mocker.spy(usecase._user_repo, "exist") await usecase.execute(user_id=user_id) @@ -17,7 +17,7 @@ async def test_repo_exists_is_called(usecase: UserExistUseCase, mocker: MockerFi async def test_bool_is_returned(usecase: UserExistUseCase, mocker: MockerFixture) -> None: mocker.patch.object(usecase._user_repo, "exist", return_value=True) - is_exist = await usecase.execute(user_id=uuid.uuid4()) + is_exist = await usecase.execute(user_id=UserID(uuid.uuid4())) - assert is_exist == usecase._user_repo.exist.return_value # type: ignore + assert is_exist == usecase._user_repo.exist.return_value # type: ignore assert isinstance(is_exist, bool) From 1ccb12a1c6be008bceba92708049e5f7c985c0ce Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:46:37 +0200 Subject: [PATCH 07/18] HF-43: Implemented custom data types for `expenses` module --- src/expenses/types.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/expenses/types.py diff --git a/src/expenses/types.py b/src/expenses/types.py new file mode 100644 index 0000000..5f87895 --- /dev/null +++ b/src/expenses/types.py @@ -0,0 +1,41 @@ +import re +from typing import NewType +from uuid import UUID + +OwnerID = NewType("OwnerID", UUID) + + +class ExpensesStorageLink(str): + __slots__ = () + + _GOOGLE_SHEETS_LINK_PATTERN = r"https:\/\/docs\.google\.com\/spreadsheets\/d\/[a-zA-Z0-9_-]+" + _INVALID_LINK_ERROR_TEXT = "Expenses Storage link is invalid" + + def __new__(cls, value: str) -> "ExpensesStorageLink": + if not re.match(pattern=cls._GOOGLE_SHEETS_LINK_PATTERN, string=value): + raise ValueError(cls._INVALID_LINK_ERROR_TEXT) + return super().__new__(cls, value) + + +class Amount(float): + _AMOUNT_MIN_VALUE_ERROR_MESSAGE = "Amount cannot be negative" + + def __new__(cls, value: float) -> "Amount": + if value <= 0: + raise ValueError(cls._AMOUNT_MIN_VALUE_ERROR_MESSAGE) + return super().__new__(cls, value) + + +class Category(str): + __slots__ = () + + _CATEGORY_MAX_LENGTH = 50 + _CATEGORY_MIN_LENGTH = 3 + _CATEGORY_LENGTH_ERROR_MESSAGE = ( + f"Category must be between {_CATEGORY_MIN_LENGTH} and {_CATEGORY_MAX_LENGTH} characters" + ) + + def __new__(cls, value: str) -> "Category": + if not cls._CATEGORY_MIN_LENGTH <= len(value) <= cls._CATEGORY_MAX_LENGTH: + raise ValueError(cls._CATEGORY_LENGTH_ERROR_MESSAGE) + return super().__new__(cls, value) From c1357e7d81f2cbeb7c7ed2d842a571016f180518 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:47:25 +0200 Subject: [PATCH 08/18] HF-43: Updated `Expense` Entity to support new custom data types --- src/expenses/entities/expense.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/expenses/entities/expense.py b/src/expenses/entities/expense.py index a21f321..e49d1de 100644 --- a/src/expenses/entities/expense.py +++ b/src/expenses/entities/expense.py @@ -1,13 +1,14 @@ -import uuid from dataclasses import dataclass, field from datetime import datetime +from src.expenses.types import Amount, Category, ExpensesStorageLink, OwnerID + @dataclass class Expense: - user_id: uuid.UUID - exepenses_storage_link: str - amount: float - category: str + owner_id: OwnerID + expenses_storage_link: ExpensesStorageLink + amount: Amount + category: Category subcategory: str = "" created_at: datetime = field(default_factory=datetime.now) From a829dfadbfeb589199e281465aa4ef67b645a348 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:50:32 +0200 Subject: [PATCH 09/18] HF-43: Implemented custom data types for `storages` module --- src/storages/types.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/storages/types.py diff --git a/src/storages/types.py b/src/storages/types.py new file mode 100644 index 0000000..7bdfc82 --- /dev/null +++ b/src/storages/types.py @@ -0,0 +1,20 @@ +import re +from typing import NewType +from uuid import UUID + + +class StorageLink(str): + __slots__ = () + + _GOOGLE_SHEETS_LINK_PATTERN = r"https:\/\/docs\.google\.com\/spreadsheets\/d\/[a-zA-Z0-9_-]+" + _INVALID_LINK_ERROR_TEXT = "Storage link is invalid" + + def __new__(cls, value: str) -> "StorageLink": + if not re.match(pattern=cls._GOOGLE_SHEETS_LINK_PATTERN, string=value): + raise ValueError(cls._INVALID_LINK_ERROR_TEXT) + return super().__new__(cls, value) + + +StorageID = NewType("StorageID", UUID) + +OwnerID = NewType("OwnerID", UUID) From 008e4fc0c1376583a6985def9ba95cacb51018ba Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:51:02 +0200 Subject: [PATCH 10/18] HF-43: Updated `Storage` entity to support new custom data types --- src/storages/entities/storage.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/storages/entities/storage.py b/src/storages/entities/storage.py index 55e181f..907bfb7 100644 --- a/src/storages/entities/storage.py +++ b/src/storages/entities/storage.py @@ -2,14 +2,16 @@ import uuid from dataclasses import dataclass, field +from src.storages.types import OwnerID, StorageID, StorageLink + @dataclass class Storage: - link: str - expenses_table_link: str - income_table_link: str - user_id: uuid.UUID + link: StorageLink + expenses_table_link: StorageLink + income_table_link: StorageLink + owner_id: OwnerID primary: bool = False - id: uuid.UUID = field(default_factory=uuid.uuid4) + id: StorageID = field(default_factory=lambda: StorageID(uuid.uuid4())) created_at: datetime.datetime = field(default_factory=datetime.datetime.now) From 51822084ee69798ab65605f25b3c02b56c9c41c3 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:51:39 +0200 Subject: [PATCH 11/18] HF-43: Updated `StorageCreateUseCase` to support new custom data types --- src/storages/usecases/create.py | 85 +++++++++++++-------------------- 1 file changed, 33 insertions(+), 52 deletions(-) diff --git a/src/storages/usecases/create.py b/src/storages/usecases/create.py index f0940bf..b3e6899 100644 --- a/src/storages/usecases/create.py +++ b/src/storages/usecases/create.py @@ -1,10 +1,9 @@ import abc -import re -import uuid from dataclasses import dataclass from src.exceptions import ValidationError from src.storages.entities import Storage +from src.storages.types import OwnerID, StorageLink class StorageCreateRepoInterface(abc.ABC): @@ -13,23 +12,27 @@ async def save(self, storage: Storage) -> None: ... @abc.abstractmethod - async def exists(self, user_id: uuid.UUID, primary: bool) -> bool: + async def exists(self, owner_id: OwnerID) -> bool: ... @abc.abstractmethod - async def is_accessable(self, link: str) -> bool: + async def is_accessable(self, link: StorageLink) -> bool: ... @dataclass class StorageCreateInput: - link: str - expenses_table_link: str - income_table_link: str - user_id: uuid.UUID + link: StorageLink + expenses_table_link: StorageLink + income_table_link: StorageLink + owner_id: OwnerID class StorageCreateUseCase: + _STORAGE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT = "Storage link is not accessable" + _EXPENSES_TABLE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT = "Expenses table link is not accessable" + _INCOME_TABLE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT = "Income table link is not accessable" + def __init__( self, storage_repo: StorageCreateRepoInterface, @@ -37,56 +40,34 @@ def __init__( self._storage_repo = storage_repo async def execute(self, input_: StorageCreateInput) -> Storage: - # Validating input await self._validate(input_=input_) - # Creating domain entity - storage = self._create_domain(input_=input_) + storage = Storage( + link=input_.link, + expenses_table_link=input_.expenses_table_link, + income_table_link=input_.income_table_link, + owner_id=input_.owner_id, + ) - # Setting primary flag to Storage - # If User has no primary storage, then set primary flag to True - storage.primary = bool(not await self._storage_repo.exists(user_id=input_.user_id, primary=True)) + if not await self._storage_repo.exists(owner_id=input_.owner_id): + storage.primary = True - # Saving Storage to repository await self._storage_repo.save(storage=storage) return storage async def _validate(self, input_: StorageCreateInput) -> None: - await self._validate_storage_link(link=input_.link) - await self._validate_expenses_table_link(link=input_.expenses_table_link) - await self._validate_income_table_link(link=input_.income_table_link) - - async def _validate_storage_link(self, link: str) -> None: - if not self._is_correct_storage_link_format(link=link): - raise ValidationError(field="link", message="Invalid link to Storage") - - if not await self._storage_repo.is_accessable(link=link): - raise ValidationError(field="link", message="Storage is not accessable") - - async def _validate_expenses_table_link(self, link: str) -> None: - if not self._is_correct_storage_link_format(link=link): - raise ValidationError(field="expenses_table_link", message="Invalid link to Expenses table") - - if not await self._storage_repo.is_accessable(link=link): - raise ValidationError(field="expenses_table_link", message="Expenses table is not accessable") - - async def _validate_income_table_link(self, link: str) -> None: - if not self._is_correct_storage_link_format(link=link): - raise ValidationError(field="income_table_link", message="Invalid link to Income table") - - if not await self._storage_repo.is_accessable(link=link): - raise ValidationError(field="income_table_link", message="Income table is not accessable") - - def _is_correct_storage_link_format(self, link: str) -> bool: - # Check if link is Google Sheets link - pattern = r"https:\/\/docs\.google\.com\/spreadsheets\/d\/[a-zA-Z0-9_-]+" - return re.match(pattern=pattern, string=link) is not None - - def _create_domain(self, input_: StorageCreateInput) -> Storage: - return Storage( - link=input_.link, - expenses_table_link=input_.expenses_table_link, - income_table_link=input_.income_table_link, - user_id=input_.user_id, - ) + if not await self._storage_repo.is_accessable(link=input_.link): + raise ValidationError(field="link", message=self._STORAGE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT) + + if not await self._storage_repo.is_accessable(link=input_.expenses_table_link): + raise ValidationError( + field="expenses_table_link", + message=self._EXPENSES_TABLE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT, + ) + + if not await self._storage_repo.is_accessable(link=input_.income_table_link): + raise ValidationError( + field="income_table_link", + message=self._INCOME_TABLE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT, + ) From 9add6603318d3a694aaf46ff86d6dc2501f9bfb5 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:52:13 +0200 Subject: [PATCH 12/18] HF-43: Updated `StorageGetUseCase`'s filter DTO to support new custom data types --- src/storages/usecases/get.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/storages/usecases/get.py b/src/storages/usecases/get.py index 1002076..8e78a67 100644 --- a/src/storages/usecases/get.py +++ b/src/storages/usecases/get.py @@ -1,14 +1,14 @@ import abc -import uuid from typing import TypedDict from src.storages.entities import Storage +from src.storages.types import OwnerID, StorageID, StorageLink class Filter(TypedDict, total=False): - id: uuid.UUID - user_id: uuid.UUID - link: str + id: StorageID + owner_id: OwnerID + link: StorageLink primary: bool From e61c7474f4931448f728ebb062960579b21dc8cd Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:52:36 +0200 Subject: [PATCH 13/18] HF-43: Added dependencies to `storage.__init__.py` --- src/storages/__init__.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/storages/__init__.py b/src/storages/__init__.py index e69de29..22a4a62 100644 --- a/src/storages/__init__.py +++ b/src/storages/__init__.py @@ -0,0 +1,24 @@ +from .entities import Storage +from .types import OwnerID, StorageID, StorageLink +from .usecases import ( + StorageCreateInput, + StorageCreateRepoInterface, + StorageCreateUseCase, + StorageGetRepoInterface, + StorageGetUseCase, +) + +__all__ = [ + # entities + "Storage", + # types + "OwnerID", + "StorageID", + "StorageLink", + # usecases + "StorageCreateInput", + "StorageCreateRepoInterface", + "StorageCreateUseCase", + "StorageGetRepoInterface", + "StorageGetUseCase", +] From d503841689fb83e58b86ee34a6ed404d0ec033ff Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:52:59 +0200 Subject: [PATCH 14/18] HF-43: Updated `ExpenseCreateUseCase` to support new custom data types and simplified it --- src/expenses/usecases/create.py | 35 +++++++++++++++------------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/expenses/usecases/create.py b/src/expenses/usecases/create.py index fd654be..19c88da 100644 --- a/src/expenses/usecases/create.py +++ b/src/expenses/usecases/create.py @@ -1,12 +1,12 @@ import abc -import uuid from dataclasses import dataclass, field from datetime import datetime from src.expenses.entities import Expense from src.expenses.exceptions import UserShouldHavePrimaryStorageError -from src.storages.entities import Storage -from src.storages.usecases import StorageGetUseCase +from src.expenses.types import Amount, Category, ExpensesStorageLink, OwnerID +from src.storages import OwnerID as StorageOwnerID +from src.storages import StorageGetUseCase class ExpenseCreateRepoInterface(abc.ABC): @@ -17,9 +17,9 @@ async def save(self, expense: Expense) -> None: @dataclass class ExpenseCreateInput: - user_id: uuid.UUID - amount: float - category: str + owner_id: OwnerID + amount: Amount + category: Category subcategory: str = "" created_at: datetime = field(default_factory=datetime.now) @@ -35,24 +35,21 @@ def __init__( async def execute(self, input_: ExpenseCreateInput) -> Expense: # Getting User's Primary Storage - storage = await self._storage_get_usecase.execute(filter_={"user_id": input_.user_id, "primary": True}) + storage = await self._storage_get_usecase.execute( + filter_={"owner_id": StorageOwnerID(input_.owner_id), "primary": True}, + ) if not storage: raise UserShouldHavePrimaryStorageError - # Creating domain entity - expense = self._create_domain(input_=input_, storage=storage) - - # Saving Storage to repository - await self._expense_repo.save(expense=expense) - - return expense - - def _create_domain(self, input_: ExpenseCreateInput, storage: Storage) -> Expense: - return Expense( - user_id=input_.user_id, - exepenses_storage_link=storage.expenses_table_link, + expense = Expense( + owner_id=input_.owner_id, + expenses_storage_link=ExpensesStorageLink(storage.expenses_table_link), amount=input_.amount, category=input_.category, subcategory=input_.subcategory, created_at=input_.created_at, ) + + await self._expense_repo.save(expense) + + return expense From ab2c9dce6063299fa63659af08751175c1ca2068 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:53:43 +0200 Subject: [PATCH 15/18] HF-43: Added Expenses module dependencies to `src/expenses/__init__.py` --- src/expenses/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/expenses/__init__.py b/src/expenses/__init__.py index e69de29..4a1125a 100644 --- a/src/expenses/__init__.py +++ b/src/expenses/__init__.py @@ -0,0 +1,16 @@ +from .entities import Expense +from .exceptions import UserShouldHavePrimaryStorageError +from .types import Amount, Category, ExpensesStorageLink, OwnerID +from .usecases import ExpenseCreateInput, ExpenseCreateRepoInterface, ExpenseCreateUseCase + +__all__ = [ + "Expense", + "UserShouldHavePrimaryStorageError", + "Amount", + "Category", + "ExpensesStorageLink", + "OwnerID", + "ExpenseCreateInput", + "ExpenseCreateRepoInterface", + "ExpenseCreateUseCase", +] From a70d3ce0c1a1173020dd32d662259e2ec8d41f47 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 16:54:04 +0200 Subject: [PATCH 16/18] HF-43: Updated tests --- tests/conftest.py | 30 +++--- tests/test_expenses/test_types/__init__.py | 0 tests/test_expenses/test_types/test_amount.py | 30 ++++++ .../test_expenses/test_types/test_category.py | 29 ++++++ .../test_types/test_expenses_storage_link.py | 32 +++++++ .../test_usecases/test_create/conftest.py | 20 ++-- .../test_create/test__create_domain.py | 21 ----- .../test_usecases/test_create/test_execute.py | 68 ++++---------- tests/test_storages/test_types/__init__.py | 0 .../test_types/test_storage_link.py | 28 ++++++ .../test_usecases/test_create/conftest.py | 14 ++- .../test_create/test__create_domain.py | 17 ---- .../test__is_correct_storage_link_format.py | 15 --- .../test_create/test__validate.py | 49 ---------- .../test__validate_expenses_table_link.py | 73 --------------- .../test__validate_income_table_link.py | 73 --------------- .../test__validate_storage_link.py | 71 -------------- .../test_usecases/test_create/test_execute.py | 93 +++++++++++-------- 18 files changed, 226 insertions(+), 437 deletions(-) create mode 100644 tests/test_expenses/test_types/__init__.py create mode 100644 tests/test_expenses/test_types/test_amount.py create mode 100644 tests/test_expenses/test_types/test_category.py create mode 100644 tests/test_expenses/test_types/test_expenses_storage_link.py delete mode 100644 tests/test_expenses/test_usecases/test_create/test__create_domain.py create mode 100644 tests/test_storages/test_types/__init__.py create mode 100644 tests/test_storages/test_types/test_storage_link.py delete mode 100644 tests/test_storages/test_usecases/test_create/test__create_domain.py delete mode 100644 tests/test_storages/test_usecases/test_create/test__is_correct_storage_link_format.py delete mode 100644 tests/test_storages/test_usecases/test_create/test__validate.py delete mode 100644 tests/test_storages/test_usecases/test_create/test__validate_expenses_table_link.py delete mode 100644 tests/test_storages/test_usecases/test_create/test__validate_income_table_link.py delete mode 100644 tests/test_storages/test_usecases/test_create/test__validate_storage_link.py diff --git a/tests/conftest.py b/tests/conftest.py index 135587b..0ebcdc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,35 +2,37 @@ import pytest -from src.expenses.entities import Expense -from src.storages.entities import Storage -from src.users.entities import User +from src.expenses import Amount, Category, Expense, ExpensesStorageLink +from src.expenses import OwnerID as ExpenseOwnerID +from src.storages import OwnerID as StorageOwnerID +from src.storages import Storage, StorageLink +from src.users import Email, HashedPassword, User @pytest.fixture() def user() -> User: return User( - email="example@email.com", - password=b"some_password", + email=Email("example@email.com"), + password=HashedPassword(b"some_password"), ) @pytest.fixture() def storage() -> Storage: return Storage( - link="https://www.example.com", - expenses_table_link="https://www.example.com/expenses", - income_table_link="https://www.example.com/income", - user_id=uuid.uuid4(), + link=StorageLink("https://docs.google.com/spreadsheets/d/1/edit"), + expenses_table_link=StorageLink("https://docs.google.com/spreadsheets/d/1/edit"), + income_table_link=StorageLink("https://docs.google.com/spreadsheets/d/1/edit"), + owner_id=StorageOwnerID(uuid.uuid4()), ) @pytest.fixture() def expense() -> Expense: return Expense( - user_id=uuid.uuid4(), - exepenses_storage_link="https://www.example.com/expenses", - amount=100, - category="Monthly", - subcategory="Rent", + owner_id=ExpenseOwnerID(uuid.uuid4()), + expenses_storage_link=ExpensesStorageLink("https://www.example.com/expenses"), + amount=Amount(100), + category=Category("Rent"), + subcategory="Cleaning", ) diff --git a/tests/test_expenses/test_types/__init__.py b/tests/test_expenses/test_types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_expenses/test_types/test_amount.py b/tests/test_expenses/test_types/test_amount.py new file mode 100644 index 0000000..304f7ab --- /dev/null +++ b/tests/test_expenses/test_types/test_amount.py @@ -0,0 +1,30 @@ +import pytest + +from src.expenses import Amount + + +@pytest.mark.parametrize( + "amount", + [ + 1, + 100, + ], +) +def test_amount_is_created_successfully(amount: int) -> None: + try: + Amount(amount) + except Exception as e: + msg = f"Unexpected error: {e}" + pytest.fail(msg) + + +@pytest.mark.parametrize( + "amount", + [ + 0, + -1, + ], +) +def test_error_if_amount_is_less_than_min_value(amount: int) -> None: + with pytest.raises(ValueError, match=Amount._AMOUNT_MIN_VALUE_ERROR_MESSAGE): + Amount(amount) diff --git a/tests/test_expenses/test_types/test_category.py b/tests/test_expenses/test_types/test_category.py new file mode 100644 index 0000000..5762d94 --- /dev/null +++ b/tests/test_expenses/test_types/test_category.py @@ -0,0 +1,29 @@ +import pytest + +from src.expenses import Category + + +@pytest.mark.parametrize( + "category_name", + [ + "Food", + "Entertainment", + ], +) +def test_category_is_created_successfully(category_name: str) -> None: + try: + Category(category_name) + except Exception as e: + pytest.fail(f"Unexpected error: {e}") + + +@pytest.mark.parametrize( + "category_name", + [ + "F", + "EntertainmentEntertainmentEntertainmentEntertainmentEntertainmentEntertainmentEntertainment", + ], +) +def test_error_if_category_length_is_invalid(category_name: str) -> None: + with pytest.raises(ValueError, match=Category._CATEGORY_LENGTH_ERROR_MESSAGE): + Category(category_name) diff --git a/tests/test_expenses/test_types/test_expenses_storage_link.py b/tests/test_expenses/test_types/test_expenses_storage_link.py new file mode 100644 index 0000000..751cef8 --- /dev/null +++ b/tests/test_expenses/test_types/test_expenses_storage_link.py @@ -0,0 +1,32 @@ +import pytest + +from src.expenses import ExpensesStorageLink + + +@pytest.mark.parametrize( + "link_address", + [ + "https://docs.google.com/spreadsheets/d/1", + "https://docs.google.com/spreadsheets/d/1/edit", + "https://docs.google.com/spreadsheets/d/1/edit#gid=0", + "https://docs.google.com/spreadsheets/d/1/edit#gid=0&vpid=1", + ], +) +def test_expenses_storage_link_is_created_successfully(link_address: str) -> None: + try: + ExpensesStorageLink(link_address) + except Exception as e: + msg = f"Unexpected error: {e}" + pytest.fail(msg) + + +@pytest.mark.parametrize( + "link_address", + [ + "https://www.google.com", + "https://www.example.com/spreadsheets/d/1", + ], +) +def test_error_if_expenses_storage_link_is_invalid(link_address: str) -> None: + with pytest.raises(ValueError, match=ExpensesStorageLink._INVALID_LINK_ERROR_TEXT): + ExpensesStorageLink(link_address) diff --git a/tests/test_expenses/test_usecases/test_create/conftest.py b/tests/test_expenses/test_usecases/test_create/conftest.py index 6acee18..e1d11ea 100644 --- a/tests/test_expenses/test_usecases/test_create/conftest.py +++ b/tests/test_expenses/test_usecases/test_create/conftest.py @@ -1,13 +1,18 @@ +from datetime import datetime, timezone + import pytest from pytest_mock import MockerFixture -from src.expenses.usecases import ( +from src.expenses import ( + Amount, + Category, ExpenseCreateInput, ExpenseCreateRepoInterface, ExpenseCreateUseCase, + OwnerID, ) -from src.storages.usecases import StorageGetUseCase -from src.users.entities import User +from src.storages import StorageGetUseCase +from src.users import User @pytest.fixture() @@ -21,8 +26,9 @@ def usecase(mocker: MockerFixture) -> ExpenseCreateUseCase: @pytest.fixture() def input_(user: User) -> ExpenseCreateInput: return ExpenseCreateInput( - user_id=user.id, - amount=100, - category="Monthly", - subcategory="Rent", + owner_id=OwnerID(user.id), + amount=Amount(100), + category=Category("Rent"), + subcategory="Cleaning", + created_at=datetime.now(tz=timezone.utc), ) diff --git a/tests/test_expenses/test_usecases/test_create/test__create_domain.py b/tests/test_expenses/test_usecases/test_create/test__create_domain.py deleted file mode 100644 index 6665172..0000000 --- a/tests/test_expenses/test_usecases/test_create/test__create_domain.py +++ /dev/null @@ -1,21 +0,0 @@ -# pyright: reportPrivateUsage=false - -from src.expenses.entities import Expense -from src.expenses.usecases import ExpenseCreateInput, ExpenseCreateUseCase -from src.storages.entities import Storage - - -async def test_domain_entity_is_created( - usecase: ExpenseCreateUseCase, - input_: ExpenseCreateInput, - storage: Storage, -) -> None: - expense = usecase._create_domain(input_=input_, storage=storage) - - assert isinstance(expense, Expense) - assert expense.amount == input_.amount - assert expense.category == input_.category - assert expense.subcategory == input_.subcategory - assert expense.user_id == input_.user_id - assert expense.exepenses_storage_link == storage.expenses_table_link - assert expense.created_at == input_.created_at diff --git a/tests/test_expenses/test_usecases/test_create/test_execute.py b/tests/test_expenses/test_usecases/test_create/test_execute.py index a8e8aff..b22c0f8 100644 --- a/tests/test_expenses/test_usecases/test_create/test_execute.py +++ b/tests/test_expenses/test_usecases/test_create/test_execute.py @@ -3,10 +3,8 @@ import pytest from pytest_mock import MockerFixture -from src.expenses.entities import Expense -from src.expenses.exceptions import UserShouldHavePrimaryStorageError -from src.expenses.usecases import ExpenseCreateInput, ExpenseCreateUseCase -from src.storages.entities import Storage +from src.expenses import Expense, ExpenseCreateInput, ExpenseCreateUseCase, UserShouldHavePrimaryStorageError +from src.storages import Storage @pytest.fixture(autouse=True) @@ -14,74 +12,42 @@ def necessary_mocks( usecase: ExpenseCreateUseCase, mocker: MockerFixture, storage: Storage, - expense: Expense, ) -> None: - mocker.patch.object(usecase, "_create_domain", return_value=expense) mocker.patch.object(usecase._storage_get_usecase, "execute", return_value=storage) -class TestGettingUserPrimaryStorage: - async def test__storage_get_usecase_is_called_to_get_user_primary_storage( - self, - usecase: ExpenseCreateUseCase, - input_: ExpenseCreateInput, - mocker: MockerFixture, - ) -> None: - spy = mocker.spy(usecase._storage_get_usecase, "execute") - - await usecase.execute(input_) - - spy.assert_called_once_with(filter_={"user_id": input_.user_id, "primary": True}) - - async def test_error_if_no_user_primary_storage( - self, - usecase: ExpenseCreateUseCase, - input_: ExpenseCreateInput, - mocker: MockerFixture, - ) -> None: - mocker.patch.object(usecase._storage_get_usecase, "execute", return_value=None) - - with pytest.raises(UserShouldHavePrimaryStorageError): - await usecase.execute(input_) - - async def test_no_error_if_user_has_primary_storage( - self, - usecase: ExpenseCreateUseCase, - input_: ExpenseCreateInput, - ) -> None: - try: - await usecase.execute(input_) - except Exception: - pytest.fail("Unexpected error") - - -async def test_create_domain_entity_is_called( +async def test_error_if_owner_has_no_primary_storage( usecase: ExpenseCreateUseCase, input_: ExpenseCreateInput, mocker: MockerFixture, - storage: Storage, ) -> None: - spy = mocker.spy(usecase, "_create_domain") + mocker.patch.object(usecase._storage_get_usecase, "execute", return_value=None) - await usecase.execute(input_) - - spy.assert_called_once_with(input_=input_, storage=storage) + with pytest.raises(UserShouldHavePrimaryStorageError): + await usecase.execute(input_) -async def test_repo_save_is_called( +async def test_expense_is_saved_to_repository( usecase: ExpenseCreateUseCase, input_: ExpenseCreateInput, mocker: MockerFixture, ) -> None: spy = mocker.spy(usecase._expense_repo, "save") + expected_expense = Expense( + owner_id=input_.owner_id, + expenses_storage_link=usecase._storage_get_usecase.execute.return_value.expenses_table_link, # type: ignore + amount=input_.amount, + category=input_.category, + subcategory=input_.subcategory, + created_at=input_.created_at, + ) await usecase.execute(input_) - spy.assert_called_once_with(expense=usecase._create_domain.return_value) # type: ignore + spy.assert_called_once_with(expected_expense) -async def test_domain_is_returned(usecase: ExpenseCreateUseCase, input_: ExpenseCreateInput) -> None: +async def test_expense_entity_is_returned(usecase: ExpenseCreateUseCase, input_: ExpenseCreateInput) -> None: storage = await usecase.execute(input_) - assert storage == usecase._create_domain.return_value # type: ignore assert isinstance(storage, Expense) diff --git a/tests/test_storages/test_types/__init__.py b/tests/test_storages/test_types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_storages/test_types/test_storage_link.py b/tests/test_storages/test_types/test_storage_link.py new file mode 100644 index 0000000..6b4ad11 --- /dev/null +++ b/tests/test_storages/test_types/test_storage_link.py @@ -0,0 +1,28 @@ +import pytest + +from src.storages import StorageLink + + +@pytest.mark.parametrize( + "link_address", + [ + "https://docs.google.com/spreadsheets/d/1", + "https://docs.google.com/spreadsheets/d/1/edit", + "https://docs.google.com/spreadsheets/d/1/edit#gid=0", + ], +) +def test_storage_link_is_created_successfully(link_address: str) -> None: + StorageLink(link_address) + + +@pytest.mark.parametrize( + "link_address", + [ + "https://www.example.com/spreadsheets/d/1", + "https://reddit.com/neovim", + "https://google.com", + ], +) +def test_error_if_storage_link_is_invalid(link_address: str) -> None: + with pytest.raises(ValueError, match=StorageLink._INVALID_LINK_ERROR_TEXT): + StorageLink(link_address) diff --git a/tests/test_storages/test_usecases/test_create/conftest.py b/tests/test_storages/test_usecases/test_create/conftest.py index 265759f..b932d53 100644 --- a/tests/test_storages/test_usecases/test_create/conftest.py +++ b/tests/test_storages/test_usecases/test_create/conftest.py @@ -3,10 +3,12 @@ import pytest from pytest_mock import MockerFixture -from src.storages.usecases import ( +from src.storages import ( + OwnerID, StorageCreateInput, StorageCreateRepoInterface, StorageCreateUseCase, + StorageLink, ) @@ -19,10 +21,12 @@ def usecase(mocker: MockerFixture) -> StorageCreateUseCase: @pytest.fixture() def input_() -> StorageCreateInput: - link_to_storage = "https://www.fake_storage.com/123456789" + link_to_storage = StorageLink("https://docs.google.com/spreadsheets/d/torirejfklsjdf324234klj4234") + expenses_table_link = StorageLink(f"{link_to_storage}/expenses") + income_table_link = StorageLink(f"{link_to_storage}/income") return StorageCreateInput( link=link_to_storage, - expenses_table_link=f"{link_to_storage}/expenses", - income_table_link=f"{link_to_storage}/income", - user_id=uuid.uuid4(), + expenses_table_link=expenses_table_link, + income_table_link=income_table_link, + owner_id=OwnerID(uuid.uuid4()), ) diff --git a/tests/test_storages/test_usecases/test_create/test__create_domain.py b/tests/test_storages/test_usecases/test_create/test__create_domain.py deleted file mode 100644 index cd0bbf5..0000000 --- a/tests/test_storages/test_usecases/test_create/test__create_domain.py +++ /dev/null @@ -1,17 +0,0 @@ -# pyright: reportPrivateUsage=false - -from src.storages.entities import Storage -from src.storages.usecases import StorageCreateInput, StorageCreateUseCase - - -async def test_domain_entity_is_created(usecase: StorageCreateUseCase, input_: StorageCreateInput) -> None: - storage = usecase._create_domain(input_) - - assert isinstance(storage, Storage) - assert storage.id is not None - assert storage.created_at is not None - assert storage.link == input_.link - assert storage.user_id == input_.user_id - assert storage.primary is False - assert storage.expenses_table_link == input_.expenses_table_link - assert storage.income_table_link == input_.income_table_link diff --git a/tests/test_storages/test_usecases/test_create/test__is_correct_storage_link_format.py b/tests/test_storages/test_usecases/test_create/test__is_correct_storage_link_format.py deleted file mode 100644 index 4a4a168..0000000 --- a/tests/test_storages/test_usecases/test_create/test__is_correct_storage_link_format.py +++ /dev/null @@ -1,15 +0,0 @@ -# pyright: reportPrivateUsage=false - -import pytest - -from src.storages.usecases import StorageCreateUseCase - - -@pytest.mark.parametrize(("storage_link", "valid"), [ - ("some_text", False), - ("", False), - ("https://www.fake_storage.com/123456789", False), - ("https://docs.google.com/spreadsheets/d/18nSstUo7EaCLAVZ", True), -], ids=["some_text", "empty", "random_link", "google_sheets_link"]) -def test_error_if_storage_link_is_invalid(usecase: StorageCreateUseCase, storage_link: str, valid: bool) -> None: - assert usecase._is_correct_storage_link_format(link=storage_link) == valid diff --git a/tests/test_storages/test_usecases/test_create/test__validate.py b/tests/test_storages/test_usecases/test_create/test__validate.py deleted file mode 100644 index b69898d..0000000 --- a/tests/test_storages/test_usecases/test_create/test__validate.py +++ /dev/null @@ -1,49 +0,0 @@ -# pyright: reportPrivateUsage=false - -import pytest -from pytest_mock import MockerFixture - -from src.storages.usecases import StorageCreateInput, StorageCreateUseCase - - -@pytest.fixture(autouse=True) -def necessary_mocks(usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase, "_validate_storage_link") - mocker.patch.object(usecase, "_validate_expenses_table_link") - mocker.patch.object(usecase, "_validate_income_table_link") - - -async def test__validate_storage_link_is_called( - usecase: StorageCreateUseCase, - input_: StorageCreateInput, - mocker: MockerFixture, -) -> None: - spy = mocker.spy(usecase, "_validate_storage_link") - - await usecase._validate(input_) - - spy.assert_called_once_with(link=input_.link) - - -async def test__validate_expenses_table_link_is_called( - usecase: StorageCreateUseCase, - input_: StorageCreateInput, - mocker: MockerFixture, -) -> None: - spy = mocker.spy(usecase, "_validate_expenses_table_link") - - await usecase._validate(input_) - - spy.assert_called_once_with(link=input_.expenses_table_link) - - -async def test__validate_income_table_link_is_called( - usecase: StorageCreateUseCase, - input_: StorageCreateInput, - mocker: MockerFixture, -) -> None: - spy = mocker.spy(usecase, "_validate_income_table_link") - - await usecase._validate(input_) - - spy.assert_called_once_with(link=input_.income_table_link) diff --git a/tests/test_storages/test_usecases/test_create/test__validate_expenses_table_link.py b/tests/test_storages/test_usecases/test_create/test__validate_expenses_table_link.py deleted file mode 100644 index 7863cf5..0000000 --- a/tests/test_storages/test_usecases/test_create/test__validate_expenses_table_link.py +++ /dev/null @@ -1,73 +0,0 @@ -# pyright: reportPrivateUsage=false - -import pytest -from pytest_mock import MockerFixture - -from src.exceptions import ValidationError -from src.storages.usecases import StorageCreateUseCase - - -@pytest.fixture(autouse=True) -def necessary_mocks(mocker: MockerFixture, usecase: StorageCreateUseCase) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=True) - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=True) - - -class TestValidatingStorageLinkFormat: - async def test__is_correct_storage_link_format_called( - self, - usecase: StorageCreateUseCase, - mocker: MockerFixture, - ) -> None: - link = "https://www.fake_storage.com/123456789" - spy = mocker.spy(usecase, "_is_correct_storage_link_format") - - await usecase._validate_expenses_table_link(link=link) - - spy.assert_called_once_with(link=link) - - async def test_error_if_storage_link_is_invalid(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=False) - - with pytest.raises(ValidationError, match="expenses_table_link: Invalid link to Expenses table") as exc: - await usecase._validate_expenses_table_link(link="https://www.fake_storage.com/123456789") - assert exc.value.field == "expenses_table_link" - assert exc.value.error == "Invalid link to Expenses table" - - async def test_no_error_if_storage_link_is_valid( - self, - usecase: StorageCreateUseCase, - mocker: MockerFixture, - ) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=True) - - try: - await usecase._validate_expenses_table_link(link="https://www.fake_storage.com/123456789") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") - - -class TestCheckingTableAccessabilityByLink: - async def test_storage_repo_exists_called(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - link = "https://www.fake_storage.com/123456789" - spy = mocker.spy(usecase._storage_repo, "is_accessable") - - await usecase._validate_expenses_table_link(link=link) - - spy.assert_called_once_with(link=link) - - async def test_error_if_link_is_not_accessable(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=False) - - with pytest.raises(ValidationError, match="expenses_table_link: Expenses table is not accessable") as exc: - await usecase._validate_expenses_table_link(link="https://www.fake_storage.com/123456789") - assert exc.value.field == "expenses_table_link" - assert exc.value.error == "Expenses table is not accessable" - - async def test_no_error_if_link_is_accessable(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=True) - - try: - await usecase._validate_expenses_table_link(link="https://www.fake_storage.com/123456789") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") diff --git a/tests/test_storages/test_usecases/test_create/test__validate_income_table_link.py b/tests/test_storages/test_usecases/test_create/test__validate_income_table_link.py deleted file mode 100644 index 7224058..0000000 --- a/tests/test_storages/test_usecases/test_create/test__validate_income_table_link.py +++ /dev/null @@ -1,73 +0,0 @@ -# pyright: reportPrivateUsage=false - -import pytest -from pytest_mock import MockerFixture - -from src.exceptions import ValidationError -from src.storages.usecases import StorageCreateUseCase - - -@pytest.fixture(autouse=True) -def necessary_mocks(mocker: MockerFixture, usecase: StorageCreateUseCase) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=True) - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=True) - - -class TestValidatingStorageLinkFormat: - async def test__is_correct_storage_link_format_called( - self, - usecase: StorageCreateUseCase, - mocker: MockerFixture, - ) -> None: - link = "https://www.fake_storage.com/123456789" - spy = mocker.spy(usecase, "_is_correct_storage_link_format") - - await usecase._validate_storage_link(link=link) - - spy.assert_called_once_with(link=link) - - async def test_error_if_storage_link_is_invalid(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=False) - - with pytest.raises(ValidationError, match="income_table_link: Invalid link to Income table") as exc: - await usecase._validate_income_table_link(link="https://www.fake_storage.com/123456789") - assert exc.value.field == "income_table_link" - assert exc.value.error == "Invalid link to Income table" - - async def test_no_error_if_storage_link_is_valid( - self, - usecase: StorageCreateUseCase, - mocker: MockerFixture, - ) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=True) - - try: - await usecase._validate_income_table_link(link="https://www.fake_storage.com/123456789") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") - - -class TestCheckingTableAccessabilityByLink: - async def test_storage_repo_exists_called(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - link = "https://www.fake_storage.com/123456789" - spy = mocker.spy(usecase._storage_repo, "is_accessable") - - await usecase._validate_income_table_link(link=link) - - spy.assert_called_once_with(link=link) - - async def test_error_if_link_is_not_accessable(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=False) - - with pytest.raises(ValidationError, match="income_table_link: Income table is not accessable") as exc: - await usecase._validate_income_table_link(link="https://www.fake_storage.com/123456789") - assert exc.value.field == "income_table_link" - assert exc.value.error == "Income table is not accessable" - - async def test_no_error_if_link_is_accessable(self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=True) - - try: - await usecase._validate_storage_link(link="https://www.fake_storage.com/123456789") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") diff --git a/tests/test_storages/test_usecases/test_create/test__validate_storage_link.py b/tests/test_storages/test_usecases/test_create/test__validate_storage_link.py deleted file mode 100644 index e14e2d6..0000000 --- a/tests/test_storages/test_usecases/test_create/test__validate_storage_link.py +++ /dev/null @@ -1,71 +0,0 @@ -# pyright: reportPrivateUsage=false - -import pytest -from pytest_mock import MockerFixture - -from src.exceptions import ValidationError -from src.storages.usecases import StorageCreateUseCase - - -@pytest.fixture(autouse=True) -def necessary_mocks(mocker: MockerFixture, usecase: StorageCreateUseCase) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=True) - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=True) - - -class TestValidatingStorageLinkFormat: - async def test__is_correct_storage_link_format_called( - self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - link = "https://www.fake_storage.com/123456789" - spy = mocker.spy(usecase, "_is_correct_storage_link_format") - - await usecase._validate_storage_link(link=link) - - spy.assert_called_once_with(link=link) - - async def test_error_if_storage_link_is_invalid( - self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=False) - - with pytest.raises(ValidationError, match="link: Invalid link to Storage") as exc: - await usecase._validate_storage_link(link="https://www.fake_storage.com/123456789") - assert exc.value.field == "link" - assert exc.value.error == "Invalid link to Storage" - - async def test_no_error_if_storage_link_is_valid( - self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase, "_is_correct_storage_link_format", return_value=True) - - try: - await usecase._validate_storage_link(link="https://www.fake_storage.com/123456789") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") - - -class TestCheckingStorageAccessabilityByLink: - async def test_storage_repo_exists_called( - self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - link = "https://www.fake_storage.com/123456789" - spy = mocker.spy(usecase._storage_repo, "is_accessable") - - await usecase._validate_storage_link(link=link) - - spy.assert_called_once_with(link=link) - - async def test_error_if_starage_link_is_not_accessable( - self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=False) - - with pytest.raises(ValidationError, match="link: Storage is not accessable") as exc: - await usecase._validate_storage_link(link="https://www.fake_storage.com/123456789") - assert exc.value.field == "link" - assert exc.value.error == "Storage is not accessable" - - async def test_no_error_if_storage_link_is_accessable( - self, usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=True) - - try: - await usecase._validate_storage_link(link="https://www.fake_storage.com/123456789") - except Exception as e: - pytest.fail(f"Unexpected exception raised: {e}") diff --git a/tests/test_storages/test_usecases/test_create/test_execute.py b/tests/test_storages/test_usecases/test_create/test_execute.py index 063ddbf..82b1407 100644 --- a/tests/test_storages/test_usecases/test_create/test_execute.py +++ b/tests/test_storages/test_usecases/test_create/test_execute.py @@ -3,72 +3,83 @@ import pytest from pytest_mock import MockerFixture +from src.exceptions.core import ValidationError from src.storages.entities import Storage from src.storages.usecases import StorageCreateInput, StorageCreateUseCase @pytest.fixture(autouse=True) -def necessary_mocks(usecase: StorageCreateUseCase, mocker: MockerFixture, storage: Storage) -> None: - mocker.patch.object(usecase, "_validate") - mocker.patch.object(usecase, "_create_domain", return_value=storage) +def necessary_mocks(usecase: StorageCreateUseCase, mocker: MockerFixture) -> None: + mocker.patch.object(usecase._storage_repo, "exists", return_value=False) + mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=True) -async def test__validate_is_called( - usecase: StorageCreateUseCase, input_: StorageCreateInput, mocker: MockerFixture) -> None: - spy = mocker.spy(usecase, "_validate") - - await usecase.execute(input_) - - spy.assert_called_once_with(input_=input_) - - -async def test_create_domain_entity_is_called( - usecase: StorageCreateUseCase, input_: StorageCreateInput, mocker: MockerFixture) -> None: - spy = mocker.spy(usecase, "_create_domain") - - await usecase.execute(input_) - - spy.assert_called_once_with(input_=input_) - - -class TestSettingPrimaryFlagToStorage: - async def test__storage_repo_is_called_to_check_if_primary_storage_exists( - self, usecase: StorageCreateUseCase, input_: StorageCreateInput, mocker: MockerFixture) -> None: - spy = mocker.spy(usecase._storage_repo, "exists") +async def test_error_if_storage_link_is_not_accessable( + usecase: StorageCreateUseCase, + input_: StorageCreateInput, + mocker: MockerFixture, +) -> None: + mocker.patch.object(usecase._storage_repo, "is_accessable", return_value=False) + with pytest.raises(ValidationError, match=usecase._STORAGE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT): await usecase.execute(input_) - spy.assert_called_once_with(user_id=input_.user_id, primary=True) - async def test_setting_primary_to_true_if_primary_storage_does_not_exist( - self, usecase: StorageCreateUseCase, input_: StorageCreateInput, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "exists", return_value=False) +async def test_error_if_expenses_table_link_is_not_accessable( + usecase: StorageCreateUseCase, + input_: StorageCreateInput, + mocker: MockerFixture, +) -> None: + mocker.patch.object(usecase._storage_repo, "is_accessable", side_effect=[True, False]) - storage = await usecase.execute(input_) - - assert storage.primary is True + with pytest.raises(ValidationError, match=usecase._EXPENSES_TABLE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT): + await usecase.execute(input_) - async def test_setting_primary_to_false_if_primary_storage_exists( - self, usecase: StorageCreateUseCase, input_: StorageCreateInput, mocker: MockerFixture) -> None: - mocker.patch.object(usecase._storage_repo, "exists", return_value=True) - storage = await usecase.execute(input_) +async def test_error_if_income_table_link_is_not_accessable( + usecase: StorageCreateUseCase, + input_: StorageCreateInput, + mocker: MockerFixture, +) -> None: + mocker.patch.object(usecase._storage_repo, "is_accessable", side_effect=[True, True, False]) - assert storage.primary is False + with pytest.raises(ValidationError, match=usecase._INCOME_TABLE_LINK_IS_NOT_ACCESSABLE_ERROR_TEXT): + await usecase.execute(input_) -async def test_repo_save_is_called( - usecase: StorageCreateUseCase, input_: StorageCreateInput, mocker: MockerFixture) -> None: +@pytest.mark.parametrize( + ("user_has_storages", "primary_flag"), + [ + (True, False), + (False, True), + ], +) +async def test_storage_is_saved_to_repository( + usecase: StorageCreateUseCase, + input_: StorageCreateInput, + mocker: MockerFixture, + user_has_storages: bool, + primary_flag: bool, +) -> None: + mocker.patch.object(usecase._storage_repo, "exists", return_value=user_has_storages) spy = mocker.spy(usecase._storage_repo, "save") await usecase.execute(input_) + storage = Storage( + id=mocker.ANY, + created_at=mocker.ANY, + primary=primary_flag, + link=input_.link, + expenses_table_link=input_.expenses_table_link, + income_table_link=input_.income_table_link, + owner_id=input_.owner_id, + ) - spy.assert_called_once_with(storage=usecase._create_domain.return_value) # type: ignore + spy.assert_called_once_with(storage=storage) async def test_domain_is_returned(usecase: StorageCreateUseCase, input_: StorageCreateInput) -> None: storage = await usecase.execute(input_) - assert storage == usecase._create_domain.return_value # type: ignore assert isinstance(storage, Storage) From 29be87bdd5892fb9622751c228d001723a7c59cd Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 17:13:15 +0200 Subject: [PATCH 17/18] HF-43: Minor fixes after review --- src/expenses/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/expenses/__init__.py b/src/expenses/__init__.py index 4a1125a..af86874 100644 --- a/src/expenses/__init__.py +++ b/src/expenses/__init__.py @@ -4,12 +4,16 @@ from .usecases import ExpenseCreateInput, ExpenseCreateRepoInterface, ExpenseCreateUseCase __all__ = [ + # entities "Expense", + # exceptions "UserShouldHavePrimaryStorageError", + # types "Amount", "Category", "ExpensesStorageLink", "OwnerID", + # usecases "ExpenseCreateInput", "ExpenseCreateRepoInterface", "ExpenseCreateUseCase", From 808daf8b9259288610e17abadd6423463b5afc20 Mon Sep 17 00:00:00 2001 From: Enes Gulakhmet Date: Wed, 28 Feb 2024 17:24:00 +0200 Subject: [PATCH 18/18] HF-43: Renamed `StorageGetUseCase`'s `Filter` to `StorageGetFilter` --- src/storages/__init__.py | 2 ++ src/storages/usecases/__init__.py | 2 ++ src/storages/usecases/get.py | 6 +++--- .../test_usecases/test_get/test_execute.py | 13 ++++++++----- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/storages/__init__.py b/src/storages/__init__.py index 22a4a62..50d6a30 100644 --- a/src/storages/__init__.py +++ b/src/storages/__init__.py @@ -4,6 +4,7 @@ StorageCreateInput, StorageCreateRepoInterface, StorageCreateUseCase, + StorageGetFilter, StorageGetRepoInterface, StorageGetUseCase, ) @@ -19,6 +20,7 @@ "StorageCreateInput", "StorageCreateRepoInterface", "StorageCreateUseCase", + "StorageGetFilter", "StorageGetRepoInterface", "StorageGetUseCase", ] diff --git a/src/storages/usecases/__init__.py b/src/storages/usecases/__init__.py index efd2b0a..4a9d8ca 100644 --- a/src/storages/usecases/__init__.py +++ b/src/storages/usecases/__init__.py @@ -4,6 +4,7 @@ StorageCreateUseCase, ) from .get import ( + StorageGetFilter, StorageGetRepoInterface, StorageGetUseCase, ) @@ -14,4 +15,5 @@ "StorageCreateUseCase", "StorageGetRepoInterface", "StorageGetUseCase", + "StorageGetFilter", ] diff --git a/src/storages/usecases/get.py b/src/storages/usecases/get.py index 8e78a67..679572e 100644 --- a/src/storages/usecases/get.py +++ b/src/storages/usecases/get.py @@ -5,7 +5,7 @@ from src.storages.types import OwnerID, StorageID, StorageLink -class Filter(TypedDict, total=False): +class StorageGetFilter(TypedDict, total=False): id: StorageID owner_id: OwnerID link: StorageLink @@ -14,7 +14,7 @@ class Filter(TypedDict, total=False): class StorageGetRepoInterface(abc.ABC): @abc.abstractmethod - async def get(self, filter_: Filter) -> Storage: + async def get(self, filter_: StorageGetFilter) -> Storage: ... @@ -22,5 +22,5 @@ class StorageGetUseCase: def __init__(self, storage_repo: StorageGetRepoInterface) -> None: self._storage_repo = storage_repo - async def execute(self, filter_: Filter) -> Storage: + async def execute(self, filter_: StorageGetFilter) -> Storage: return await self._storage_repo.get(filter_=filter_) diff --git a/tests/test_storages/test_usecases/test_get/test_execute.py b/tests/test_storages/test_usecases/test_get/test_execute.py index 3392ef7..82fdc6b 100644 --- a/tests/test_storages/test_usecases/test_get/test_execute.py +++ b/tests/test_storages/test_usecases/test_get/test_execute.py @@ -4,8 +4,7 @@ from pytest_mock import MockerFixture -from src.storages.entities import Storage -from src.storages.usecases import StorageGetUseCase +from src.storages import OwnerID, Storage, StorageGetFilter, StorageGetUseCase, StorageID async def test__storage_repo_is_called_to_get_storage(usecase: StorageGetUseCase, mocker: MockerFixture) -> None: @@ -13,14 +12,18 @@ async def test__storage_repo_is_called_to_get_storage(usecase: StorageGetUseCase storage_id = uuid.uuid4() primary = True spy = mocker.spy(usecase._storage_repo, "get") + filter_: StorageGetFilter = {"owner_id": OwnerID(user_id), "id": StorageID(storage_id), "primary": primary} - await usecase.execute(filter_={"user_id": user_id, "id": storage_id, "primary": primary}) + await usecase.execute(filter_=filter_) - spy.assert_called_once_with(filter_={"user_id": user_id, "id": storage_id, "primary": primary}) + spy.assert_called_once_with(filter_=filter_) async def test__storage_repo_response_is_returned( - usecase: StorageGetUseCase, mocker: MockerFixture, storage: Storage) -> None: + usecase: StorageGetUseCase, + mocker: MockerFixture, + storage: Storage, +) -> None: mocker.patch.object(usecase._storage_repo, "get", return_value=storage) result = await usecase.execute(filter_={"primary": True})