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

@@ -1,58 +0,0 @@
"""Integration test fixtures."""
from collections.abc import AsyncGenerator
import pytest
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.infrastructure.database.models import Base
# Use in-memory SQLite for tests
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def engine() -> AsyncEngine:
"""Create test engine."""
return create_async_engine(
TEST_DATABASE_URL,
echo=False,
future=True,
)
@pytest.fixture(scope="session")
def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Create test session factory."""
return async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
@pytest.fixture(autouse=True)
async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None]:
"""Setup database tables for each test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def db_session(
session_factory: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession]:
"""Create database session for testing."""
async with session_factory() as session:
yield session
await session.rollback()

View File

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