Compare commits
2 Commits
76c64ca720
...
6eddde5c70
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eddde5c70 | |||
| 7270d544a5 |
@@ -1,14 +0,0 @@
|
||||
when:
|
||||
- event: [push, pull_request]
|
||||
branch: dev
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: python:3.13
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --no-dev --only-group lints
|
||||
- uv run ruff check .
|
||||
- uv run ruff format --check .
|
||||
- uv run isort --check-only .
|
||||
|
||||
89
.woodpecker/pipeline.yml
Normal file
89
.woodpecker/pipeline.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
when:
|
||||
event: [push, pull_request]
|
||||
branch: [dev, main, master]
|
||||
|
||||
steps:
|
||||
- name: deps
|
||||
image: python:3.13
|
||||
volumes:
|
||||
- /tmp/uv-cache:/root/.cache/uv
|
||||
environment:
|
||||
UV_CACHE_DIR: /root/.cache/uv
|
||||
UV_LINK_MODE: copy
|
||||
UV_PYTHON: "3.13"
|
||||
commands:
|
||||
- pip install uv
|
||||
- cd ..
|
||||
- |
|
||||
cat > pyproject.toml << 'EOF'
|
||||
[project]
|
||||
name = "pyaqa"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["blog.pyaqa.ru", "pytfm"]
|
||||
EOF
|
||||
- git clone https://git.pyaqa.ru/pi3c/pytfm.git
|
||||
- cd $CI_WORKSPACE
|
||||
- rm -rf .venv
|
||||
- uv sync --group lints --group tests --group types --group dev
|
||||
|
||||
- name: lint
|
||||
image: python:3.13
|
||||
volumes:
|
||||
- /tmp/uv-cache:/root/.cache/uv
|
||||
environment:
|
||||
UV_CACHE_DIR: /root/.cache/uv
|
||||
UV_LINK_MODE: copy
|
||||
UV_PYTHON: "3.13"
|
||||
depends_on: [deps]
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv run --no-sync ruff check .
|
||||
- uv run --no-sync ruff format --check .
|
||||
- uv run --no-sync isort --check-only .
|
||||
|
||||
- name: type
|
||||
image: python:3.13
|
||||
volumes:
|
||||
- /tmp/uv-cache:/root/.cache/uv
|
||||
environment:
|
||||
UV_CACHE_DIR: /root/.cache/uv
|
||||
UV_LINK_MODE: copy
|
||||
UV_PYTHON: "3.13"
|
||||
depends_on: [deps]
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv run --no-sync mypy .
|
||||
|
||||
- name: test-unit
|
||||
image: python:3.13
|
||||
volumes:
|
||||
- /tmp/uv-cache:/root/.cache/uv
|
||||
environment:
|
||||
UV_CACHE_DIR: /root/.cache/uv
|
||||
UV_LINK_MODE: copy
|
||||
UV_PYTHON: "3.13"
|
||||
depends_on: [deps]
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv run --no-sync pytest tests/unit/
|
||||
|
||||
- name: test-e2e
|
||||
image: python:3.13
|
||||
volumes:
|
||||
- /tmp/uv-cache:/root/.cache/uv
|
||||
environment:
|
||||
UV_CACHE_DIR: /root/.cache/uv
|
||||
UV_LINK_MODE: copy
|
||||
UV_PYTHON: "3.13"
|
||||
depends_on: [deps]
|
||||
commands:
|
||||
- pip install uv
|
||||
- apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2
|
||||
- uv run --no-sync playwright install chromium
|
||||
- uv run --no-sync blog &
|
||||
- sleep 5
|
||||
- uv run --no-sync pytest tests/e2e/ -v --no-cov
|
||||
@@ -1,11 +0,0 @@
|
||||
when:
|
||||
- event: [push, pull_request]
|
||||
branch: dev
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: python:3.13
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --no-dev --group tests
|
||||
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing
|
||||
@@ -1,11 +0,0 @@
|
||||
when:
|
||||
- event: [push, pull_request]
|
||||
branch: dev
|
||||
|
||||
steps:
|
||||
- name: type
|
||||
image: python:3.13
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv sync --no-dev --only-group types
|
||||
- uv run mypy .
|
||||
@@ -48,14 +48,11 @@ tests = [
|
||||
"pytfm",
|
||||
]
|
||||
lints = [
|
||||
"black>=23.7.0",
|
||||
"ruff>=0.15.11",
|
||||
"isort>=8.0.1",
|
||||
]
|
||||
types = [
|
||||
"mimesis>=19.1.0",
|
||||
"mypy>=1.20.1",
|
||||
"pytfm",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -66,11 +63,10 @@ pytfm = { workspace = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "--cov=app --cov-report=term-missing --cov-report=html"
|
||||
addopts = "--cov=app --cov-fail-under=70 --cov-report=term-missing --cov-report=html"
|
||||
pythonpath = "."
|
||||
testpaths = "tests"
|
||||
xfail_strict = true
|
||||
exclude = ["tests/e2e"]
|
||||
markers = [
|
||||
"e2e: End-to-end tests requiring running server",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
14
tests/unit/infrastructure/test_di_container.py
Normal file
14
tests/unit/infrastructure/test_di_container.py
Normal 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)
|
||||
54
tests/unit/infrastructure/test_mock_auth.py
Normal file
54
tests/unit/infrastructure/test_mock_auth.py
Normal 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
|
||||
131
tests/unit/presentation/test_web_deps.py
Normal file
131
tests/unit/presentation/test_web_deps.py
Normal 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
|
||||
Reference in New Issue
Block a user