feat: RBAC E2E тесты и фикс admin-прав для редактирования постов

Основные изменения:
- Добавлены E2E тесты для проверки ownership (TC-E2E-102/103):
  * test_admin_can_edit_any_post — admin может редактировать любой пост
  * test_user_cannot_edit_other_users_post — user не может редактировать чужой пост
- Исправлены use cases (UpdatePost, DeletePost, PublishPost) — добавлена проверка роли admin
- Обновлены web routes и API routes для передачи роли в use cases
- Добавлены unit тесты для admin-сценариев

Реструктуризация тестов:
- Удалены старые API тесты (tests/api/) — требуют переработки
- Удалены старые integration тесты (tests/integration/)
- Переработаны E2E тесты: удалены старые, добавлены новые с POM
- Добавлена документация тестов: FEATURE_*.md, TEST_MODEL.md, AGENTS.md

Инфраструктура:
- Добавлен MockKeycloakClient для dev-режима
- Добавлены статические файлы: EasyMDE, Highlight.js, стили markdown
- Обновлены шаблоны: base.html, post_form.html, post_detail.html
- Обновлена DI конфигурация и провайдеры

Документация:
- tests/FEATURE_RBAC.md — матрица тестов RBAC
- tests/FEATURE_POST_LIFECYCLE.md — тесты жизненного цикла поста
- tests/FEATURE_DOMAIN_FOUNDATION.md — тесты доменного слоя
- tests/FEATURE_INFRASTRUCTURE.md — тесты инфраструктуры
- tests/TEST_MODEL.md — глобальная матрица покрытия
- app/presentation/web/AGENTS.md — гайд по Web UI
- tests/AGENTS.md — гайд по тестированию
This commit is contained in:
2026-05-07 19:55:15 +03:00
parent 41f2a3d98e
commit 46cc06b596
58 changed files with 4234 additions and 4014 deletions

View File

View File

