From 8f0d0ffc5c44ca75d2576cb57f62372c09cc5a09 Mon Sep 17 00:00:00 2001 From: Sergey Vanyushkin Date: Sun, 26 Apr 2026 10:29:02 +0300 Subject: [PATCH] fix: fix woodpecker pipelines --- .coverage | Bin 53248 -> 0 bytes .gitignore | 10 +- .woodpecker/deploy.yaml | 18 --- .woodpecker/git-check.yaml | 49 -------- .woodpecker/lints.yaml | 18 ++- .woodpecker/tests.yaml | 15 ++- .woodpecker/types.yaml | 16 ++- app/__pycache__/__init__.cpython-313.pyc | Bin 139 -> 0 bytes app/__pycache__/main.cpython-313.pyc | Bin 876 -> 0 bytes app/common/error_handler.py | 10 +- app/main.py | 7 +- pyproject.toml | 12 ++ scripts/update_readme.py | 2 +- .../test_app_run.cpython-313-pytest-9.0.3.pyc | Bin 5440 -> 0 bytes tests/api/conftest.py | 6 +- tests/conftest.py | 8 +- tests/e2e/conftest.py | 7 +- tests/integration/conftest.py | 6 +- tests/test_app_run.py | 12 +- tests/unit/conftest.py | 4 +- tests/unit/test_config.py | 52 +++++++++ tests/unit/test_error_handler.py | 110 ++++++++++++++++++ tests/unit/test_exceptions.py | 87 ++++++++++++++ tests/unit/test_unit_app_run.py | 10 +- 24 files changed, 349 insertions(+), 110 deletions(-) delete mode 100644 .coverage delete mode 100644 .woodpecker/deploy.yaml delete mode 100644 .woodpecker/git-check.yaml delete mode 100644 app/__pycache__/__init__.cpython-313.pyc delete mode 100644 app/__pycache__/main.cpython-313.pyc delete mode 100644 tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_error_handler.py create mode 100644 tests/unit/test_exceptions.py diff --git a/.coverage b/.coverage deleted file mode 100644 index 7e68d20af959b5942b697a700d297ceb68ebd4a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI)U2oe|7zc1W?vf@=abcRGQcXQK(3RzdXhOVQK!Hk3OoFk2*bUxEa*`NgJGGsy zMM9`!()bLB>+SQvC*X2dOi1GbjSHT~$IeUFY`d$L{jD~2>~oIK^E+>G()P;bOKz-0 zHwZl$iwnk@VVcH=LKucor0-Sw&b9*WEM-sVr#ZDhZ?|aFf7n~Kzc$MGZw&j})mQCW zxmW&s<+t)X#jjS}LbX_@6WAaC0SG`~VFV5@mkX8kb@SGbv1|=g9Llx|_2cTtU+(T) z-4$1NKDx9k^f7U+BxtME#EuAqn_{R!(RBw(xPHfN%h>gMBJQg!^(ayuUbE;Jty-M0 zp5*5}$L&zASoNrgVd#1?JP_ZjgKCl>y;`jHV|{=MQEt!Y6yjW(eOrX8t3u_sRitZi z&h2cMwtxPqRH&RjZQfByOfqcK*UC&ZbYK$7#V!@p4#E!Cz7@2fauAHkYs7&VxPB6*AGxs`_(JWg_9#}JV*{)*TDB41bTm}fbq?sd&Y50oN@tS!n4D?y zG;^0K=LZ?}<>nCr#%x)1BqR!cw7f=8So$M+DDH{C#RB z2tG0y0!J2tXRrQH#(nX4-T5$8eCt%c^8T5573V5AO@7SZUdts_{>4i4J(~3S+nT8| zADLA7Gj&kAAqO;@45b^=kfk|TuKzFr$B$Hj=Q=|&={!{e*c2a__Q11_U? zV=Jjswz%*#f0YJZHoj=b^Q7NN*DGFzb*VJ>^Wfy`=t+b!iY*$H5e?BJWoCt@XL-iT z%C8T}giG~gM-O*yl&KPL_SIv>>DA~H8LQa!l;9*uWs;6{Q%0g4Dmt02rAfs|tbept zx|mPtOg4w=XtuYkC-og}VyDTEe3!h;FXaw5%|hk;dGm0Tj$l1GIT5`Q@MIQi`ZPDw zvyPmcbh6KBmZq0T)18aKrwsY2z5>sPy4hQrE=!fZKq_tX4ZdkVGUy8%1Rwwb2tWV= z5P$##AOHafKmY=ZCt#U5v%vTNIr~q;{)-;4K>z{}fB*y_009U<00Izz00bcLTndzP z)>%9IwU9U0OlxC3{T+a}>&*-Gw@MUM&c1Kh_w9e4%K%Yn2tWV=5P$##AOHafKmY;| zfB*!>0%hy0nY|5=D_I-G^j!e``Tt$R{@uPiPK+H0KmY;|fB*y_009U<00Izz00d4z zU^{0S*Bbr6Q;nf}q1_18FlY=9rZ6J+c`00Izz00bZa0SG_<0uX=z1Qt-hqTdu)<, fix/" - fi - - check-commit-message: - image: alpine/git - commands: - - MSG=$(git log -1 --pretty=%s) - - | - echo "Last commit: $MSG" - if echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z]"; then - echo "✓ Commit message follows convention" - else - echo "❌ Invalid commit message format" - echo " Expected: : " - echo " Types: feat, fix, docs, style, refactor, test, chore" - exit 1 - fi - - check-cache-files: - image: python:3.13 - commands: - - | - CACHE_FILES=$(git diff --name-only HEAD~1 | grep -E "__pycache__|\.pyc$" || true) - if [ -n "$CACHE_FILES" ]; then - echo "❌ Cache files in commit:" - echo "$CACHE_FILES" - exit 1 - fi - - echo "✓ No cache files in commit" diff --git a/.woodpecker/lints.yaml b/.woodpecker/lints.yaml index 9e48e81..0fd7ee4 100644 --- a/.woodpecker/lints.yaml +++ b/.woodpecker/lints.yaml @@ -1,6 +1,6 @@ when: - - event: [push, pull_request] - branch: main + - event: [pull_request] + branch: dev steps: - name: lint @@ -11,3 +11,17 @@ steps: - uv run black --check . - uv run ruff check . - uv run isort --check-only . + - comment: + image: mcs94/gitea-comment + settings: + gitea_address: https://git.pi3c.ru + gitea_token: + from_secret: gitea_token + comment: > + ✅ Build ${CI_BUILD_EVENT} of `${CI_REPO_NAME}` has status `${CI_BUILD_STATUS}`. + + 📝 Commit by ${CI_COMMIT_AUTHOR} on `${CI_COMMIT_BRANCH}`: + + `${CI_COMMIT_MESSAGE}` + + 🌐 ${CI_BUILD_LINK} diff --git a/.woodpecker/tests.yaml b/.woodpecker/tests.yaml index d1e1421..3636171 100644 --- a/.woodpecker/tests.yaml +++ b/.woodpecker/tests.yaml @@ -1,6 +1,6 @@ when: - event: [push, pull_request] - branch: main + branch: dev steps: - name: tests @@ -9,4 +9,17 @@ steps: - pip install uv - uv sync --no-dev --group tests - uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing + - comment: + image: mcs94/gitea-comment + settings: + gitea_address: https://git.pi3c.ru + gitea_token: + from_secret: gitea_token + comment: > + ✅ Build ${CI_BUILD_EVENT} of `${CI_REPO_NAME}` has status `${CI_BUILD_STATUS}`. + 📝 Commit by ${CI_COMMIT_AUTHOR} on `${CI_COMMIT_BRANCH}`: + + `${CI_COMMIT_MESSAGE}` + + 🌐 ${CI_BUILD_LINK} diff --git a/.woodpecker/types.yaml b/.woodpecker/types.yaml index 6e2cebe..6b37e09 100644 --- a/.woodpecker/types.yaml +++ b/.woodpecker/types.yaml @@ -1,6 +1,6 @@ when: - event: [push, pull_request] - branch: main + branch: dev steps: - name: types @@ -9,3 +9,17 @@ steps: - pip install uv - uv sync --no-dev --only-group types - uv run mypy . + - comment: + image: mcs94/gitea-comment + settings: + gitea_address: https://git.pi3c.ru + gitea_token: + from_secret: gitea_token + comment: > + ✅ Build ${CI_BUILD_EVENT} of `${CI_REPO_NAME}` has status `${CI_BUILD_STATUS}`. + + 📝 Commit by ${CI_COMMIT_AUTHOR} on `${CI_COMMIT_BRANCH}`: + + `${CI_COMMIT_MESSAGE}` + + 🌐 ${CI_BUILD_LINK} diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e6ff2942773d1a0c63e04272b11b4db25c42ae7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 139 zcmey&%ge<81XFdMW`gL)AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekklk6v1k{HmJaOu32V2u0x|B z&9JDU+k@31?kd4*bJ;wL;2F$-ZD4=;blXr=skNe8{<5kv%DjqH-iGwESasAWM(CeT zpzFeq%EZUpUn?`+2j8mW-K7G9@E;IyYoG-b2t*0mo(_Qq5rAn}B@AsYa}AffVjiEl z33h3DWyicetAF9W%lKkEH~Ubz*o@OG7fGx#3U6(PeypOLtF1@_E22Oh=;l&w>*#1J`=_|FV! zMEU|Mk7PUWywecYop!vw)e7#aNld1PN*|%?amLsoTswmCL%7r%0pmi?V65`NEC82+ i4+1Ba!Oomi7+YjL3&iN#odZ7kU0AOddmO}+hW`VD^{hMq diff --git a/app/common/error_handler.py b/app/common/error_handler.py index 5811ed4..572d526 100644 --- a/app/common/error_handler.py +++ b/app/common/error_handler.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timezone from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -11,7 +11,7 @@ from app.core.exceptions import AppException class ErrorResponse(BaseModel): status_code: int message: str - details: dict | None = None + details: dict[str, str] | None = None timestamp: str @@ -21,7 +21,7 @@ async def app_exception_handler(request: Request, exc: AppException) -> JSONResp content={ "status_code": exc.status_code, "message": exc.message, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) @@ -32,12 +32,12 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe content={ "status_code": exc.status_code, "message": str(exc.detail), - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }, ) -def register_exception_handlers(app: FastAPI): +def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler( AppException, app_exception_handler, # type: ignore[arg-type] diff --git a/app/main.py b/app/main.py index 70dbf6e..eeeb8c2 100644 --- a/app/main.py +++ b/app/main.py @@ -1,20 +1,21 @@ from contextlib import asynccontextmanager +from typing import AsyncGenerator import uvicorn from fastapi import FastAPI @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield -def app_factory(): +def app_factory() -> FastAPI: app = FastAPI(lifespan=lifespan) return app -def main(): +def main() -> None: uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000) diff --git a/pyproject.toml b/pyproject.toml index d1b1794..26c61c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,8 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "fastapi>=0.136.0", + "pydantic>=2.13.2", + "pydantic-settings>=2.14.0", "uvicorn>=0.44.0", ] @@ -18,6 +20,7 @@ dev = [ ] tests = [ "httpx>=0.28.1", + "mimesis>=19.1.0", "pytest>=9.0.3", "pytest-asyncio>=1.3.0", "pytest-cov>=7.1.0", @@ -28,6 +31,7 @@ lints = [ "isort>=8.0.1", ] types = [ + "mimesis>=19.1.0", "mypy>=1.20.1", ] @@ -39,3 +43,11 @@ pythonpath = "." testpaths = "tests" xfail_strict = true +[tool.mypy] +strict = true +plugins = ["pydantic.mypy"] + +[tool.isort] +profile = "black" +filter_files = true + diff --git a/scripts/update_readme.py b/scripts/update_readme.py index 0e1fb70..97e1be3 100644 --- a/scripts/update_readme.py +++ b/scripts/update_readme.py @@ -342,7 +342,7 @@ def update_readme(check_only: bool = False) -> bool: return False -def main(): +def main() -> None: import sys check_only = "--check" in sys.argv diff --git a/tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc deleted file mode 100644 index a8580fb0ddc6f94de8e3d6e7214a571be6758e15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5440 zcmdT|UrZdw8K1q~+xw3p2K)y(-k21zDL(!i+bvbB{NW4=t+?>gi*#Ba2b^-;UT2Ra zP$6NGDvg|oR7#{gMCx;;JRr+%P->G`9(RQeXq70Hnm**s36&zXedzbi?(8mb4MvJ8 zb?_LS&|jT2o=~jG%6Y) zg^WrxtT-hym7ydw78#Aw=xB__7+)NVk0xk>(b8CQG(}U44v#gAo}ed28)@Tc6Kzt+ zEAqtC+_3+h=$HbkV446+59- ztNCfIP}AvxEzN1AO2Ku7eXuj%gm;fhgtQX{x-I8AMLee~B*(j!eqhxKrl|$u;oeiAGNO z-wPxc(YmV@;v-~yTLvcKugq7e)l9lntXYBRRL5UeTO2%8^jXXuV z2rGwZbZUr_B;gS<1rakHQpkCzRUtF-uy~$K3q#_xpho8318>YfTFd5!dE2~eEnBOQ zu5>BZ+t!M?q40%F0sd|CFHWg-(^^%`&&&-FUpDWWpMmIo>y~*>G1sh{<~n44$7;OK z*&qw#3X{F*OWy;=eR%Jfe@5FNbT7R~HNz;cSP(i2v3llwro|QH@UI^r}V| zV+?T_AY7S2&H}<^^|@;Os-ec0()oO$40iJQ_!4C8c*!VLjG9&{6lnx{gyNE_Mr^UV z(1S@YCViOnV={=z5GGf~)g;9cWg1M^j(L^S1eDmxXR8bO8}p@d4Q$wQSubd1!;S;D zRy1mPjoPR4#;iVHp32i=mF8#4`VFm|uV`~csLRl%i^#ScuCab8>6I5K)v0|lKdsX_ zt(GsoQ7yyPQ-cyUY>Tkt*aB=h8>|Tq(^-Ekhc${3^ca!^he_A(tAt0(d73cX*{>O z66u}@@C#6v_h95AQrJ5SLwOXEm<(bGjtZLingOWbr!Fe+(8Wa5v*ZzVP{9!> zB6nyn;x-v7@U=a3;Ta=<3Jz(zsNgtWwgLL^?bO9O{@Uz$2Wx#mtFCvxmWu+8;}s&R zBfRpF9N$?y?+{V>+I77dtoTo#K$C{U4M7>_FV2U?u8X$l1w!7 zI{dHRKT&5#)thpsxy;oJA^P*Y)exm%HQ)pE!odfCeq>S_5VFxp!LMf^H0UBXKtabD zrT&x_o3FYc3zshIou+<8G9Nvx#hSGTbAu zn3InYb29gae_i|~0{&{Ak=4}vI}jy5fO-OlHU$99hWRN#lr`%Q){0_&4mf2Eu+Tc7 zp?3kjY?yZ)CNLlaKn9rWz;sWEPyv)Pc!NM^4N4uX#5c?I;$Q&r3~=ZI#D7CPfMo!* zFmOR39ti^=szh-mqbW=lwRM6+D8ZD|3)-6G-)9+*)tTPxa=?!Mw5=U$( zsB^`@-Ic*3MWG7%)#$)C|A0?>fQ>CME&=RWCSS)}Ze3h{?u$gr`-P8(wi4~8)V`f) z-;wtHEvX&hjmz7gbJ*Kb`}>8UL?Gu$d^{AC;JJrVyIIfTUlR?hSKqm6N_{(t4lB_C z#3!9rqHhNn?7t=TnTd{1Iz5I%Je2x=4=~CVviuwtKr67oQT2oz;sF})c%T@_dF*Ye zZx6bUEH=XoOyg}ydj7RiL8p~8;LHMas`q$U=Xqe&!#WCqJqFRxfydtk@y-L!>Q^K< z6@^uKz5=2@uqvQGig_CjI-kOO8>TXxl$3yj&Q#yOKH#y)T*nn)jTOL=$69fz_4Lb& zk)AaBm7az57OsvW-eu9OZq#h4s?*xIDpI`7u}?WuiUoXeDzC$FCI5P?jWp=di|>6xKm-!$x1VBkWeL zq_blPo0P1@e%59e=ItN;K2 diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 685d9c2..5301bc6 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -1,12 +1,14 @@ # API test fixtures # Provides: httpx.AsyncClient, authentication helpers, test API data +from typing import AsyncGenerator + import pytest from httpx import ASGITransport, AsyncClient @pytest.fixture -async def client(): +async def client() -> AsyncGenerator[AsyncClient, None]: """Create async HTTP client for API testing.""" from app.main import app_factory @@ -17,6 +19,6 @@ async def client(): @pytest.fixture -def auth_headers(): +def auth_headers() -> dict[str, str]: """Return mock authentication headers.""" return {"Authorization": "Bearer test_token"} diff --git a/tests/conftest.py b/tests/conftest.py index 162ddb9..3dd919e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ +from asyncio import AbstractEventLoopPolicy, DefaultEventLoopPolicy + import pytest @pytest.fixture(scope="session") -def event_loop_policy(): - import asyncio - - return asyncio.DefaultEventLoopPolicy() +def event_loop_policy() -> AbstractEventLoopPolicy: + return DefaultEventLoopPolicy() diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 296b0c5..a449049 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,11 +1,14 @@ # E2E test fixtures # Provides: full application state, end-to-end workflows, cleanup +from typing import AsyncGenerator + import pytest +from fastapi import FastAPI @pytest.fixture -async def e2e_app(): +async def e2e_app() -> AsyncGenerator[FastAPI, None]: """Create full application instance for E2E testing.""" from app.main import app_factory @@ -15,7 +18,7 @@ async def e2e_app(): @pytest.fixture -def e2e_user_data(): +def e2e_user_data() -> dict[str, str]: """Generate realistic user data for E2E scenarios.""" from mimesis import Person diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5c40ae8..eeca67a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,18 +1,20 @@ # Integration test fixtures # Provides: test database, external service connections +from typing import Generator + import pytest @pytest.fixture -def test_db_connection(): +def test_db_connection() -> Generator[str, None, None]: """Create test database connection.""" # TODO: Implement when DB is added to project yield "test_db" @pytest.fixture -def cleanup_db(): +def cleanup_db() -> Generator[None, None, None]: """Cleanup database after test.""" yield # TODO: Implement cleanup logic diff --git a/tests/test_app_run.py b/tests/test_app_run.py index 386e900..d1f0aac 100644 --- a/tests/test_app_run.py +++ b/tests/test_app_run.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from fastapi import FastAPI @@ -10,19 +10,19 @@ from app.main import app_factory, lifespan, main @pytest.mark.asyncio -async def test_lifespan(): +async def test_lifespan() -> None: """Проверяет, что lifespan является корректным асинхронным контекстным менеджером.""" app = FastAPI() # Проверяем, что lifespan - это asynccontextmanager - assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) + assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type] # Проверяем, что контекстный менеджер работает (ничего не ломается) async with lifespan(app): pass # Просто убеждаемся, что yield отрабатывает -def test_app_factory(): - """Проверяет, что app_factory создаёт правильное приложение FastAPI с переданным lifespan.""" +def test_app_factory() -> None: + """Проверяет, что app_factory создаёт приложение FastAPI с переданным lifespan.""" app = app_factory() assert isinstance(app, FastAPI) # Проверяем, что lifespan приложения установлен на функцию lifespan @@ -30,7 +30,7 @@ def test_app_factory(): @patch("app.main.uvicorn.run") -def test_main(mock_uvicorn_run): +def test_main(mock_uvicorn_run: Mock) -> None: """Проверяет, что main вызывает uvicorn.run с правильными параметрами.""" main() mock_uvicorn_run.assert_called_once_with( diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 401c50e..d9acc02 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -7,12 +7,12 @@ import pytest @pytest.fixture -def mock_service(): +def mock_service() -> Mock: """Create a mock service for unit testing.""" return Mock() @pytest.fixture -def mock_async_service(): +def mock_async_service() -> AsyncMock: """Create an async mock service for unit testing.""" return AsyncMock() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..61ffcb8 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,52 @@ +import os +from unittest.mock import patch + +from app.core.config import Settings + + +class TestSettings: + def test_default_values(self) -> None: + settings = Settings() + assert settings.app_name == "Blog API" + assert settings.debug is False + assert settings.host == "0.0.0.0" + assert settings.port == 8000 + assert settings.database_url is None + + def test_custom_values(self) -> None: + settings = Settings( + app_name="Test API", + debug=True, + host="localhost", + port=9000, + database_url="postgresql://test", + ) + assert settings.app_name == "Test API" + assert settings.debug is True + assert settings.host == "localhost" + assert settings.port == 9000 + assert settings.database_url == "postgresql://test" + + def test_settings_from_env(self) -> None: + with patch.dict( + os.environ, + { + "APP_NAME": "Env API", + "DEBUG": "true", + "HOST": "127.0.0.1", + "PORT": "8080", + "DATABASE_URL": "sqlite:///test.db", + }, + ): + settings = Settings() + assert settings.app_name == "Env API" + assert settings.debug is True + assert settings.host == "127.0.0.1" + assert settings.port == 8080 + assert settings.database_url == "sqlite:///test.db" + + def test_global_settings_instance(self) -> None: + from app.core.config import settings + + assert isinstance(settings, Settings) + assert settings.app_name == "Blog API" diff --git a/tests/unit/test_error_handler.py b/tests/unit/test_error_handler.py new file mode 100644 index 0000000..5b4c621 --- /dev/null +++ b/tests/unit/test_error_handler.py @@ -0,0 +1,110 @@ +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest +from fastapi import FastAPI, Request +from starlette.exceptions import HTTPException + +from app.common.error_handler import ( + ErrorResponse, + app_exception_handler, + http_exception_handler, + register_exception_handlers, +) +from app.core.exceptions import AppException + + +class TestErrorResponse: + def test_error_response_creation(self) -> None: + response = ErrorResponse( + status_code=400, + message="Bad request", + timestamp=datetime.now(timezone.utc).isoformat(), + ) + assert response.status_code == 400 + assert response.message == "Bad request" + assert response.details is None + + def test_error_response_with_details(self) -> None: + response = ErrorResponse( + status_code=500, + message="Internal error", + details={"field": "value"}, + timestamp=datetime.now(timezone.utc).isoformat(), + ) + assert response.status_code == 500 + assert response.message == "Internal error" + assert response.details == {"field": "value"} + + +class TestAppExceptionHandler: + @pytest.mark.asyncio + async def test_app_exception_handler(self) -> None: + request = Mock(spec=Request) + exc = AppException(message="Test error", status_code=400) + + response = await app_exception_handler(request, exc) + + assert response.status_code == 400 + body = bytes(response.body).decode() + assert "Test error" in body + assert "400" in body + + @pytest.mark.asyncio + async def test_app_exception_handler_content(self) -> None: + request = Mock(spec=Request) + exc = AppException(message="Validation error", status_code=422) + + with patch("app.common.error_handler.datetime") as mock_datetime: + mock_datetime.now.return_value.isoformat.return_value = ( + "2024-01-01T00:00:00" + ) + + response = await app_exception_handler(request, exc) + + content = bytes(response.body).decode() + assert "Validation error" in content + assert "422" in content + assert "2024-01-01T00:00:00" in content + + +class TestHttpExceptionHandler: + @pytest.mark.asyncio + async def test_http_exception_handler(self) -> None: + request = Mock(spec=Request) + exc = HTTPException(status_code=404, detail="Not found") + + response = await http_exception_handler(request, exc) + + assert response.status_code == 404 + body = bytes(response.body).decode() + assert "Not found" in body + assert "404" in body + + @pytest.mark.asyncio + async def test_http_exception_handler_content(self) -> None: + request = Mock(spec=Request) + exc = HTTPException(status_code=503, detail="Service unavailable") + + with patch("app.common.error_handler.datetime") as mock_datetime: + mock_datetime.now.return_value.isoformat.return_value = ( + "2024-01-01T12:00:00" + ) + + response = await http_exception_handler(request, exc) + + content = bytes(response.body).decode() + assert "Service unavailable" in content + assert "503" in content + assert "2024-01-01T12:00:00" in content + + +class TestRegisterExceptionHandlers: + def test_register_exception_handlers(self) -> None: + app = Mock(spec=FastAPI) + + register_exception_handlers(app) + + assert app.add_exception_handler.call_count == 2 + app.add_exception_handler.assert_any_call(AppException, app_exception_handler) + app.add_exception_handler.assert_any_call(HTTPException, http_exception_handler) diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..73c928b --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,87 @@ +from app.core.exceptions import ( + AppException, + ForbiddenError, + NotFoundError, + UnauthorizedError, + ValidationError, +) + + +class TestAppException: + def test_default_status_code(self) -> None: + exc = AppException(message="Test error") + assert exc.message == "Test error" + assert exc.status_code == 500 + + def test_custom_status_code(self) -> None: + exc = AppException(message="Custom error", status_code=400) + assert exc.message == "Custom error" + assert exc.status_code == 400 + + def test_string_representation(self) -> None: + exc = AppException(message="Error message") + assert str(exc) == "Error message" + + +class TestNotFoundError: + def test_default_message(self) -> None: + exc = NotFoundError() + assert exc.message == "Resource not found" + assert exc.status_code == 404 + + def test_custom_message(self) -> None: + exc = NotFoundError(message="Item not found") + assert exc.message == "Item not found" + assert exc.status_code == 404 + + def test_is_subclass_of_app_exception(self) -> None: + exc = NotFoundError() + assert isinstance(exc, AppException) + + +class TestValidationError: + def test_default_message(self) -> None: + exc = ValidationError() + assert exc.message == "Validation failed" + assert exc.status_code == 400 + + def test_custom_message(self) -> None: + exc = ValidationError(message="Invalid email format") + assert exc.message == "Invalid email format" + assert exc.status_code == 400 + + def test_is_subclass_of_app_exception(self) -> None: + exc = ValidationError() + assert isinstance(exc, AppException) + + +class TestUnauthorizedError: + def test_default_message(self) -> None: + exc = UnauthorizedError() + assert exc.message == "Unauthorized" + assert exc.status_code == 401 + + def test_custom_message(self) -> None: + exc = UnauthorizedError(message="Invalid credentials") + assert exc.message == "Invalid credentials" + assert exc.status_code == 401 + + def test_is_subclass_of_app_exception(self) -> None: + exc = UnauthorizedError() + assert isinstance(exc, AppException) + + +class TestForbiddenError: + def test_default_message(self) -> None: + exc = ForbiddenError() + assert exc.message == "Forbidden" + assert exc.status_code == 403 + + def test_custom_message(self) -> None: + exc = ForbiddenError(message="Access denied") + assert exc.message == "Access denied" + assert exc.status_code == 403 + + def test_is_subclass_of_app_exception(self) -> None: + exc = ForbiddenError() + assert isinstance(exc, AppException) diff --git a/tests/unit/test_unit_app_run.py b/tests/unit/test_unit_app_run.py index b6f4094..c4d3c1f 100644 --- a/tests/unit/test_unit_app_run.py +++ b/tests/unit/test_unit_app_run.py @@ -1,5 +1,5 @@ from contextlib import asynccontextmanager -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from fastapi import FastAPI @@ -8,22 +8,22 @@ from app.main import app_factory, lifespan, main @pytest.mark.asyncio -async def test_lifespan(): +async def test_lifespan() -> None: app = FastAPI() - assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) + assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type] async with lifespan(app): pass -def test_app_factory(): +def test_app_factory() -> None: app = app_factory() assert isinstance(app, FastAPI) assert app.router.lifespan_context == lifespan @patch("app.main.uvicorn.run") -def test_main(mock_uvicorn_run): +def test_main(mock_uvicorn_run: Mock) -> None: main() mock_uvicorn_run.assert_called_once_with( app_factory,