diff --git a/tests/FEATURE_POST_LIFECYCLE.md b/tests/FEATURE_POST_LIFECYCLE.md index 045cd6f..4545862 100644 --- a/tests/FEATURE_POST_LIFECYCLE.md +++ b/tests/FEATURE_POST_LIFECYCLE.md @@ -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) diff --git a/tests/FEATURE_RBAC.md b/tests/FEATURE_RBAC.md index 98df8df..0f1a0ef 100644 --- a/tests/FEATURE_RBAC.md +++ b/tests/FEATURE_RBAC.md @@ -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) diff --git a/tests/TEST_MODEL.md b/tests/TEST_MODEL.md index 0e44cad..fb062ca 100644 --- a/tests/TEST_MODEL.md +++ b/tests/TEST_MODEL.md @@ -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 | diff --git a/tests/unit/application/test_use_cases.py b/tests/unit/application/test_use_cases.py index 088aa8a..1f71bbf 100644 --- a/tests/unit/application/test_use_cases.py +++ b/tests/unit/application/test_use_cases.py @@ -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 diff --git a/tests/unit/domain/test_entities.py b/tests/unit/domain/test_entities.py index 9949d78..0b386f7 100644 --- a/tests/unit/domain/test_entities.py +++ b/tests/unit/domain/test_entities.py @@ -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) diff --git a/tests/unit/domain/test_roles.py b/tests/unit/domain/test_roles.py index 5a3d0e1..5cc37fe 100644 --- a/tests/unit/domain/test_roles.py +++ b/tests/unit/domain/test_roles.py @@ -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) diff --git a/tests/unit/domain/test_value_objects.py b/tests/unit/domain/test_value_objects.py index 58e83d9..dbc46dd 100644 --- a/tests/unit/domain/test_value_objects.py +++ b/tests/unit/domain/test_value_objects.py @@ -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 diff --git a/tests/unit/infrastructure/test_auth.py b/tests/unit/infrastructure/test_auth.py index 8c5c178..1788e5b 100644 --- a/tests/unit/infrastructure/test_auth.py +++ b/tests/unit/infrastructure/test_auth.py @@ -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.""" diff --git a/tests/unit/infrastructure/test_di_container.py b/tests/unit/infrastructure/test_di_container.py new file mode 100644 index 0000000..ab12d36 --- /dev/null +++ b/tests/unit/infrastructure/test_di_container.py @@ -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) diff --git a/tests/unit/infrastructure/test_mock_auth.py b/tests/unit/infrastructure/test_mock_auth.py new file mode 100644 index 0000000..ff45bfe --- /dev/null +++ b/tests/unit/infrastructure/test_mock_auth.py @@ -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 diff --git a/tests/unit/presentation/test_web_deps.py b/tests/unit/presentation/test_web_deps.py new file mode 100644 index 0000000..58ae800 --- /dev/null +++ b/tests/unit/presentation/test_web_deps.py @@ -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