test(unit): add roles, web deps, use cases, VO boundary tests — reach 70% coverage
Some checks failed
ci/woodpecker/pr/pipeline Pipeline was canceled

This commit is contained in:
2026-05-09 19:51:41 +03:00
parent 7270d544a5
commit 6eddde5c70
11 changed files with 465 additions and 7 deletions

View File

@@ -265,8 +265,8 @@ Covers both API use cases and web UI end-to-end flows.
## Gaps (Not Yet Covered)
- [ ] TC-UNIT-023: CreatePostUseCase — explicit validation error (title too short, content empty)
- [ ] TC-UNIT-024: UpdatePostUseCase — not found scenario
- [ ] TC-UNIT-025: UpdatePostUseCase — validation error
- [x] TC-UNIT-024: UpdatePostUseCase — not found scenario
- [x] TC-UNIT-025: UpdatePostUseCase — content and tags update
- [ ] TC-UNIT-026: ListPostsUseCase — pagination edge cases (page boundaries, empty page)
- [x] TC-E2E-003: 404 error page for nonexistent post
- [x] TC-E2E-003a: Edit post via web UI and verify changes (own post)

View File

@@ -161,10 +161,10 @@ unit tests for the web layer.
## Gaps (Not Yet Covered)
- [ ] TC-UNIT-113: Web deps — `can_create_post` for each role
- [ ] TC-UNIT-114: Web deps — `can_edit_post` for owner vs non-owner
- [ ] TC-UNIT-115: Web deps — `can_delete_post` for owner vs non-owner
- [ ] TC-UNIT-116: Web deps — `can_see_draft` for each role combination
- [x] TC-UNIT-113: Web deps — `can_create_post` for each role
- [x] TC-UNIT-114: Web deps — `can_edit_post` for owner vs non-owner
- [x] TC-UNIT-115: Web deps — `can_delete_post` for owner vs non-owner
- [x] TC-UNIT-116: Web deps — `can_see_draft` for each role combination
- [ ] TC-API-101: API POST create — unauthorized (no token)
- [ ] TC-API-102: API POST create — forbidden (guest token)
- [ ] TC-API-103: API GET unpublished post — forbidden (other user)

View File

@@ -9,7 +9,7 @@ adding new tests.
| Feature | Unit | Integration | API | E2E | Priority | Status |
|---------|:----:|:-----------:|:---:|:---:|:--------:|:------:|
| Post Lifecycle (CRUD, Publish) | 85% | — | — | 70% | P0 | ✅ Active |
| RBAC & Access Control | 90% | — | — | 60% | P0 | ✅ Active |
| RBAC & Access Control | 100% | — | — | 60% | P0 | ✅ Active |
| Domain Value Objects | 100% | — | — | — | P0 | ✅ Stable |
| Domain Entities | 95% | — | — | — | P0 | ✅ Stable |
| Domain Exceptions | 100% | — | — | — | P1 | ✅ Stable |

View File

