diff --git a/.coverage b/.coverage deleted file mode 100644 index 7e68d20..0000000 Binary files a/.coverage and /dev/null differ diff --git a/.gitignore b/.gitignore index 198e7d3..31dce73 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ site/ # Python cache (ignore all) -__pycache__/ +**/__pycache__/ *.py[cod] *$py.class *.pyc @@ -11,14 +11,10 @@ __pycache__/ # opencode skills (agent-only) .opencode/ AGENTS.md -.github/PULL_REQUEST_TEMPLATE.md +.github/ # Scripts (except hooks) -scripts/* -!scripts/commit-msg -!scripts/post-commit -!scripts/update_readme.py -!scripts/clean_cache.sh +scripts/ # IDE .idea/ diff --git a/.woodpecker/deploy.yaml b/.woodpecker/deploy.yaml deleted file mode 100644 index 15d4cc2..0000000 --- a/.woodpecker/deploy.yaml +++ /dev/null @@ -1,18 +0,0 @@ -when: - event: [push] - branch: main - -steps: - deploy: - image: python:3.13 - commands: - - echo "🚀 Deploying to production..." - - echo "Branch: main" - - echo "Commit: $(git rev-parse --short HEAD)" - # Add your deployment commands here - # Example: - # - uv sync --frozen - # - uv run python -m app.main & - - echo "✅ Deployment complete" - when: - status: [success] diff --git a/.woodpecker/git-check.yaml b/.woodpecker/git-check.yaml deleted file mode 100644 index 1519091..0000000 --- a/.woodpecker/git-check.yaml +++ /dev/null @@ -1,49 +0,0 @@ -when: - event: [push, pull_request] - -steps: - check-branch: - image: alpine/git - commands: - - BRANCH=$(git rev-parse --abbrev-ref HEAD) - - | - echo "Branch: $BRANCH" - if [ "$BRANCH" = "main" ]; then - echo "✓ Production branch (protected)" - elif [ "$BRANCH" = "dev" ]; then - echo "✓ Development branch (protected)" - elif echo "$BRANCH" | grep -qE "^feature/"; then - echo "✓ Feature branch" - elif echo "$BRANCH" | grep -qE "^(fix|hotfix|release)/"; then - echo "✓ Special branch" - else - echo "⚠️ Unusual branch name: $BRANCH" - echo " Recommended: feature/, 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 e6ff294..0000000 Binary files a/app/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 83b9a5f..0000000 Binary files a/app/__pycache__/main.cpython-313.pyc and /dev/null differ 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 a8580fb..0000000 Binary files a/tests/__pycache__/test_app_run.cpython-313-pytest-9.0.3.pyc and /dev/null differ 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,