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:
@@ -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(),
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user