From 926c125255cea4867bc19467e655d5bf5e00b1a0 Mon Sep 17 00:00:00 2001 From: Artem Kashaev Date: Thu, 27 Nov 2025 14:24:57 +0500 Subject: [PATCH] feat: add pytest and pytest-asyncio dependencies for testing --- pyproject.toml | 2 + tests/conftest.py | 10 ++++ tests/services/test_auth_service.py | 87 +++++++++++++++++++++++++++++ uv.lock | 59 +++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/services/test_auth_service.py diff --git a/pyproject.toml b/pyproject.toml index f87f1ef..56ec6ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,4 +19,6 @@ dev = [ "isort>=7.0.0", "mypy>=1.18.2", "ruff>=0.14.6", + "pytest>=8.3.3", + "pytest-asyncio>=0.25.0", ] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dac7509 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Pytest configuration & shared fixtures.""" +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure project root is on sys.path so that `app` package imports succeed during tests. +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) diff --git a/tests/services/test_auth_service.py b/tests/services/test_auth_service.py new file mode 100644 index 0000000..b31d359 --- /dev/null +++ b/tests/services/test_auth_service.py @@ -0,0 +1,87 @@ +"""Unit tests for AuthService.""" +from __future__ import annotations + +from typing import cast +from unittest.mock import MagicMock + +import pytest # type: ignore[import-not-found] +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import JWTService, PasswordHasher +from app.models.user import User +from app.repositories.user_repo import UserRepository +from app.services.auth_service import AuthService, InvalidCredentialsError + + +class StubUserRepository(UserRepository): + """In-memory stand-in for UserRepository.""" + + def __init__(self, user: User | None) -> None: + super().__init__(session=MagicMock(spec=AsyncSession)) + self._user = user + + async def get_by_email(self, email: str) -> User | None: # pragma: no cover - helper + if self._user and self._user.email == email: + return self._user + return None + + +@pytest.fixture() +def password_hasher() -> PasswordHasher: + class DummyPasswordHasher: + def hash(self, password: str) -> str: # pragma: no cover - trivial + return f"hashed::{password}" + + def verify(self, password: str, hashed_password: str) -> bool: # pragma: no cover - trivial + return hashed_password == self.hash(password) + + return cast(PasswordHasher, DummyPasswordHasher()) + + +@pytest.fixture() +def jwt_service() -> JWTService: + return JWTService(secret_key="unit-test-secret", algorithm="HS256") + + +@pytest.mark.asyncio +async def test_authenticate_success(password_hasher: PasswordHasher, jwt_service: JWTService) -> None: + hashed = password_hasher.hash("StrongPass123") + user = User(email="user@example.com", hashed_password=hashed, name="Alice", is_active=True) + user.id = 1 + repo = StubUserRepository(user) + service = AuthService(repo, password_hasher, jwt_service) + + authenticated = await service.authenticate("user@example.com", "StrongPass123") + + assert authenticated is user + + +@pytest.mark.asyncio +async def test_authenticate_invalid_credentials( + password_hasher: PasswordHasher, + jwt_service: JWTService, +) -> None: + hashed = password_hasher.hash("StrongPass123") + user = User(email="user@example.com", hashed_password=hashed, name="Alice", is_active=True) + user.id = 1 + repo = StubUserRepository(user) + service = AuthService(repo, password_hasher, jwt_service) + + with pytest.raises(InvalidCredentialsError): + await service.authenticate("user@example.com", "wrong-pass") + + +def test_create_access_token_contains_user_claims( + password_hasher: PasswordHasher, + jwt_service: JWTService, +) -> None: + user = User(email="user@example.com", hashed_password="hashed", name="Alice", is_active=True) + user.id = 42 + service = AuthService(StubUserRepository(user), password_hasher, jwt_service) + + token = service.create_access_token(user) + payload = jwt_service.decode(token.access_token) + + assert payload["sub"] == str(user.id) + assert payload["email"] == user.email + assert token.expires_in > 0 diff --git a/uv.lock b/uv.lock index 67bb6c0..77b269a 100644 --- a/uv.lock +++ b/uv.lock @@ -354,6 +354,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "isort" version = "7.0.0" @@ -467,6 +476,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "passlib" version = "1.7.4" @@ -490,6 +508,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.12.4" @@ -581,6 +608,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -790,6 +845,8 @@ dependencies = [ dev = [ { name = "isort" }, { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -808,6 +865,8 @@ requires-dist = [ dev = [ { name = "isort", specifier = ">=7.0.0" }, { name = "mypy", specifier = ">=1.18.2" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "ruff", specifier = ">=0.14.6" }, ]