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,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