feat: implement blog project with CI pipeline
All checks were successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful
ci/woodpecker/pr/comment_pr Pipeline was successful

This commit is contained in:
2026-04-25 19:15:34 +03:00
parent 9c3b44b561
commit 2e930ffbe5
46 changed files with 1315 additions and 15 deletions

0
tests/unit/__init__.py Normal file
View File

18
tests/unit/conftest.py Normal file
View File

@@ -0,0 +1,18 @@
# Unit test fixtures
# Provides: mocks, stubs, isolated test data
from unittest.mock import AsyncMock, Mock
import pytest
@pytest.fixture
def mock_service() -> Mock:
"""Create a mock service for unit testing."""
return Mock()
@pytest.fixture
def mock_async_service() -> AsyncMock:
"""Create an async mock service for unit testing."""
return AsyncMock()

52
tests/unit/test_config.py Normal file
View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,33 @@
from contextlib import asynccontextmanager
from unittest.mock import Mock, patch
import pytest
from fastapi import FastAPI
from app.main import app_factory, lifespan, main
@pytest.mark.asyncio
async def test_lifespan() -> None:
app = FastAPI()
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
async with lifespan(app):
pass
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: Mock) -> None:
main()
mock_uvicorn_run.assert_called_once_with(
app_factory,
factory=True,
host="0.0.0.0",
port=8000,
)