@@ -1,145 +0,0 @@
"""API test fixtures."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from httpx import ASGITransport, AsyncClient
from app.application.dtos import PostResponseDTO
from app.infrastructure.auth.models import TokenInfo
from app.main import app_factory
class MockKeycloakClient:
def __init__(self, token_info: TokenInfo) -> None:
self._token_info = token_info
async def introspect_token(self, token: str) -> TokenInfo:
return self._token_info
@pytest.fixture
def user_token_info() -> TokenInfo:
return TokenInfo(
active=True,
user_id="test-user-id",
username="testuser",
email="test@example.com",
roles=["user"],
)
@pytest.fixture
def admin_token_info() -> TokenInfo:
return TokenInfo(
active=True,
user_id="admin-user-id",
username="adminuser",
email="admin@example.com",
roles=["admin", "user"],
)
@pytest.fixture
def mock_keycloak_client(user_token_info: TokenInfo) -> MagicMock:
mock_client = AsyncMock()
mock_client.introspect_token.return_value = user_token_info
return mock_client
@pytest.fixture
async def client(mock_keycloak_client: MagicMock) -> AsyncGenerator[AsyncClient]:
with patch(
"app.presentation.api.deps.KeycloakAuthClient",
return_value=mock_keycloak_client,
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def auth_client(user_token_info: TokenInfo) -> AsyncGenerator[AsyncClient]:
mock_client = MockKeycloakClient(user_token_info)
with patch(
"app.presentation.api.deps.get_keycloak_client",
return_value=mock_client,
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers={"Authorization": "Bearer user_token"},
) as ac:
yield ac
@pytest.fixture
async def admin_client(admin_token_info: TokenInfo) -> AsyncGenerator[AsyncClient]:
mock_client = MockKeycloakClient(admin_token_info)
with patch(
"app.presentation.api.deps.get_keycloak_client",
return_value=mock_client,
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers={"Authorization": "Bearer admin_token"},
) as ac:
yield ac
@pytest.fixture
def auth_headers() -> dict[str, str]:
return {"Authorization": "Bearer test_token"}
@pytest.fixture
def unauthorized_keycloak_client() -> MagicMock:
mock_client = AsyncMock()
mock_client.introspect_token.return_value = TokenInfo(
active=False,
user_id="",
username="",
email="",
roles=[],
)
return mock_client
@pytest.fixture
def sample_post_dto() -> PostResponseDTO:
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=__import__("datetime").datetime.now(),
updated_at=__import__("datetime").datetime.now(),
)
@pytest.fixture
def sample_unpublished_post_dto() -> PostResponseDTO:
return PostResponseDTO(
id=uuid4(),
title="Draft Post",
content="This is a draft post",
slug="draft-post",
author_id="test-user-id",
published=False,
tags=["draft"],
created_at=__import__("datetime").datetime.now(),
updated_at=__import__("datetime").datetime.now(),
)

View File

@@ -1,447 +0,0 @@
"""API authorization and role-based access control tests."""
from datetime import datetime
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from httpx import ASGITransport, AsyncClient
from app.application.dtos import PostResponseDTO
from app.domain.exceptions import ForbiddenException
from app.infrastructure.auth.models import TokenInfo
from app.main import app_factory
class TestCreatePostAuthorization:
"""Test suite for POST /api/v1/posts authorization."""
async def test_create_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test authenticated user can create post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="New Post",
content="Post content here",
slug="new-post",
author_id="test-user-id",
published=False,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.create_post.CreatePostUseCase.execute",
return_value=mock_result,
):
response = await auth_client.post(
"/api/v1/posts",
json={
"title": "New Post",
"content": "Post content here",
"tags": [],
},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "New Post"
assert data["author_id"] == "test-user-id"
async def test_create_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated request 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": "New Post",
"content": "Post content here",
},
)
assert response.status_code == 401
class TestUpdatePostAuthorization:
"""Test suite for PATCH /api/v1/posts/{post_id} authorization."""
async def test_update_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can update their own post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Updated Title",
content="Original content",
slug="updated-title",
author_id="test-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.update_post.UpdatePostUseCase.execute",
return_value=mock_result,
):
response = await auth_client.patch(
f"/api/v1/posts/{post_id}",
json={"title": "Updated Title"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated Title"
async def test_update_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated request 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
async def test_update_other_user_post_returns_403(
self,
auth_client: AsyncClient,
) -> None:
"""Test user cannot update another user's post."""
post_id = uuid4()
with patch(
"app.application.use_cases.update_post.UpdatePostUseCase.execute",
side_effect=ForbiddenException("Can only update own posts"),
):
response = await auth_client.patch(
f"/api/v1/posts/{post_id}",
json={"title": "Updated Title"},
)
assert response.status_code == 403
class TestDeletePostAuthorization:
"""Test suite for DELETE /api/v1/posts/{post_id} authorization."""
async def test_delete_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can delete their own post."""
post_id = uuid4()
with patch(
"app.application.use_cases.delete_post.DeletePostUseCase.execute",
return_value=None,
):
response = await auth_client.delete(f"/api/v1/posts/{post_id}")
assert response.status_code == 204
async def test_delete_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated request 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
async def test_delete_other_user_post_returns_403(
self,
auth_client: AsyncClient,
) -> None:
"""Test user cannot delete another user's post."""
post_id = uuid4()
with patch(
"app.application.use_cases.delete_post.DeletePostUseCase.execute",
side_effect=ForbiddenException("Can only delete own posts"),
):
response = await auth_client.delete(f"/api/v1/posts/{post_id}")
assert response.status_code == 403
class TestPublishUnpublishAuthorization:
"""Test suite for publish/unpublish endpoints authorization."""
async def test_publish_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can publish their own post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Test Post",
content="Content",
slug="test-post",
author_id="test-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.publish_post.PublishPostUseCase.publish",
return_value=mock_result,
):
response = await auth_client.post(f"/api/v1/posts/{post_id}/publish")
assert response.status_code == 200
data = response.json()
assert data["published"] is True
async def test_unpublish_own_post_with_user_role_success(
self,
auth_client: AsyncClient,
) -> None:
"""Test user can unpublish their own post."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Test Post",
content="Content",
slug="test-post",
author_id="test-user-id",
published=False,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.publish_post.PublishPostUseCase.unpublish",
return_value=mock_result,
):
response = await auth_client.post(f"/api/v1/posts/{post_id}/unpublish")
assert response.status_code == 200
data = response.json()
assert data["published"] is False
async def test_publish_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated publish request 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
async def test_unpublish_post_without_auth_returns_401(self) -> None:
"""Test unauthenticated unpublish request 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 TestRoleBasedAccessControl:
"""Test suite for role-based permissions."""
async def test_admin_can_view_unpublished_posts(
self,
admin_client: AsyncClient,
) -> None:
"""Test admin can use include_unpublished parameter."""
mock_posts = [
PostResponseDTO(
id=uuid4(),
title="Published Post",
content="Content",
slug="published-post",
author_id="test-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
),
PostResponseDTO(
id=uuid4(),
title="Draft Post",
content="Draft content",
slug="draft-post",
author_id="test-user-id",
published=False,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
),
]
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.all_posts",
return_value=mock_posts,
):
response = await admin_client.get("/api/v1/posts?include_unpublished=true")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
async def test_user_cannot_view_unpublished_posts(
self,
auth_client: AsyncClient,
) -> None:
"""Test regular user cannot use include_unpublished parameter."""
response = await auth_client.get("/api/v1/posts?include_unpublished=true")
assert response.status_code == 403
data = response.json()
assert "message" in data
assert "Only admins can view unpublished posts" in data["message"]
async def test_admin_can_update_any_post(
self,
admin_client: AsyncClient,
) -> None:
"""Test admin can update any post regardless of ownership."""
post_id = uuid4()
mock_result = PostResponseDTO(
id=post_id,
title="Admin Updated Title",
content="Content",
slug="admin-updated-title",
author_id="other-user-id",
published=True,
tags=[],
created_at=datetime.now(),
updated_at=datetime.now(),
)
with patch(
"app.application.use_cases.update_post.UpdatePostUseCase.execute",
return_value=mock_result,
):
response = await admin_client.patch(
f"/api/v1/posts/{post_id}",
json={"title": "Admin Updated Title"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Admin Updated Title"
async def test_admin_can_delete_any_post(
self,
admin_client: AsyncClient,
) -> None:
"""Test admin can delete any post regardless of ownership."""
post_id = uuid4()
with patch(
"app.application.use_cases.delete_post.DeletePostUseCase.execute",
return_value=None,
):
response = await admin_client.delete(f"/api/v1/posts/{post_id}")
assert response.status_code == 204
class TestTokenValidation:
"""Test suite for token validation scenarios."""
async def test_expired_token_returns_401(self) -> None:
mock_client = AsyncMock()
mock_client.introspect_token.return_value = TokenInfo(
active=False,
user_id="test-user-id",
username="testuser",
email="test@example.com",
roles=["user"],
)
with patch(
"app.presentation.api.deps.get_keycloak_client",
return_value=mock_client,
):
app = app_factory()
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers={"Authorization": "Bearer expired_token"},
) as client:
response = await client.post(
"/api/v1/posts",
json={"title": "Test", "content": "Content"},
)
assert response.status_code == 401
async def test_invalid_token_format_returns_401(self) -> None:
"""Test invalid token format 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", "content": "Content"},
headers={"Authorization": "InvalidFormat token"},
)
assert response.status_code == 401
async def test_missing_token_returns_401(self) -> None:
"""Test request without token 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", "content": "Content"},
)
assert response.status_code == 401
class TestAuthorizationErrorResponses:
"""Test suite for authorization error response formats."""
async def test_401_response_format(self) -> None:
"""Test 401 error has correct format."""
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", "content": "Content"},
)
assert response.status_code == 401
data = response.json()
assert "message" in data
assert "Authentication required" in data["message"]
async def test_403_response_format(
self,
auth_client: AsyncClient,
) -> None:
"""Test 403 error has correct format."""
with patch(
"app.application.use_cases.list_posts.ListPostsUseCase.all_posts",
side_effect=ForbiddenException("Only admins can view unpublished posts"),
):
response = await auth_client.get("/api/v1/posts?include_unpublished=true")
assert response.status_code == 403
data = response.json()
assert "message" in data
assert "Only admins can view unpublished posts" in data["message"]

View File

@@ -1,207 +0,0 @@
"""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

View File

@@ -1,318 +0,0 @@
"""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