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