@@ -126,6 +126,37 @@ class TestGetPostUseCase:
with pytest.raises(NotFoundException):
await use_case.by_id(post_id)
@pytest.mark.asyncio
async def test_get_post_by_slug_success(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test successful get post by slug."""
mock_post_repository.get_by_slug = AsyncMock(return_value=test_post)
use_case = GetPostUseCase(mock_post_repository, mock_transaction_manager)
result = await use_case.by_slug(test_post.slug.value)
assert result.id == test_post.id
assert result.title == test_post.title.value
mock_post_repository.get_by_slug.assert_called_once_with(test_post.slug.value)
@pytest.mark.asyncio
async def test_get_post_by_slug_not_found(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test get post by slug when not found."""
mock_post_repository.get_by_slug = AsyncMock(return_value=None)
use_case = GetPostUseCase(mock_post_repository, mock_transaction_manager)
with pytest.raises(NotFoundException):
await use_case.by_slug("nonexistent-slug")
class TestUpdatePostUseCase:
@pytest.mark.asyncio
@@ -170,6 +201,46 @@ class TestUpdatePostUseCase:
mock_post_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_update_post_not_found(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test update post when post does not exist."""
mock_post_repository.get_by_id = AsyncMock(return_value=None)
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
dto = UpdatePostDTO(title="New Title")
post_id = uuid4()
with pytest.raises(NotFoundException):
await use_case.execute(post_id, dto, "user-123")
@pytest.mark.asyncio
async def test_update_post_with_content_and_tags(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test update post content and tags."""
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.update = AsyncMock()
use_case = UpdatePostUseCase(mock_post_repository, mock_transaction_manager)
dto = UpdatePostDTO(
content="Updated content with enough characters for validation",
tags=["new", "tags"],
)
result = await use_case.execute(test_post.id, dto, "user-123")
assert result.content == dto.content
assert result.tags == dto.tags
mock_post_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
class TestDeletePostUseCase:
@pytest.mark.asyncio
@@ -231,6 +302,21 @@ class TestDeletePostUseCase:
mock_post_repository.delete.assert_called_once_with(test_post.id)
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_delete_post_not_found(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test delete post when post does not exist."""
mock_post_repository.get_by_id = AsyncMock(return_value=None)
use_case = DeletePostUseCase(mock_post_repository, mock_transaction_manager)
post_id = uuid4()
with pytest.raises(NotFoundException):
await use_case.execute(post_id, "user-123")
class TestPublishPostUseCase:
@pytest.mark.asyncio

View File

@@ -1,5 +1,6 @@
"""Tests for domain entities."""
from typing import Any
from uuid import UUID
from app.domain.entities import Post
@@ -126,3 +127,19 @@ class TestPost:
assert "id" in data
assert "created_at" in data
assert "updated_at" in data
def test_base_entity_eq_and_hash(self) -> None:
"""Test BaseEntity equality and hash directly."""
from app.domain.entities.base import BaseEntity
class ConcreteEntity(BaseEntity):
def to_dict(self) -> dict[str, Any]:
return {}
e1 = ConcreteEntity()
e2 = ConcreteEntity()
e2.id = e1.id
assert BaseEntity.__eq__(e1, e2) is True
assert BaseEntity.__eq__(e1, "not an entity") == NotImplemented
assert BaseEntity.__hash__(e1) == hash(e1.id)

View File

@@ -1,11 +1,17 @@
"""Tests for role-based access control."""
from typing import Any
import pytest
from app.domain.exceptions import ForbiddenException
from app.domain.roles import (
ROLE_PERMISSIONS,
Permission,
Role,
get_effective_role,
has_permission,
require_permission,
)
@@ -121,3 +127,61 @@ class TestGetEffectiveRole:
assert get_effective_role(["user", "admin", "guest"]) == Role.ADMIN
# User takes precedence over guest
assert get_effective_role(["guest", "user"]) == Role.USER
class TestRequirePermission:
"""Test require_permission decorator."""
async def test_admin_passes_permission_check(self) -> None:
"""Test admin with permission succeeds."""
@require_permission(Permission.POST_CREATE)
async def dummy(*, token_info: Any = None) -> str:
return "ok"
token = type("TokenInfo", (), {"roles": ["admin"]})()
result = await dummy(token_info=token)
assert result == "ok"
async def test_user_passes_permission_check(self) -> None:
"""Test user with permission succeeds."""
@require_permission(Permission.POST_READ)
async def dummy(*, token_info: Any = None) -> str:
return "ok"
token = type("TokenInfo", (), {"roles": ["user"]})()
result = await dummy(token_info=token)
assert result == "ok"
async def test_no_token_raises(self) -> None:
"""Test missing token raises ForbiddenException."""
@require_permission(Permission.POST_CREATE)
async def dummy(*, token_info: Any = None) -> str:
return "ok"
with pytest.raises(ForbiddenException, match="Authentication required"):
await dummy()
async def test_guest_without_permission_raises(self) -> None:
"""Test guest lacking permission raises ForbiddenException."""
@require_permission(Permission.POST_CREATE)
async def dummy(*, token_info: Any = None) -> str:
return "ok"
token = type("TokenInfo", (), {"roles": []})()
with pytest.raises(ForbiddenException, match="Permission 'post:create' required"):
await dummy(token_info=token)
async def test_user_forbidden_unpublished(self) -> None:
"""Test user cannot read unpublished via decorator."""
@require_permission(Permission.POST_READ_UNPUBLISHED)
async def dummy(*, token_info: Any = None) -> str:
return "ok"
token = type("TokenInfo", (), {"roles": ["user"]})()
with pytest.raises(ForbiddenException, match="Permission 'post:read_unpublished' required"):
await dummy(token_info=token)

View File

@@ -91,3 +91,73 @@ class TestSlug:
slug2 = Slug("test-slug")
assert slug1 == slug2
assert hash(slug1) == hash(slug2)
def test_slug_str_and_primitive(self) -> None:
"""Test slug string and primitive conversion."""
slug = Slug("test-slug")
assert str(slug) == "test-slug"
assert slug.to_primitive() == "test-slug"
def test_slug_eq_with_non_value_object(self) -> None:
"""Test slug equality with non-ValueObject returns False."""
slug = Slug("test-slug")
assert slug != "test-slug"
def test_slug_not_string(self) -> None:
"""Test non-string slug raises ValueError."""
with pytest.raises(ValueError, match="string"):
Slug(123) # type: ignore[arg-type]
def test_slug_too_long(self) -> None:
"""Test slug exceeding max length raises ValueError."""
with pytest.raises(ValueError, match="at most"):
Slug("a" * 201)
class TestValueObjectBase:
"""Test base ValueObject behavior across types."""
def test_title_eq_with_non_value_object(self) -> None:
"""Test title equality with non-ValueObject returns False."""
title = Title("Test Title")
assert title != "Test Title"
def test_content_str_and_primitive(self) -> None:
"""Test content string and primitive conversion."""
content = Content("Enough characters for valid content")
assert str(content) == "Enough characters for valid content"
assert content.to_primitive() == "Enough characters for valid content"
def test_content_not_string(self) -> None:
"""Test non-string content raises ValueError."""
with pytest.raises(ValueError, match="string"):
Content(123) # type: ignore[arg-type]
def test_value_object_eq_and_hash_directly(self) -> None:
"""Test ValueObject base eq and hash via direct call."""
from app.domain.value_objects.base import ValueObject
class SimpleVO(ValueObject[str]):
def _validate(self) -> None:
pass
vo1 = SimpleVO("test")
vo2 = SimpleVO("test")
vo3 = SimpleVO("other")
assert ValueObject.__eq__(vo1, vo2) is True
assert ValueObject.__eq__(vo1, vo3) is False
assert ValueObject.__eq__(vo1, "test") is False
assert ValueObject.__hash__(vo1) == hash("test")
def test_content_exact_min_length(self) -> None:
"""Test content at exact minimum length boundary."""
min_content = "a" * 10
content = Content(min_content)
assert content.value == min_content
def test_slug_exact_max_length(self) -> None:
"""Test slug at exact maximum length boundary."""
max_slug = "a" * 200
slug = Slug(max_slug)
assert slug.value == max_slug

View File

@@ -234,6 +234,28 @@ class TestKeycloakAuthClient:
assert mock_async_client.post.call_count == 1
assert result1.user_id == result2.user_id
def test_get_cached_token_expired(self, client: KeycloakAuthClient) -> None:
"""Test expired cache entry returns None and is removed."""
from app.infrastructure.auth.models import TokenInfo
client._cache["expired-token"] = (TokenInfo(active=True), 0)
with patch("time.time", return_value=1000):
result = client._get_cached_token("expired-token")
assert result is None
assert "expired-token" not in client._cache
def test_cache_token_removes_expired_entries(self, client: KeycloakAuthClient) -> None:
"""Test caching new token removes expired existing entries."""
from app.infrastructure.auth.models import TokenInfo
old_token = TokenInfo(active=True)
new_token = TokenInfo(active=True)
client._cache["old"] = (old_token, 0)
with patch("time.time", return_value=1000):
client._cache_token("new", new_token)
assert "old" not in client._cache
assert "new" in client._cache
@pytest.mark.asyncio
async def test_get_userinfo_success(self, client: KeycloakAuthClient) -> None:
"""Test successful userinfo retrieval."""

View File

@@ -0,0 +1,14 @@
"""Tests for DI container."""
from app.infrastructure.di.container import create_container
class TestContainer:
"""Test DI container creation."""
def test_create_container(self) -> None:
"""Test container factory returns AsyncContainer."""
from dishka import AsyncContainer
container = create_container()
assert isinstance(container, AsyncContainer)

View File

@@ -0,0 +1,54 @@
"""Tests for mock Keycloak client."""
import pytest
from app.infrastructure.auth.mock_client import MockKeycloakClient
class TestMockKeycloakClient:
"""Test MockKeycloakClient token introspection."""
@pytest.fixture
def client(self) -> MockKeycloakClient:
"""Create mock client instance."""
return MockKeycloakClient()
@pytest.mark.asyncio
async def test_introspect_user_token(self, client: MockKeycloakClient) -> None:
"""Test introspecting user token."""
result = await client.introspect_token("dev-token-user")
assert result.is_valid is True
assert result.user_id == "dev-user"
assert result.username == "Dev User"
assert result.email == "dev.user@example.com"
assert result.roles == ["user"]
@pytest.mark.asyncio
async def test_introspect_user2_token(self, client: MockKeycloakClient) -> None:
"""Test introspecting user2 token."""
result = await client.introspect_token("dev-token-user2")
assert result.is_valid is True
assert result.user_id == "dev-user2"
assert result.username == "Test User"
assert result.roles == ["user"]
@pytest.mark.asyncio
async def test_introspect_admin_token(self, client: MockKeycloakClient) -> None:
"""Test introspecting admin token."""
result = await client.introspect_token("dev-token-admin")
assert result.is_valid is True
assert result.user_id == "dev-admin"
assert result.username == "Dev Admin"
assert result.roles == ["admin"]
@pytest.mark.asyncio
async def test_introspect_guest_token(self, client: MockKeycloakClient) -> None:
"""Test introspecting guest token returns inactive."""
result = await client.introspect_token("dev-token-guest")
assert result.is_valid is False
@pytest.mark.asyncio
async def test_introspect_unknown_token(self, client: MockKeycloakClient) -> None:
"""Test introspecting unknown token returns inactive."""
result = await client.introspect_token("unknown-token")
assert result.is_valid is False

View File

@@ -0,0 +1,131 @@
"""Tests for web dependencies."""
from unittest.mock import MagicMock
from app.domain.roles import Role
from app.presentation.web.deps import (
can_create_post,
can_delete_post,
can_edit_post,
can_see_draft,
get_user_role,
)
class TestGetUserRole:
"""Test get_user_role function."""
def test_none_returns_guest(self) -> None:
"""Test None user returns GUEST role."""
assert get_user_role(None) == Role.GUEST
def test_admin_roles(self) -> None:
"""Test admin role detection."""
user = MagicMock()
user.roles = ["admin"]
assert get_user_role(user) == Role.ADMIN
def test_user_roles(self) -> None:
"""Test user role detection."""
user = MagicMock()
user.roles = ["user"]
assert get_user_role(user) == Role.USER
def test_empty_roles_returns_guest(self) -> None:
"""Test empty roles list returns GUEST."""
user = MagicMock()
user.roles = []
assert get_user_role(user) == Role.GUEST
class TestCanCreatePost:
"""Test can_create_post function."""
def test_guest_cannot_create(self) -> None:
"""Test guest cannot create posts."""
assert can_create_post(None) is False
def test_user_can_create(self) -> None:
"""Test user can create posts."""
user = MagicMock()
user.roles = ["user"]
assert can_create_post(user) is True
def test_admin_can_create(self) -> None:
"""Test admin can create posts."""
user = MagicMock()
user.roles = ["admin"]
assert can_create_post(user) is True
class TestCanEditPost:
"""Test can_edit_post function."""
def test_guest_cannot_edit(self) -> None:
"""Test guest cannot edit any post."""
assert can_edit_post(None, "author-1") is False
def test_user_can_edit_own(self) -> None:
"""Test user can edit own post."""
user = MagicMock()
user.roles = ["user"]
user.user_id = "author-1"
assert can_edit_post(user, "author-1") is True
def test_user_cannot_edit_other(self) -> None:
"""Test user cannot edit other's post."""
user = MagicMock()
user.roles = ["user"]
user.user_id = "user-2"
assert can_edit_post(user, "author-1") is False
def test_admin_can_edit_any(self) -> None:
"""Test admin can edit any post."""
user = MagicMock()
user.roles = ["admin"]
user.user_id = "admin-1"
assert can_edit_post(user, "author-1") is True
class TestCanDeletePost:
"""Test can_delete_post function."""
def test_delegates_to_can_edit(self) -> None:
"""Test can_delete_post delegates to can_edit_post logic."""
user = MagicMock()
user.roles = ["user"]
user.user_id = "author-1"
assert can_delete_post(user, "author-1") is True
def test_guest_cannot_delete(self) -> None:
"""Test guest cannot delete posts."""
assert can_delete_post(None, "author-1") is False
class TestCanSeeDraft:
"""Test can_see_draft function."""
def test_guest_cannot_see(self) -> None:
"""Test guest cannot see drafts."""
assert can_see_draft(None, "author-1") is False
def test_user_can_see_own(self) -> None:
"""Test user can see own draft."""
user = MagicMock()
user.roles = ["user"]
user.user_id = "author-1"
assert can_see_draft(user, "author-1") is True
def test_user_cannot_see_other(self) -> None:
"""Test user cannot see other's draft."""
user = MagicMock()
user.roles = ["user"]
user.user_id = "user-2"
assert can_see_draft(user, "author-1") is False
def test_admin_can_see_any(self) -> None:
"""Test admin can see any draft."""
user = MagicMock()
user.roles = ["admin"]
user.user_id = "admin-1"
assert can_see_draft(user, "author-1") is True