feat: implement blog project with CI pipeline
This commit is contained in:
Binary file not shown.
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
24
tests/api/conftest.py
Normal file
24
tests/api/conftest.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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() -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create async HTTP client for API testing."""
|
||||
from app.main import app_factory
|
||||
|
||||
app = app_factory()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers() -> dict[str, str]:
|
||||
"""Return mock authentication headers."""
|
||||
return {"Authorization": "Bearer test_token"}
|
||||
8
tests/conftest.py
Normal file
8
tests/conftest.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from asyncio import AbstractEventLoopPolicy, DefaultEventLoopPolicy
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> AbstractEventLoopPolicy:
|
||||
return DefaultEventLoopPolicy()
|
||||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
30
tests/e2e/conftest.py
Normal file
30
tests/e2e/conftest.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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() -> AsyncGenerator[FastAPI, None]:
|
||||
"""Create full application instance for E2E testing."""
|
||||
from app.main import app_factory
|
||||
|
||||
app = app_factory()
|
||||
yield app
|
||||
# Cleanup after E2E test
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def e2e_user_data() -> dict[str, str]:
|
||||
"""Generate realistic user data for E2E scenarios."""
|
||||
from mimesis import Person
|
||||
|
||||
person = Person()
|
||||
return {
|
||||
"username": person.username(),
|
||||
"email": person.email(),
|
||||
"password": "SecurePass123!",
|
||||
}
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
20
tests/integration/conftest.py
Normal file
20
tests/integration/conftest.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Integration test fixtures
|
||||
# Provides: test database, external service connections
|
||||
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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() -> Generator[None, None, None]:
|
||||
"""Cleanup database after test."""
|
||||
yield
|
||||
# TODO: Implement cleanup logic
|
||||
@@ -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(
|
||||
|
||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
18
tests/unit/conftest.py
Normal file
18
tests/unit/conftest.py
Normal 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
52
tests/unit/test_config.py
Normal 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"
|
||||
110
tests/unit/test_error_handler.py
Normal file
110
tests/unit/test_error_handler.py
Normal 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)
|
||||
87
tests/unit/test_exceptions.py
Normal file
87
tests/unit/test_exceptions.py
Normal 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)
|
||||
33
tests/unit/test_unit_app_run.py
Normal file
33
tests/unit/test_unit_app_run.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user