refactor: migrate to DDD architecture with Dishka DI
Complete architectural refactoring from simple MVC to Clean Architecture/DDD pattern: Domain Layer: - Add entities (Post, BaseEntity) with business logic - Add value objects (Title, Content, Slug) with validation - Add repository interfaces (PostRepository) - Add domain exceptions Application Layer: - Add use cases (CreatePost, GetPost, UpdatePost, DeletePost, ListPosts, PublishPost) - Add DTOs for data transfer - Add TransactionManager interface Infrastructure Layer: - Add SQLAlchemy models and async database connection - Add SQLAlchemyPostRepository implementation - Add Dishka DI container with providers - Add error handlers and middleware Presentation Layer: - Add FastAPI routes with Dishka integration - Add Pydantic schemas - Add dependency injection using FromDishka[T] Other Changes: - Remove old flat structure (api/, common/, core/, modules/) - Add hatchling build system for package scripts - Add blog CLI command - Update AGENTS.md with new architecture docs - All 48 tests passing, mypy clean, ruff clean
This commit is contained in:
@@ -1,17 +1,16 @@
|
||||
# API test fixtures
|
||||
# Provides: httpx.AsyncClient, authentication helpers, test API data
|
||||
"""API test fixtures."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app_factory
|
||||
|
||||
|
||||
@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:
|
||||
@@ -21,4 +20,4 @@ async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
@pytest.fixture
|
||||
def auth_headers() -> dict[str, str]:
|
||||
"""Return mock authentication headers."""
|
||||
return {"Authorization": "Bearer test_token"}
|
||||
return {"Authorization": "Bearer test_token", "X-User-Id": "user-123"}
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
# Integration test fixtures
|
||||
# Provides: test database, external service connections
|
||||
"""Integration test fixtures."""
|
||||
|
||||
from typing import Generator
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.infrastructure.database.models import Base
|
||||
|
||||
# Use in-memory SQLite for tests
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@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(scope="session")
|
||||
def engine() -> AsyncEngine:
|
||||
"""Create test engine."""
|
||||
return create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cleanup_db() -> Generator[None, None, None]:
|
||||
"""Cleanup database after test."""
|
||||
@pytest.fixture(scope="session")
|
||||
def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
"""Create test session factory."""
|
||||
return async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None, None]:
|
||||
"""Setup database tables for each test."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# TODO: Implement cleanup logic
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create database session for testing."""
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Предполагаем, что тестируемый модуль называется `myapp`
|
||||
# Импортируем из него нужные объекты
|
||||
from app.main import app_factory, lifespan, main
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan() -> None:
|
||||
"""Проверяет, что lifespan является корректным асинхронным контекстным менеджером."""
|
||||
app = FastAPI()
|
||||
# Проверяем, что lifespan - это asynccontextmanager
|
||||
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
|
||||
|
||||
# Проверяем, что контекстный менеджер работает (ничего не ломается)
|
||||
async with lifespan(app):
|
||||
pass # Просто убеждаемся, что yield отрабатывает
|
||||
|
||||
|
||||
def test_app_factory() -> None:
|
||||
"""Проверяет, что app_factory создаёт приложение FastAPI с переданным lifespan."""
|
||||
app = app_factory()
|
||||
assert isinstance(app, FastAPI)
|
||||
# Проверяем, что lifespan приложения установлен на функцию lifespan
|
||||
assert app.router.lifespan_context == lifespan
|
||||
|
||||
|
||||
@patch("app.main.uvicorn.run")
|
||||
def test_main(mock_uvicorn_run: Mock) -> None:
|
||||
"""Проверяет, что main вызывает uvicorn.run с правильными параметрами."""
|
||||
main()
|
||||
mock_uvicorn_run.assert_called_once_with(
|
||||
app_factory,
|
||||
factory=True,
|
||||
host="0.0.0.0",
|
||||
port=8000, # Предполагаемый порт (в коде обрезано, но обычно 8000)
|
||||
)
|
||||
0
tests/unit/application/__init__.py
Normal file
0
tests/unit/application/__init__.py
Normal file
273
tests/unit/application/test_use_cases.py
Normal file
273
tests/unit/application/test_use_cases.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Tests for application use cases."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.dtos.post import CreatePostDTO, UpdatePostDTO
|
||||
from app.application.use_cases import (
|
||||
CreatePostUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
from app.domain.entities import Post
|
||||
from app.domain.exceptions import (
|
||||
AlreadyExistsException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_post() -> Post:
|
||||
"""Create a test post."""
|
||||
return Post.create(
|
||||
title_str="Test Post",
|
||||
content_str="This is test content with enough characters",
|
||||
author_id="user-123",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
|
||||
class TestCreatePostUseCase:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_post_success(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test successful post creation."""
|
||||
# Setup
|
||||
mock_post_repository.slug_exists = AsyncMock(return_value=False)
|
||||
mock_post_repository.add = AsyncMock()
|
||||
|
||||
use_case = CreatePostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
dto = CreatePostDTO(
|
||||
title="New Post",
|
||||
content="Content with enough characters",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = await use_case.execute(dto)
|
||||
|
||||
# Assert
|
||||
assert result.title == "New Post"
|
||||
assert result.author_id == "user-123"
|
||||
mock_post_repository.add.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_post_slug_exists(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test post creation with existing slug."""
|
||||
# Setup
|
||||
mock_post_repository.slug_exists = AsyncMock(return_value=True)
|
||||
|
||||
use_case = CreatePostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
dto = CreatePostDTO(
|
||||
title="Existing Post",
|
||||
content="Content with enough characters",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(AlreadyExistsException):
|
||||
await use_case.execute(dto)
|
||||
|
||||
mock_post_repository.add.assert_not_called()
|
||||
mock_transaction_manager.commit.assert_not_called()
|
||||
|
||||
|
||||
class TestGetPostUseCase:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_post_by_id_success(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test successful get post by ID."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
|
||||
use_case = GetPostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
# Execute
|
||||
result = await use_case.by_id(test_post.id)
|
||||
|
||||
# Assert
|
||||
assert result.id == test_post.id
|
||||
assert result.title == test_post.title.value
|
||||
mock_post_repository.get_by_id.assert_called_once_with(test_post.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_post_by_id_not_found(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test get post by ID when not found."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = GetPostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
post_id = uuid4()
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.by_id(post_id)
|
||||
|
||||
|
||||
class TestUpdatePostUseCase:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_post_success(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test successful post update."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
dto = UpdatePostDTO(title="Updated Title")
|
||||
|
||||
# Execute
|
||||
result = await use_case.execute(test_post.id, dto, "user-123")
|
||||
|
||||
# Assert
|
||||
assert result.title == "Updated Title"
|
||||
mock_post_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_post_not_found(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
) -> None:
|
||||
"""Test update post when not found."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
dto = UpdatePostDTO(title="Updated Title")
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(NotFoundException):
|
||||
await use_case.execute(uuid4(), dto, "user-123")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_post_forbidden(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test update post by different user."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
|
||||
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
dto = UpdatePostDTO(title="Updated Title")
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(ForbiddenException):
|
||||
await use_case.execute(test_post.id, dto, "other-user")
|
||||
|
||||
|
||||
class TestDeletePostUseCase:
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_post_success(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test successful post deletion."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
mock_post_repository.delete = AsyncMock()
|
||||
|
||||
use_case = DeletePostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
# Execute
|
||||
await use_case.execute(test_post.id, "user-123")
|
||||
|
||||
# Assert
|
||||
mock_post_repository.delete.assert_called_once_with(test_post.id)
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_post_forbidden(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test delete post by different user."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
|
||||
use_case = DeletePostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(ForbiddenException):
|
||||
await use_case.execute(test_post.id, "other-user")
|
||||
|
||||
|
||||
class TestPublishPostUseCase:
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_post_success(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test successful post publish."""
|
||||
# Setup
|
||||
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
|
||||
mock_post_repository.update = AsyncMock()
|
||||
|
||||
use_case = PublishPostUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
# Execute
|
||||
result = await use_case.publish(test_post.id, "user-123")
|
||||
|
||||
# Assert
|
||||
assert result.published is True
|
||||
mock_post_repository.update.assert_called_once()
|
||||
mock_transaction_manager.commit.assert_called_once()
|
||||
|
||||
|
||||
class TestListPostsUseCase:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_posts(
|
||||
self,
|
||||
mock_post_repository: Mock,
|
||||
mock_transaction_manager: Mock,
|
||||
test_post: Post,
|
||||
) -> None:
|
||||
"""Test listing all posts."""
|
||||
# Setup
|
||||
mock_post_repository.get_all = AsyncMock(return_value=[test_post])
|
||||
|
||||
use_case = ListPostsUseCase(mock_post_repository, mock_transaction_manager)
|
||||
|
||||
# Execute
|
||||
results = await use_case.all_posts()
|
||||
|
||||
# Assert
|
||||
assert len(results) == 1
|
||||
assert results[0].id == test_post.id
|
||||
mock_post_repository.get_all.assert_called_once()
|
||||
@@ -1,18 +1,29 @@
|
||||
# Unit test fixtures
|
||||
# Provides: mocks, stubs, isolated test data
|
||||
"""Unit test fixtures."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.repositories import PostRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service() -> Mock:
|
||||
"""Create a mock service for unit testing."""
|
||||
return Mock()
|
||||
def mock_post_repository() -> Mock:
|
||||
"""Create a mock post repository."""
|
||||
return Mock(spec=PostRepository)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_transaction_manager() -> Mock:
|
||||
"""Create a mock transaction manager."""
|
||||
tx_manager = Mock(spec=TransactionManager)
|
||||
tx_manager.commit = AsyncMock()
|
||||
tx_manager.rollback = AsyncMock()
|
||||
return tx_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_async_service() -> AsyncMock:
|
||||
"""Create an async mock service for unit testing."""
|
||||
"""Create an async mock service."""
|
||||
return AsyncMock()
|
||||
|
||||
0
tests/unit/domain/__init__.py
Normal file
0
tests/unit/domain/__init__.py
Normal file
128
tests/unit/domain/test_entities.py
Normal file
128
tests/unit/domain/test_entities.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Tests for domain entities."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities import Post
|
||||
from app.domain.value_objects import Content, Title
|
||||
|
||||
|
||||
class TestPost:
|
||||
def test_post_creation(self) -> None:
|
||||
"""Test creating a post."""
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
tags=["test", "python"],
|
||||
)
|
||||
|
||||
assert isinstance(post.id, UUID)
|
||||
assert post.title.value == "Test Title"
|
||||
assert post.content.value == "This is test content that is long enough"
|
||||
assert post.slug.value == "test-title"
|
||||
assert post.author_id == "user-123"
|
||||
assert post.published is False
|
||||
assert post.tags == ["test", "python"]
|
||||
|
||||
def test_post_publish(self) -> None:
|
||||
"""Test publishing a post."""
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
assert post.published is False
|
||||
post.publish()
|
||||
assert post.published is True
|
||||
|
||||
def test_post_unpublish(self) -> None:
|
||||
"""Test unpublishing a post."""
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
post.publish()
|
||||
assert post.published is True
|
||||
post.unpublish()
|
||||
assert post.published is False
|
||||
|
||||
def test_post_update_title(self) -> None:
|
||||
"""Test updating post title."""
|
||||
post = Post.create(
|
||||
title_str="Original Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
old_updated_at = post.updated_at
|
||||
post.update_title(Title("New Title"))
|
||||
|
||||
assert post.title.value == "New Title"
|
||||
assert post.slug.value == "new-title"
|
||||
assert post.updated_at > old_updated_at
|
||||
|
||||
def test_post_update_content(self) -> None:
|
||||
"""Test updating post content."""
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
old_updated_at = post.updated_at
|
||||
post.update_content(Content("Updated content that is also long enough"))
|
||||
|
||||
assert post.content.value == "Updated content that is also long enough"
|
||||
assert post.updated_at > old_updated_at
|
||||
|
||||
def test_post_add_tag(self) -> None:
|
||||
"""Test adding a tag."""
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
)
|
||||
|
||||
post.add_tag("python")
|
||||
assert "python" in post.tags
|
||||
|
||||
# Adding same tag twice should not duplicate
|
||||
post.add_tag("python")
|
||||
assert post.tags.count("python") == 1
|
||||
|
||||
def test_post_remove_tag(self) -> None:
|
||||
"""Test removing a tag."""
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
tags=["python", "fastapi"],
|
||||
)
|
||||
|
||||
post.remove_tag("python")
|
||||
assert "python" not in post.tags
|
||||
assert "fastapi" in post.tags
|
||||
|
||||
def test_post_to_dict(self) -> None:
|
||||
"""Test converting post to dict."""
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="This is test content that is long enough",
|
||||
author_id="user-123",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
data = post.to_dict()
|
||||
|
||||
assert data["title"] == "Test Title"
|
||||
assert data["content"] == "This is test content that is long enough"
|
||||
assert data["slug"] == "test-title"
|
||||
assert data["author_id"] == "user-123"
|
||||
assert data["published"] is False
|
||||
assert data["tags"] == ["test"]
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
assert "updated_at" in data
|
||||
48
tests/unit/domain/test_exceptions.py
Normal file
48
tests/unit/domain/test_exceptions.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for domain exceptions."""
|
||||
|
||||
from app.domain.exceptions import (
|
||||
AlreadyExistsException,
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
|
||||
class TestDomainExceptions:
|
||||
def test_base_exception(self) -> None:
|
||||
"""Test base domain exception."""
|
||||
exc = DomainException("Something went wrong")
|
||||
assert exc.message == "Something went wrong"
|
||||
assert str(exc) == "Something went wrong"
|
||||
|
||||
def test_validation_exception(self) -> None:
|
||||
"""Test validation exception."""
|
||||
exc = ValidationException("Invalid input")
|
||||
assert isinstance(exc, DomainException)
|
||||
assert exc.message == "Invalid input"
|
||||
|
||||
def test_not_found_exception(self) -> None:
|
||||
"""Test not found exception."""
|
||||
exc = NotFoundException("Resource not found")
|
||||
assert isinstance(exc, DomainException)
|
||||
assert exc.message == "Resource not found"
|
||||
|
||||
def test_already_exists_exception(self) -> None:
|
||||
"""Test already exists exception."""
|
||||
exc = AlreadyExistsException("Already exists")
|
||||
assert isinstance(exc, DomainException)
|
||||
assert exc.message == "Already exists"
|
||||
|
||||
def test_unauthorized_exception(self) -> None:
|
||||
"""Test unauthorized exception."""
|
||||
exc = UnauthorizedException("Unauthorized")
|
||||
assert isinstance(exc, DomainException)
|
||||
assert exc.message == "Unauthorized"
|
||||
|
||||
def test_forbidden_exception(self) -> None:
|
||||
"""Test forbidden exception."""
|
||||
exc = ForbiddenException("Forbidden")
|
||||
assert isinstance(exc, DomainException)
|
||||
assert exc.message == "Forbidden"
|
||||
93
tests/unit/domain/test_value_objects.py
Normal file
93
tests/unit/domain/test_value_objects.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Tests for domain value objects."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.value_objects import Content, Slug, Title
|
||||
|
||||
|
||||
class TestTitle:
|
||||
def test_valid_title(self) -> None:
|
||||
"""Test creating a valid title."""
|
||||
title = Title("Valid Title")
|
||||
assert title.value == "Valid Title"
|
||||
|
||||
def test_title_too_short(self) -> None:
|
||||
"""Test title that is too short."""
|
||||
with pytest.raises(ValueError, match="at least"):
|
||||
Title("ab")
|
||||
|
||||
def test_title_too_long(self) -> None:
|
||||
"""Test title that is too long."""
|
||||
with pytest.raises(ValueError, match="at most"):
|
||||
Title("a" * 201)
|
||||
|
||||
def test_title_empty(self) -> None:
|
||||
"""Test empty title."""
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
Title(" ")
|
||||
|
||||
def test_title_not_string(self) -> None:
|
||||
"""Test non-string title."""
|
||||
with pytest.raises(ValueError, match="string"):
|
||||
Title(123) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestContent:
|
||||
def test_valid_content(self) -> None:
|
||||
"""Test creating valid content."""
|
||||
content = Content("This is valid content with enough characters")
|
||||
assert content.value == "This is valid content with enough characters"
|
||||
|
||||
def test_content_too_short(self) -> None:
|
||||
"""Test content that is too short."""
|
||||
with pytest.raises(ValueError, match="at least"):
|
||||
Content("short")
|
||||
|
||||
def test_content_too_long(self) -> None:
|
||||
"""Test content that is too long."""
|
||||
with pytest.raises(ValueError, match="at most"):
|
||||
Content("a" * 50001)
|
||||
|
||||
def test_content_empty(self) -> None:
|
||||
"""Test empty content."""
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
Content(" ")
|
||||
|
||||
|
||||
class TestSlug:
|
||||
def test_valid_slug(self) -> None:
|
||||
"""Test creating a valid slug."""
|
||||
slug = Slug("valid-slug")
|
||||
assert slug.value == "valid-slug"
|
||||
|
||||
def test_slug_from_title(self) -> None:
|
||||
"""Test generating slug from title."""
|
||||
slug = Slug.from_title("Hello World Post")
|
||||
assert slug.value == "hello-world-post"
|
||||
|
||||
def test_slug_from_title_with_special_chars(self) -> None:
|
||||
"""Test generating slug from title with special characters."""
|
||||
slug = Slug.from_title("Hello, World! Post @#$%")
|
||||
assert slug.value == "hello-world-post"
|
||||
|
||||
def test_slug_from_title_only_special_chars(self) -> None:
|
||||
"""Test generating slug from title with only special characters."""
|
||||
slug = Slug.from_title("!@#$%")
|
||||
assert slug.value == "post"
|
||||
|
||||
def test_slug_invalid_chars(self) -> None:
|
||||
"""Test slug with invalid characters."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
Slug("Invalid_Slug")
|
||||
|
||||
def test_slug_uppercase(self) -> None:
|
||||
"""Test slug with uppercase letters."""
|
||||
with pytest.raises(ValueError, match="lowercase"):
|
||||
Slug("Uppercase-Slug")
|
||||
|
||||
def test_slug_equality(self) -> None:
|
||||
"""Test slug value equality."""
|
||||
slug1 = Slug("test-slug")
|
||||
slug2 = Slug("test-slug")
|
||||
assert slug1 == slug2
|
||||
assert hash(slug1) == hash(slug2)
|
||||
0
tests/unit/infrastructure/__init__.py
Normal file
0
tests/unit/infrastructure/__init__.py
Normal file
37
tests/unit/infrastructure/test_config.py
Normal file
37
tests/unit/infrastructure/test_config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Tests for infrastructure config."""
|
||||
|
||||
from app.infrastructure.config import Settings
|
||||
|
||||
|
||||
class TestSettings:
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default settings values by creating settings without env file."""
|
||||
# Create settings with no env file to test defaults
|
||||
s = Settings(_env_file=None)
|
||||
assert s.app_name == "Blog API"
|
||||
assert s.debug is False
|
||||
assert s.host == "0.0.0.0"
|
||||
assert s.port == 8000
|
||||
assert s.database_url == "sqlite:///./blog.db"
|
||||
assert s.database_echo is False
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
"""Test custom settings values."""
|
||||
s = Settings(
|
||||
app_name="Test API",
|
||||
debug=True,
|
||||
host="localhost",
|
||||
port=9000,
|
||||
database_url="postgresql://test",
|
||||
secret_key="test-secret",
|
||||
)
|
||||
assert s.app_name == "Test API"
|
||||
assert s.debug is True
|
||||
assert s.host == "localhost"
|
||||
assert s.port == 9000
|
||||
assert s.database_url == "postgresql://test"
|
||||
assert s.secret_key == "test-secret"
|
||||
|
||||
def test_model_config(self) -> None:
|
||||
"""Test settings model config."""
|
||||
assert "env_file" in Settings.model_config
|
||||
@@ -1,52 +0,0 @@
|
||||
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"
|
||||
@@ -1,110 +0,0 @@
|
||||
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)
|
||||
@@ -1,87 +0,0 @@
|
||||
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)
|
||||
49
tests/unit/test_main.py
Normal file
49
tests/unit/test_main.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Tests for main application."""
|
||||
|
||||
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:
|
||||
"""Test lifespan context manager."""
|
||||
app = FastAPI()
|
||||
|
||||
with (
|
||||
patch("app.main.init_db") as mock_init,
|
||||
patch("app.main.close_db") as mock_close,
|
||||
):
|
||||
async with lifespan(app):
|
||||
mock_init.assert_called_once()
|
||||
mock_close.assert_not_called()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
def test_app_factory() -> None:
|
||||
"""Test app factory creates FastAPI app."""
|
||||
app = app_factory()
|
||||
assert isinstance(app, FastAPI)
|
||||
|
||||
|
||||
def test_app_factory_has_routes() -> None:
|
||||
"""Test app has registered routes."""
|
||||
app = app_factory()
|
||||
routes = [str(route.path) for route in app.routes if hasattr(route, "path")]
|
||||
assert "/health" in routes
|
||||
# Check that API routes are included
|
||||
assert any("api" in path for path in routes)
|
||||
|
||||
|
||||
@patch("app.main.uvicorn.run")
|
||||
def test_main(mock_uvicorn_run: Mock) -> None:
|
||||
"""Test main function starts uvicorn."""
|
||||
main()
|
||||
mock_uvicorn_run.assert_called_once()
|
||||
call_kwargs = mock_uvicorn_run.call_args.kwargs
|
||||
assert call_kwargs.get("factory") is True
|
||||
assert call_kwargs.get("host") == "0.0.0.0"
|
||||
assert call_kwargs.get("port") == 8000
|
||||
@@ -1,33 +0,0 @@
|
||||
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