diff --git a/app/infrastructure/middleware/error_handler.py b/app/infrastructure/middleware/error_handler.py index 88f11ce..247b24a 100644 --- a/app/infrastructure/middleware/error_handler.py +++ b/app/infrastructure/middleware/error_handler.py @@ -129,3 +129,4 @@ def register_exception_handlers(app: FastAPI) -> None: app.add_exception_handler(DomainException, domain_exception_handler) # type: ignore[arg-type] app.add_exception_handler(StarletteHTTPException, http_exception_handler) # type: ignore[arg-type] + app.add_exception_handler(Exception, generic_exception_handler) diff --git a/tests/api/test_error_handlers.py b/tests/api/test_error_handlers.py new file mode 100644 index 0000000..7156223 --- /dev/null +++ b/tests/api/test_error_handlers.py @@ -0,0 +1,207 @@ +"""Tests for error handler middleware. + +Tests exception handling and error responses. +""" + +from unittest.mock import patch + +from httpx import ASGITransport, AsyncClient + +from app.domain.exceptions import ( + AlreadyExistsException, + DomainException, + ForbiddenException, + NotFoundException, + ValidationException, +) +from app.main import app_factory + + +class TestDomainExceptionHandlers: + """Test suite for domain exception handlers.""" + + async def test_validation_exception(self) -> None: + """Test ValidationException returns 400.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=ValidationException("Invalid input"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "ValidationException" + assert data["message"] == "Invalid input" + assert "timestamp" in data + assert "path" in data + + async def test_forbidden_exception(self) -> None: + """Test ForbiddenException returns 403.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=ForbiddenException("Access denied"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 403 + data = response.json() + assert data["error"] == "ForbiddenException" + assert data["message"] == "Access denied" + + async def test_not_found_exception(self) -> None: + """Test NotFoundException returns 404.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=NotFoundException("Post not found"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 404 + data = response.json() + assert data["error"] == "NotFoundException" + assert data["message"] == "Post not found" + + async def test_already_exists_exception(self) -> None: + """Test AlreadyExistsException returns 409.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=AlreadyExistsException("Post already exists"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 409 + data = response.json() + assert data["error"] == "AlreadyExistsException" + assert data["message"] == "Post already exists" + + async def test_generic_domain_exception(self) -> None: + """Test generic DomainException returns 500.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=DomainException("Generic error"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/12345678-1234-1234-1234-123456789abc") + + assert response.status_code == 500 + data = response.json() + assert data["error"] == "DomainException" + assert data["message"] == "Generic error" + + +class TestHTTPExceptionHandler: + """Test suite for HTTP exception handling.""" + + async def test_http_exception_structure(self) -> None: + """Test HTTP exception response structure.""" + # Test that exception handler is registered and produces correct format + import json + from dataclasses import dataclass, field + + from starlette.exceptions import HTTPException + + from app.infrastructure.middleware.error_handler import http_exception_handler + + # Create mock request + @dataclass + class MockURL: + path: str = "/test" + + @dataclass + class MockRequest: + url: MockURL = field(default_factory=MockURL) + + exc = HTTPException(status_code=404, detail="Not found") + response = await http_exception_handler(MockRequest(), exc) # type: ignore[arg-type] + + assert response.status_code == 404 + body_bytes: bytes = response.body # type: ignore[assignment] + data: dict[str, object] = json.loads(body_bytes.decode("utf-8")) + assert data["error"] == "HTTPException" + assert "message" in data + + +class TestGenericExceptionHandler: + """Test suite for generic exception handling.""" + + async def test_generic_exception_handler_function(self) -> None: + """Test generic exception handler function directly.""" + import json + from dataclasses import dataclass, field + + from app.infrastructure.middleware.error_handler import ( + generic_exception_handler, + ) + + # Create mock request + @dataclass + class MockURL: + path: str = "/test" + + @dataclass + class MockRequest: + url: MockURL = field(default_factory=MockURL) + + exc = RuntimeError("Internal error") + response = await generic_exception_handler(MockRequest(), exc) # type: ignore[arg-type] + + assert response.status_code == 500 + body_bytes: bytes = response.body # type: ignore[assignment] + data: dict[str, object] = json.loads(body_bytes.decode("utf-8")) + assert data["error"] == "InternalServerError" + assert data["message"] == "An unexpected error occurred" + assert "timestamp" in data + assert "path" in data + + +class TestGetStatusCode: + """Test suite for get_status_code function.""" + + def test_validation_exception_status(self) -> None: + """Test ValidationException maps to 400.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = ValidationException("Invalid") + assert get_status_code(exc) == 400 + + def test_forbidden_exception_status(self) -> None: + """Test ForbiddenException maps to 403.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = ForbiddenException("Forbidden") + assert get_status_code(exc) == 403 + + def test_not_found_exception_status(self) -> None: + """Test NotFoundException maps to 404.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = NotFoundException("Not found") + assert get_status_code(exc) == 404 + + def test_already_exists_exception_status(self) -> None: + """Test AlreadyExistsException maps to 409.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = AlreadyExistsException("Already exists") + assert get_status_code(exc) == 409 + + def test_generic_exception_status(self) -> None: + """Test generic DomainException maps to 500.""" + from app.infrastructure.middleware.error_handler import get_status_code + + exc = DomainException("Generic") + assert get_status_code(exc) == 500 diff --git a/tests/api/test_posts.py b/tests/api/test_posts.py new file mode 100644 index 0000000..da4c13e --- /dev/null +++ b/tests/api/test_posts.py @@ -0,0 +1,318 @@ +"""API tests for posts endpoints. + +Tests REST API endpoints - focusing on endpoints that don't require +complex Dishka dependency mocking. +""" + +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.application.dtos import PostResponseDTO +from app.domain.exceptions import NotFoundException +from app.main import app_factory + + +@pytest.fixture +def sample_post_dto() -> PostResponseDTO: + """Create a sample post DTO for testing.""" + return PostResponseDTO( + id=uuid4(), + title="Test Post", + content="This is test content for the blog post", + slug="test-post", + author_id="test-user-id", + published=True, + tags=["python", "testing"], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +class TestListPublishedPosts: + """Test suite for GET /api/v1/posts/published endpoint.""" + + async def test_list_published_posts( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test listing published posts without authentication.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.published_posts", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/published") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + +class TestSearchPosts: + """Test suite for GET /api/v1/posts/search endpoint.""" + + async def test_search_posts( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test searching posts by query.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.search", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/search?query=test") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + async def test_search_posts_empty_query(self) -> None: + """Test search with empty query returns empty results.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.search", + return_value=[], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/search?query=") + + # Empty query returns 200 with empty results (not 422) + # as query param accepts empty strings + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + +class TestGetPostsByTag: + """Test suite for GET /api/v1/posts/by-tag/{tag} endpoint.""" + + async def test_get_posts_by_tag( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting posts by tag.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.by_tag", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/by-tag/python") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + +class TestGetPostsByAuthor: + """Test suite for GET /api/v1/posts/by-author/{author_id} endpoint.""" + + async def test_get_posts_by_author( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting posts by author.""" + with patch( + "app.application.use_cases.list_posts.ListPostsUseCase.by_author", + return_value=[sample_post_dto], + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/by-author/test-user-id") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert data["total"] == 1 + + +class TestGetPostById: + """Test suite for GET /api/v1/posts/{post_id} endpoint.""" + + async def test_get_post_by_id_success( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting a post by ID.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + return_value=sample_post_dto, + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get(f"/api/v1/posts/{sample_post_dto.id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(sample_post_dto.id) + assert data["title"] == sample_post_dto.title + + async def test_get_post_by_id_not_found(self) -> None: + """Test getting a non-existing post returns 404.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_id", + side_effect=NotFoundException("Post not found"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get(f"/api/v1/posts/{uuid4()}") + + assert response.status_code == 404 + + +class TestGetPostBySlug: + """Test suite for GET /api/v1/posts/slug/{slug} endpoint.""" + + async def test_get_post_by_slug_success( + self, + sample_post_dto: PostResponseDTO, + ) -> None: + """Test getting a post by slug.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_slug", + return_value=sample_post_dto, + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/slug/test-post") + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == "test-post" + + async def test_get_post_by_slug_not_found(self) -> None: + """Test getting a non-existing post by slug returns 404.""" + with patch( + "app.application.use_cases.get_post.GetPostUseCase.by_slug", + side_effect=NotFoundException("Post not found"), + ): + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/posts/slug/non-existing-slug") + + assert response.status_code == 404 + + +class TestCreatePostAuth: + """Test suite for POST /api/v1/posts authentication.""" + + async def test_create_post_unauthorized(self) -> None: + """Test post creation without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/posts", + json={ + "title": "Test Post", + "content": "This is test content for the blog post", + }, + ) + + assert response.status_code == 401 + + +class TestUpdatePostAuth: + """Test suite for PATCH /api/v1/posts/{post_id} authentication.""" + + async def test_update_post_unauthorized(self) -> None: + """Test updating post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + f"/api/v1/posts/{uuid4()}", + json={"title": "Updated Title"}, + ) + + assert response.status_code == 401 + + +class TestDeletePostAuth: + """Test suite for DELETE /api/v1/posts/{post_id} authentication.""" + + async def test_delete_post_unauthorized(self) -> None: + """Test deleting post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.delete(f"/api/v1/posts/{uuid4()}") + + assert response.status_code == 401 + + +class TestPublishPostAuth: + """Test suite for POST /api/v1/posts/{post_id}/publish authentication.""" + + async def test_publish_post_unauthorized(self) -> None: + """Test publishing post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post(f"/api/v1/posts/{uuid4()}/publish") + + assert response.status_code == 401 + + +class TestUnpublishPostAuth: + """Test suite for POST /api/v1/posts/{post_id}/unpublish authentication.""" + + async def test_unpublish_post_unauthorized(self) -> None: + """Test unpublishing post without authentication returns 401.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post(f"/api/v1/posts/{uuid4()}/unpublish") + + assert response.status_code == 401 + + +class TestHealthEndpoint: + """Test suite for health check endpoint.""" + + async def test_health_check(self) -> None: + """Test health check endpoint returns ok status.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert "app" in data + assert "env" in data + + +class TestRootRedirect: + """Test suite for root redirect.""" + + async def test_root_redirect(self) -> None: + """Test root URL redirects to web UI.""" + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/") + + assert response.status_code == 200 + assert "web/" in response.text diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py new file mode 100644 index 0000000..1fbc49b --- /dev/null +++ b/tests/integration/test_repositories.py @@ -0,0 +1,479 @@ +"""Integration tests for SQLAlchemyPostRepository. + +Tests repository implementation with real in-memory SQLite database. +Note: Some tests involving JSON array operations are skipped for SQLite +as it has limited support compared to PostgreSQL. +""" + +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.entities import Post +from app.domain.repositories import PostRepository +from app.domain.value_objects import Content, Slug, Title +from app.infrastructure.repositories.post import SQLAlchemyPostRepository + + +@pytest.fixture +def repository(db_session: AsyncSession) -> PostRepository: + """Create repository instance for testing.""" + return SQLAlchemyPostRepository(db_session) + + +@pytest.fixture +def sample_post() -> Post: + """Create a sample post for testing.""" + return Post( + id=uuid4(), + title=Title("Test Post Title"), + content=Content("Test content for the blog post"), + slug=Slug("test-post-title"), + author_id="test-author-123", + published=False, + tags=["python", "testing"], + ) + + +@pytest.fixture +def published_post() -> Post: + """Create a published post for testing.""" + post = Post( + id=uuid4(), + title=Title("Published Post"), + content=Content("This is a published post content"), + slug=Slug("published-post"), + author_id="test-author-456", + published=True, + tags=["published", "blog"], + ) + return post + + +class TestPostRepositoryCreate: + """Test suite for post creation operations.""" + + async def test_add_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test adding a new post to the database.""" + await repository.add(sample_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert retrieved.id == sample_post.id + assert retrieved.title.value == sample_post.title.value + assert retrieved.content.value == sample_post.content.value + assert retrieved.slug.value == sample_post.slug.value + assert retrieved.author_id == sample_post.author_id + assert retrieved.published == sample_post.published + assert retrieved.tags == sample_post.tags + + async def test_get_by_id_existing( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving an existing post by ID.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.get_by_id(sample_post.id) + + assert result is not None + assert result.id == sample_post.id + + async def test_get_by_id_non_existing(self, repository: PostRepository) -> None: + """Test retrieving a non-existing post returns None.""" + non_existing_id = uuid4() + + result = await repository.get_by_id(non_existing_id) + + assert result is None + + +class TestPostRepositoryGetAll: + """Test suite for retrieving all posts.""" + + async def test_get_all_empty(self, repository: PostRepository) -> None: + """Test retrieving all posts when database is empty.""" + results = await repository.get_all() + + assert results == [] + + async def test_get_all_multiple_posts( + self, + repository: PostRepository, + sample_post: Post, + published_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving all posts returns all entries.""" + await repository.add(sample_post) + await repository.add(published_post) + await db_session.commit() + + results = await repository.get_all() + + assert len(results) == 2 + ids = {post.id for post in results} + assert sample_post.id in ids + assert published_post.id in ids + + +class TestPostRepositoryUpdate: + """Test suite for post update operations.""" + + async def test_update_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test updating an existing post.""" + await repository.add(sample_post) + await db_session.commit() + + # Refresh to get latest state + await db_session.flush() + + # Create a new post instance with updated values + updated_post = Post( + id=sample_post.id, + title=Title("Updated Title"), + content=Content("Updated content for the post"), + slug=sample_post.slug, + author_id=sample_post.author_id, + published=sample_post.published, + tags=["updated", "tags"], + created_at=sample_post.created_at, + updated_at=sample_post.updated_at, + ) + + await repository.update(updated_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert retrieved.title.value == "Updated Title" + assert retrieved.content.value == "Updated content for the post" + assert retrieved.tags == ["updated", "tags"] + + async def test_update_publishes_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test that update reflects published status change.""" + await repository.add(sample_post) + await db_session.commit() + await db_session.flush() + + # Create updated post with published=True + updated_post = Post( + id=sample_post.id, + title=sample_post.title, + content=sample_post.content, + slug=sample_post.slug, + author_id=sample_post.author_id, + published=True, + tags=sample_post.tags, + created_at=sample_post.created_at, + updated_at=sample_post.updated_at, + ) + + await repository.update(updated_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert retrieved.published is True + + +class TestPostRepositoryDelete: + """Test suite for post deletion operations.""" + + async def test_delete_existing_post( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test deleting an existing post.""" + await repository.add(sample_post) + await db_session.commit() + + await repository.delete(sample_post.id) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + assert retrieved is None + + async def test_delete_non_existing_post(self, repository: PostRepository) -> None: + """Test deleting a non-existing post does not raise error.""" + non_existing_id = uuid4() + + await repository.delete(non_existing_id) + + +class TestPostRepositoryExists: + """Test suite for post existence checks.""" + + async def test_exists_true( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test exists returns True for existing post.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.exists(sample_post.id) + + assert result is True + + async def test_exists_false(self, repository: PostRepository) -> None: + """Test exists returns False for non-existing post.""" + non_existing_id = uuid4() + + result = await repository.exists(non_existing_id) + + assert result is False + + +class TestPostRepositoryGetBySlug: + """Test suite for slug-based retrieval.""" + + async def test_get_by_slug_existing( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving post by existing slug.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.get_by_slug(sample_post.slug.value) + + assert result is not None + assert result.id == sample_post.id + assert result.slug.value == sample_post.slug.value + + async def test_get_by_slug_non_existing(self, repository: PostRepository) -> None: + """Test retrieving by non-existing slug returns None.""" + result = await repository.get_by_slug("non-existing-slug") + + assert result is None + + +class TestPostRepositoryGetByAuthor: + """Test suite for author-based retrieval.""" + + async def test_get_by_author( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving posts by author ID.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.get_by_author(sample_post.author_id) + + assert len(results) == 1 + assert results[0].id == sample_post.id + + async def test_get_by_author_empty(self, repository: PostRepository) -> None: + """Test retrieving posts by author with no posts.""" + results = await repository.get_by_author("non-existing-author") + + assert results == [] + + +class TestPostRepositoryGetPublished: + """Test suite for published posts retrieval.""" + + async def test_get_published_only( + self, + repository: PostRepository, + sample_post: Post, + published_post: Post, + db_session: AsyncSession, + ) -> None: + """Test retrieving only published posts.""" + await repository.add(sample_post) + await repository.add(published_post) + await db_session.commit() + + results = await repository.get_published() + + assert len(results) == 1 + assert results[0].id == published_post.id + + +class TestPostRepositoryGetByTag: + """Test suite for tag-based retrieval. + + Note: These tests are skipped for SQLite as it has limited JSON support. + """ + + @pytest.mark.skip(reason="SQLite has limited JSON array support") + async def test_get_by_tag(self, repository: PostRepository, sample_post: Post) -> None: + """Test retrieving posts by tag.""" + pass + + @pytest.mark.skip(reason="SQLite has limited JSON array support") + async def test_get_by_tag_multiple_posts(self, repository: PostRepository) -> None: + """Test retrieving multiple posts with same tag.""" + pass + + @pytest.mark.skip(reason="SQLite has limited JSON array support") + async def test_get_by_tag_not_found( + self, + repository: PostRepository, + sample_post: Post, + ) -> None: + """Test retrieving by non-existing tag returns empty list.""" + pass + + +class TestPostRepositorySlugExists: + """Test suite for slug existence checks.""" + + async def test_slug_exists_true( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test slug_exists returns True for existing slug.""" + await repository.add(sample_post) + await db_session.commit() + + result = await repository.slug_exists(sample_post.slug.value) + + assert result is True + + async def test_slug_exists_false(self, repository: PostRepository) -> None: + """Test slug_exists returns False for non-existing slug.""" + result = await repository.slug_exists("non-existing-slug") + + assert result is False + + +class TestPostRepositorySearch: + """Test suite for post search functionality.""" + + async def test_search_by_title( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test searching posts by title.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("Test Post") + + assert len(results) == 1 + assert results[0].id == sample_post.id + + async def test_search_by_content( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test searching posts by content.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("blog post") + + assert len(results) == 1 + assert results[0].id == sample_post.id + + async def test_search_case_insensitive( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test search is case insensitive.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("TEST POST") + + assert len(results) == 1 + + async def test_search_no_results( + self, + repository: PostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test search with no matching results.""" + await repository.add(sample_post) + await db_session.commit() + + results = await repository.search("xyz123nonexistent") + + assert results == [] + + @pytest.mark.skip(reason="SQLite behavior without ORDER BY is non-deterministic") + async def test_search_with_limit( + self, + repository: PostRepository, + db_session: AsyncSession, + ) -> None: + """Test search with limit - skipped for SQLite.""" + pass + + @pytest.mark.skip(reason="SQLite order non-deterministic without ORDER BY") + async def test_search_with_offset( + self, + repository: PostRepository, + db_session: AsyncSession, + ) -> None: + """Test search with offset.""" + pass + + +class TestPostRepositoryConversion: + """Test suite for domain/ORM conversion.""" + + async def test_to_domain_preserves_all_fields( + self, + repository: SQLAlchemyPostRepository, + sample_post: Post, + db_session: AsyncSession, + ) -> None: + """Test that domain conversion preserves all post fields.""" + await repository.add(sample_post) + await db_session.commit() + + retrieved = await repository.get_by_id(sample_post.id) + + assert retrieved is not None + assert isinstance(retrieved.id, UUID) + assert retrieved.title.value == sample_post.title.value + assert retrieved.content.value == sample_post.content.value + assert retrieved.slug.value == sample_post.slug.value + assert retrieved.author_id == sample_post.author_id + assert retrieved.published == sample_post.published + assert retrieved.tags == sample_post.tags diff --git a/tests/unit/application/test_list_posts.py b/tests/unit/application/test_list_posts.py new file mode 100644 index 0000000..7bb1fc1 --- /dev/null +++ b/tests/unit/application/test_list_posts.py @@ -0,0 +1,225 @@ +"""Tests for ListPostsUseCase. + +Tests listing posts with various filters. +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from app.application.dtos import PostResponseDTO +from app.application.interfaces import TransactionManager +from app.application.use_cases.list_posts import ListPostsUseCase +from app.domain.entities import Post +from app.domain.repositories import PostRepository +from app.domain.value_objects import Content, Slug, Title + + +@pytest.fixture +def mock_post_repository() -> MagicMock: + """Create mock post repository.""" + return MagicMock(spec=PostRepository) + + +@pytest.fixture +def mock_transaction_manager() -> MagicMock: + """Create mock transaction manager.""" + return MagicMock(spec=TransactionManager) + + +@pytest.fixture +def list_use_case( + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, +) -> ListPostsUseCase: + """Create list use case with mocked dependencies.""" + return ListPostsUseCase(mock_post_repository, mock_transaction_manager) + + +@pytest.fixture +def sample_posts() -> list[Post]: + """Create sample posts for testing.""" + return [ + Post( + id=uuid4(), + title=Title(f"Post {i}"), + content=Content(f"Content for post number {i}"), + slug=Slug(f"post-{i}"), + author_id="author-123", + published=i % 2 == 0, + tags=["python"] if i == 0 else [], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + for i in range(3) + ] + + +class TestAllPosts: + """Test suite for all_posts method.""" + + async def test_all_posts( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting all posts.""" + mock_post_repository.get_all = AsyncMock(return_value=sample_posts) + + result = await list_use_case.all_posts() + + assert len(result) == 3 + assert all(isinstance(dto, PostResponseDTO) for dto in result) + mock_post_repository.get_all.assert_called_once() + + async def test_all_posts_empty( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting all posts when empty.""" + mock_post_repository.get_all = AsyncMock(return_value=[]) + + result = await list_use_case.all_posts() + + assert result == [] + + +class TestPublishedPosts: + """Test suite for published_posts method.""" + + async def test_published_posts( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting published posts.""" + published = [p for p in sample_posts if p.published] + mock_post_repository.get_published = AsyncMock(return_value=published) + + result = await list_use_case.published_posts() + + assert len(result) == 2 # posts 0 and 2 are published + assert all(dto.published for dto in result) + + async def test_published_posts_with_limit_offset( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting published posts with pagination.""" + mock_post_repository.get_published = AsyncMock(return_value=[]) + + result = await list_use_case.published_posts(limit=5, offset=10) + + mock_post_repository.get_published.assert_called_once_with(limit=5, offset=10) + assert result == [] + + +class TestByAuthor: + """Test suite for by_author method.""" + + async def test_by_author( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting posts by author.""" + mock_post_repository.get_by_author = AsyncMock(return_value=sample_posts) + + result = await list_use_case.by_author("author-123") + + assert len(result) == 3 + mock_post_repository.get_by_author.assert_called_once_with( + "author-123", limit=None, offset=None + ) + + async def test_by_author_with_pagination( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting posts by author with pagination.""" + mock_post_repository.get_by_author = AsyncMock(return_value=[]) + + await list_use_case.by_author("author-123", limit=5, offset=0) + + mock_post_repository.get_by_author.assert_called_once_with("author-123", limit=5, offset=0) + + +class TestByTag: + """Test suite for by_tag method.""" + + async def test_by_tag( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test getting posts by tag.""" + tagged_posts = [sample_posts[0]] + mock_post_repository.get_by_tag = AsyncMock(return_value=tagged_posts) + + result = await list_use_case.by_tag("python") + + assert len(result) == 1 + assert "python" in result[0].tags + + async def test_by_tag_empty( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test getting posts by non-existent tag.""" + mock_post_repository.get_by_tag = AsyncMock(return_value=[]) + + result = await list_use_case.by_tag("nonexistent") + + assert result == [] + + +class TestSearch: + """Test suite for search method.""" + + async def test_search( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + sample_posts: list[Post], + ) -> None: + """Test searching posts.""" + mock_post_repository.search = AsyncMock(return_value=sample_posts) + + result = await list_use_case.search("test query") + + assert len(result) == 3 + mock_post_repository.search.assert_called_once_with("test query", limit=None, offset=None) + + async def test_search_with_pagination( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test searching posts with pagination.""" + mock_post_repository.search = AsyncMock(return_value=[]) + + await list_use_case.search("query", limit=10, offset=5) + + mock_post_repository.search.assert_called_once_with("query", limit=10, offset=5) + + async def test_search_no_results( + self, + list_use_case: ListPostsUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test searching with no matches.""" + mock_post_repository.search = AsyncMock(return_value=[]) + + result = await list_use_case.search("xyz123") + + assert result == [] diff --git a/tests/unit/application/test_publish_post.py b/tests/unit/application/test_publish_post.py new file mode 100644 index 0000000..9e05644 --- /dev/null +++ b/tests/unit/application/test_publish_post.py @@ -0,0 +1,174 @@ +"""Tests for PublishPostUseCase. + +Tests publishing and unpublishing posts with authorization. +""" + +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from app.application.dtos import PostResponseDTO +from app.application.interfaces import TransactionManager +from app.application.use_cases.publish_post import PublishPostUseCase +from app.domain.entities import Post +from app.domain.exceptions import ForbiddenException, NotFoundException +from app.domain.repositories import PostRepository +from app.domain.value_objects import Content, Slug, Title + + +@pytest.fixture +def mock_post_repository() -> MagicMock: + """Create mock post repository.""" + return MagicMock(spec=PostRepository) + + +@pytest.fixture +def mock_transaction_manager() -> MagicMock: + """Create mock transaction manager.""" + tx = MagicMock(spec=TransactionManager) + tx.commit = AsyncMock() + return tx + + +@pytest.fixture +def publish_use_case( + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, +) -> PublishPostUseCase: + """Create publish use case with mocked dependencies.""" + return PublishPostUseCase(mock_post_repository, mock_transaction_manager) + + +@pytest.fixture +def sample_post() -> Post: + """Create a sample unpublished post.""" + return Post( + id=uuid4(), + title=Title("Test Post"), + content=Content("Test content"), + slug=Slug("test-post"), + author_id="author-123", + published=False, + tags=["test"], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + +@pytest.fixture +def published_post() -> Post: + """Create a sample published post.""" + post = Post( + id=uuid4(), + title=Title("Published Post"), + content=Content("Published content"), + slug=Slug("published-post"), + author_id="author-123", + published=True, + tags=["published"], + created_at=datetime.now(), + updated_at=datetime.now(), + ) + return post + + +class TestPublishPost: + """Test suite for publish method.""" + + async def test_publish_success( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, + sample_post: Post, + ) -> None: + """Test successful post publishing.""" + mock_post_repository.get_by_id = AsyncMock(return_value=sample_post) + mock_post_repository.update = AsyncMock() + + result = await publish_use_case.publish(sample_post.id, sample_post.author_id) + + assert isinstance(result, PostResponseDTO) + assert result.id == sample_post.id + assert result.published is True + mock_post_repository.update.assert_called_once() + mock_transaction_manager.commit.assert_called_once() + + async def test_publish_not_found( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test publishing non-existent post raises NotFoundException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=None) + + with pytest.raises(NotFoundException) as exc_info: + await publish_use_case.publish(uuid4(), "author-123") + + assert "not found" in str(exc_info.value).lower() + + async def test_publish_forbidden( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + sample_post: Post, + ) -> None: + """Test publishing other user's post raises ForbiddenException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=sample_post) + + with pytest.raises(ForbiddenException) as exc_info: + await publish_use_case.publish(sample_post.id, "different-author") + + assert "own posts" in str(exc_info.value).lower() + + +class TestUnpublishPost: + """Test suite for unpublish method.""" + + async def test_unpublish_success( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + mock_transaction_manager: MagicMock, + published_post: Post, + ) -> None: + """Test successful post unpublishing.""" + mock_post_repository.get_by_id = AsyncMock(return_value=published_post) + mock_post_repository.update = AsyncMock() + + result = await publish_use_case.unpublish(published_post.id, published_post.author_id) + + assert isinstance(result, PostResponseDTO) + assert result.id == published_post.id + assert result.published is False + mock_post_repository.update.assert_called_once() + mock_transaction_manager.commit.assert_called_once() + + async def test_unpublish_not_found( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + ) -> None: + """Test unpublishing non-existent post raises NotFoundException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=None) + + with pytest.raises(NotFoundException) as exc_info: + await publish_use_case.unpublish(uuid4(), "author-123") + + assert "not found" in str(exc_info.value).lower() + + async def test_unpublish_forbidden( + self, + publish_use_case: PublishPostUseCase, + mock_post_repository: MagicMock, + published_post: Post, + ) -> None: + """Test unpublishing other user's post raises ForbiddenException.""" + mock_post_repository.get_by_id = AsyncMock(return_value=published_post) + + with pytest.raises(ForbiddenException) as exc_info: + await publish_use_case.unpublish(published_post.id, "different-author") + + assert "own posts" in str(exc_info.value).lower() diff --git a/tests/unit/infrastructure/test_transaction_manager.py b/tests/unit/infrastructure/test_transaction_manager.py new file mode 100644 index 0000000..6b0154b --- /dev/null +++ b/tests/unit/infrastructure/test_transaction_manager.py @@ -0,0 +1,46 @@ +"""Tests for DI transaction manager. + +Tests SessionTransactionManager implementation. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.infrastructure.di.transaction_manager import SessionTransactionManager + + +@pytest.fixture +def mock_session() -> MagicMock: + """Create mock async session.""" + session = MagicMock(spec=AsyncSession) + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +@pytest.fixture +def transaction_manager(mock_session: MagicMock) -> SessionTransactionManager: + """Create transaction manager with mock session.""" + return SessionTransactionManager(mock_session) + + +class TestSessionTransactionManager: + """Test suite for SessionTransactionManager.""" + + async def test_commit( + self, transaction_manager: SessionTransactionManager, mock_session: MagicMock + ) -> None: + """Test commit calls session commit.""" + await transaction_manager.commit() + + mock_session.commit.assert_called_once() + + async def test_rollback( + self, transaction_manager: SessionTransactionManager, mock_session: MagicMock + ) -> None: + """Test rollback calls session rollback.""" + await transaction_manager.rollback() + + mock_session.rollback.assert_called_once()