test(api): add full API test suite with get_keycloak_client async fix
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Add 45 API tests covering all 12 post endpoints (CRUD, publish/unpublish) with RBAC policy coverage across guest, user, admin roles. Fix get_keycloak_client() in deps.py to be async - Dishka's async container requires await on get(), without it a coroutine object was returned instead of the actual client.
This commit is contained in:
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
218
tests/api/conftest.py
Normal file
218
tests/api/conftest.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""API test configuration.
|
||||
|
||||
This module provides fixtures for testing API endpoints with a real database
|
||||
and mock authentication client in development mode.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from app.infrastructure.config import settings
|
||||
from app.infrastructure.database.models import Base
|
||||
from app.main import app_factory
|
||||
|
||||
API_PREFIX = "/api/v1/posts"
|
||||
|
||||
USER_TOKEN = "dev-token-user"
|
||||
USER2_TOKEN = "dev-token-user2"
|
||||
ADMIN_TOKEN = "dev-token-admin"
|
||||
GUEST_TOKEN = "dev-token-guest"
|
||||
|
||||
USER_ID = "dev-user"
|
||||
USER2_ID = "dev-user2"
|
||||
ADMIN_ID = "dev-admin"
|
||||
|
||||
|
||||
def _sync_url(db_url: str) -> str:
|
||||
"""Strip async driver suffix for sync engine.
|
||||
|
||||
Args:
|
||||
db_url: Database URL with async driver.
|
||||
|
||||
Returns:
|
||||
Database URL without async driver suffix.
|
||||
"""
|
||||
return db_url.replace("+aiosqlite", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def _build_alembic_config(db_url: str) -> Config:
|
||||
"""Build alembic config with given database URL.
|
||||
|
||||
Args:
|
||||
db_url: Database URL to use.
|
||||
|
||||
Returns:
|
||||
Alembic Config instance.
|
||||
"""
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
return alembic_cfg
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator[asyncio.AbstractEventLoop]:
|
||||
"""Create event loop for the test session.
|
||||
|
||||
Yields:
|
||||
Event loop instance.
|
||||
"""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database() -> Generator[None]:
|
||||
"""Set up database tables once per test session.
|
||||
|
||||
Drops and recreates all tables, then stamps alembic head
|
||||
so that alembic-aware operations (like E2E tests) can proceed.
|
||||
Uses sync SQLAlchemy engine to avoid async complications.
|
||||
"""
|
||||
db_url = os.environ.get("DB_URL", settings.database_url)
|
||||
sync_engine = create_engine(_sync_url(db_url))
|
||||
Base.metadata.drop_all(sync_engine)
|
||||
Base.metadata.create_all(sync_engine)
|
||||
sync_engine.dispose()
|
||||
|
||||
alembic_cfg = _build_alembic_config(db_url)
|
||||
command.stamp(alembic_cfg, "head")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app() -> TestClient:
|
||||
"""Create FastAPI test application.
|
||||
|
||||
The app uses MockKeycloakClient in dev mode, so auth tokens
|
||||
like 'dev-token-user', 'dev-token-admin', etc. are recognized.
|
||||
Lifespan is not entered (no init_db/close_db) — the
|
||||
setup_database fixture handles table creation instead.
|
||||
|
||||
Returns:
|
||||
Configured TestClient instance.
|
||||
"""
|
||||
return TestClient(app_factory())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app: TestClient) -> TestClient:
|
||||
"""Provide TestClient for API requests.
|
||||
|
||||
The TestClient is created without entering the app lifespan
|
||||
to avoid disposing the module-level database engine. Database
|
||||
lifecycle is managed by setup_database fixture.
|
||||
|
||||
Args:
|
||||
app: Test application fixture.
|
||||
|
||||
Returns:
|
||||
TestClient instance.
|
||||
"""
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_headers() -> dict[str, str]:
|
||||
"""Headers for standard user authentication.
|
||||
|
||||
User has role 'user', user_id 'dev-user'.
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {USER_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_headers() -> dict[str, str]:
|
||||
"""Headers for second user authentication.
|
||||
|
||||
User2 has role 'user', user_id 'dev-user2'. Used for ownership
|
||||
policy tests (editing/deleting another user's post).
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {USER2_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers() -> dict[str, str]:
|
||||
"""Headers for admin authentication.
|
||||
|
||||
Admin has role 'admin', user_id 'dev-admin'. Can edit/delete
|
||||
any post regardless of ownership.
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guest_headers() -> dict[str, str]:
|
||||
"""Headers for guest (inactive token).
|
||||
|
||||
Guest token returns inactive TokenInfo, which the API treats
|
||||
as unauthenticated.
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {GUEST_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_payload() -> dict[str, Any]:
|
||||
"""Generate a unique post payload for testing.
|
||||
|
||||
Uses uuid4 for title to ensure unique slug generation
|
||||
across tests. Prevents slug uniqueness constraint errors.
|
||||
|
||||
Returns:
|
||||
Dict with title, content, and tags fields.
|
||||
"""
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
return {
|
||||
"title": f"Test Post {unique_id}",
|
||||
"content": f"This is the content of test post {unique_id}. It has enough length to pass validation.",
|
||||
"tags": ["test", f"tag-{unique_id}"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def created_post(
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a post via API and return its response data.
|
||||
|
||||
Used as test data for read, update, delete, and publish tests.
|
||||
Created by the standard user ('dev-user').
|
||||
|
||||
Args:
|
||||
client: TestClient fixture.
|
||||
user_headers: User auth headers.
|
||||
post_payload: Post creation payload.
|
||||
|
||||
Returns:
|
||||
Post response data dict.
|
||||
"""
|
||||
response = client.post(
|
||||
f"{API_PREFIX}",
|
||||
json=post_payload,
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 201, f"Failed to create test post: {response.text}"
|
||||
return cast(dict[str, Any], response.json())
|
||||
788
tests/api/test_posts.py
Normal file
788
tests/api/test_posts.py
Normal file
@@ -0,0 +1,788 @@
|
||||
"""API tests for blog post CRUD and publish operations.
|
||||
|
||||
This module tests all 12 blog post API endpoints covering create, read,
|
||||
update, delete, publish, and unpublish operations with full authorization
|
||||
policy coverage across guest, user, and admin roles.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.api.conftest import API_PREFIX, USER_ID
|
||||
|
||||
|
||||
class TestCreatePost:
|
||||
"""Tests for POST /api/v1/posts — create a new blog post."""
|
||||
|
||||
def test_create_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a post returns 201 with correct fields.
|
||||
|
||||
TC-API-001: Positive — create post as authenticated user.
|
||||
"""
|
||||
response = client.post(API_PREFIX, json=post_payload, headers=user_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["title"] == post_payload["title"]
|
||||
assert data["content"] == post_payload["content"]
|
||||
assert data["author_id"] == USER_ID
|
||||
assert data["published"] is False
|
||||
assert data["tags"] == post_payload["tags"]
|
||||
assert UUID(data["id"])
|
||||
assert data["slug"]
|
||||
assert data["created_at"]
|
||||
assert data["updated_at"]
|
||||
|
||||
def test_create_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a post without auth returns 401.
|
||||
|
||||
TC-API-101: Negative — no authorization header.
|
||||
"""
|
||||
response = client.post(API_PREFIX, json=post_payload)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_create_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a post with inactive token returns 401.
|
||||
|
||||
TC-API-102: Negative — guest/inactive token.
|
||||
"""
|
||||
response = client.post(API_PREFIX, json=post_payload, headers=guest_headers)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_create_post_invalid_payload(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test creating a post with too-short title returns 422."""
|
||||
response = client.post(
|
||||
API_PREFIX,
|
||||
json={"title": "ab", "content": "valid content here"},
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestListPosts:
|
||||
"""Tests for GET /api/v1/posts — list posts with filters."""
|
||||
|
||||
def test_list_posts_default(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test default listing returns published posts.
|
||||
|
||||
TC-API-004: Positive — default listing without auth.
|
||||
"""
|
||||
response = client.get(API_PREFIX)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert isinstance(data["items"], list)
|
||||
assert isinstance(data["total"], int)
|
||||
|
||||
def test_list_posts_pagination(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test listing posts with limit and offset query params."""
|
||||
for i in range(3):
|
||||
payload = {
|
||||
"title": f"Pagination Post {i}",
|
||||
"content": f"Content for pagination test post {i}. Enough characters here.",
|
||||
"tags": [],
|
||||
}
|
||||
client.post(API_PREFIX, json=payload, headers=user_headers)
|
||||
|
||||
response = client.get(f"{API_PREFIX}?limit=2&offset=0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) <= 2
|
||||
|
||||
def test_list_posts_include_unpublished_as_admin(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test admin can list unpublished posts.
|
||||
|
||||
TC-API-005: Positive — admin can include unpublished.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
|
||||
def test_list_posts_include_unpublished_as_user_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test user gets 403 when requesting unpublished posts.
|
||||
|
||||
TC-API-006: Policy — user cannot list unpublished.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true", headers=user_headers)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_list_posts_include_unpublished_as_guest_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test guest gets 403 when requesting unpublished posts.
|
||||
|
||||
TC-API-007: Policy — guest cannot list unpublished.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true", headers=guest_headers)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_list_posts_without_auth_include_unpublished_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test anonymous gets 403 when requesting unpublished posts."""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true")
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
|
||||
class TestListPublishedPosts:
|
||||
"""Tests for GET /api/v1/posts/published — list published posts."""
|
||||
|
||||
def test_list_published_posts_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test listing published posts returns 200 with items.
|
||||
|
||||
TC-API-008: Positive — public endpoint, no auth needed.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}/published")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
|
||||
class TestSearchPosts:
|
||||
"""Tests for GET /api/v1/posts/search — search posts."""
|
||||
|
||||
def test_search_posts_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test searching posts returns matching results.
|
||||
|
||||
TC-API-009: Positive — public endpoint, no auth needed.
|
||||
"""
|
||||
payload = {
|
||||
"title": "Searchable Unique Title",
|
||||
"content": "This content contains a very special search keyword. Enough length.",
|
||||
"tags": [],
|
||||
}
|
||||
client.post(API_PREFIX, json=payload, headers=user_headers)
|
||||
|
||||
response = client.get(f"{API_PREFIX}/search?query=Searchable")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
assert any("Searchable" in item["title"] for item in data["items"])
|
||||
|
||||
def test_search_posts_no_results(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test searching with no matches returns empty list."""
|
||||
response = client.get(f"{API_PREFIX}/search?query=xyznonexistent12345")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_search_posts_public(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test search is accessible without authentication."""
|
||||
response = client.get(f"{API_PREFIX}/search?query=test")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetPostsByTag:
|
||||
"""Tests for GET /api/v1/posts/by-tag/{tag} — posts by tag."""
|
||||
|
||||
def test_get_posts_by_tag_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test filtering posts by tag returns matching items.
|
||||
|
||||
TC-API-010: Positive — public endpoint.
|
||||
"""
|
||||
payload = {
|
||||
"title": "Tagged Test Post",
|
||||
"content": "Post with a specific tag for testing. Enough characters here.",
|
||||
"tags": ["unique-test-tag-xyz"],
|
||||
}
|
||||
client.post(API_PREFIX, json=payload, headers=user_headers)
|
||||
|
||||
response = client.get(f"{API_PREFIX}/by-tag/unique-test-tag-xyz")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
def test_get_posts_by_tag_no_results(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test filtering by nonexistent tag returns empty list."""
|
||||
response = client.get(f"{API_PREFIX}/by-tag/nonexistent-tag-xyz-123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
|
||||
|
||||
class TestGetPostsByAuthor:
|
||||
"""Tests for GET /api/v1/posts/by-author/{author_id} — posts by author."""
|
||||
|
||||
def test_get_posts_by_author_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test filtering posts by author returns their posts.
|
||||
|
||||
TC-API-011: Positive — public endpoint.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}/by-author/{USER_ID}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
assert all(item["author_id"] == USER_ID for item in data["items"])
|
||||
|
||||
def test_get_posts_by_author_no_results(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test filtering by nonexistent author returns empty list."""
|
||||
response = client.get(f"{API_PREFIX}/by-author/nonexistent-author-123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
|
||||
|
||||
class TestGetPost:
|
||||
"""Tests for GET /api/v1/posts/{post_id} — get post by ID."""
|
||||
|
||||
def test_get_post_by_id_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test getting a post by ID returns 200 with correct data.
|
||||
|
||||
TC-API-012: Positive — public endpoint.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == post_id
|
||||
assert data["title"] == created_post["title"]
|
||||
assert data["author_id"] == USER_ID
|
||||
|
||||
def test_get_post_by_id_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test getting a nonexistent post returns 404.
|
||||
|
||||
TC-API-013: Negative — nonexistent post ID.
|
||||
"""
|
||||
fake_id = "00000000-0000-0000-0000-000000000000"
|
||||
response = client.get(f"{API_PREFIX}/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
def test_get_unpublished_post_by_id(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test that unpublished posts are accessible by ID.
|
||||
|
||||
The current implementation does not filter by published status
|
||||
for individual post retrieval.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
assert created_post["published"] is False
|
||||
response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is False
|
||||
|
||||
|
||||
class TestGetPostBySlug:
|
||||
"""Tests for GET /api/v1/posts/slug/{slug} — get post by slug."""
|
||||
|
||||
def test_get_post_by_slug_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test getting a post by slug returns 200 with correct data.
|
||||
|
||||
TC-API-014: Positive — public endpoint.
|
||||
"""
|
||||
slug = created_post["slug"]
|
||||
response = client.get(f"{API_PREFIX}/slug/{slug}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["slug"] == slug
|
||||
assert data["id"] == created_post["id"]
|
||||
|
||||
def test_get_post_by_slug_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test getting a post by nonexistent slug returns 404.
|
||||
|
||||
TC-API-015: Negative — nonexistent slug.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}/slug/nonexistent-slug-xyz-123")
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
|
||||
class TestUpdatePost:
|
||||
"""Tests for PATCH /api/v1/posts/{post_id} — update a post."""
|
||||
|
||||
def test_update_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating own post returns 200 with updated fields.
|
||||
|
||||
TC-API-016: Positive — owner updates own post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
update_data = {"title": "Updated Title For Testing"}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == "Updated Title For Testing"
|
||||
assert data["id"] == post_id
|
||||
assert data["updated_at"] != created_post["updated_at"]
|
||||
|
||||
def test_update_own_post_all_fields(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating all fields of own post."""
|
||||
post_id = created_post["id"]
|
||||
update_data = {
|
||||
"title": "Completely New Title Here",
|
||||
"content": "Updated content with sufficient length for validation check.",
|
||||
"tags": ["new-tag"],
|
||||
}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == update_data["title"]
|
||||
assert data["content"] == update_data["content"]
|
||||
assert data["tags"] == update_data["tags"]
|
||||
|
||||
def test_update_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating another user's post returns 403.
|
||||
|
||||
TC-API-017: Policy — user2 cannot update user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
update_data = {"title": "Unauthorized Update Attempt"}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=user2_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_update_post_admin_can_update_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can update any user's post."""
|
||||
post_id = created_post["id"]
|
||||
update_data = {"title": "Admin Updated This Post Title"}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == "Admin Updated This Post Title"
|
||||
|
||||
def test_update_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating a post without auth returns 401.
|
||||
|
||||
TC-API-018: Negative — no authorization header.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json={"title": "No Auth Update Attempt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_update_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating a post with guest token returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json={"title": "Guest Update Attempt"},
|
||||
headers=guest_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
|
||||
class TestDeletePost:
|
||||
"""Tests for DELETE /api/v1/posts/{post_id} — delete a post."""
|
||||
|
||||
def test_delete_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting own post returns 204.
|
||||
|
||||
TC-API-019: Positive — owner deletes own post.
|
||||
"""
|
||||
create_resp = client.post(API_PREFIX, json=post_payload, headers=user_headers)
|
||||
post_id = create_resp.json()["id"]
|
||||
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=user_headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
get_response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_delete_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting another user's post returns 403.
|
||||
|
||||
TC-API-020: Policy — user2 cannot delete user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=user2_headers)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_delete_post_admin_can_delete_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can delete any user's post."""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=admin_headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
get_response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_delete_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting a post without auth returns 401.
|
||||
|
||||
TC-API-021: Negative — no authorization header.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}")
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_delete_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting a post with guest token returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=guest_headers)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
|
||||
class TestPublishPost:
|
||||
"""Tests for POST /api/v1/posts/{post_id}/publish — publish a post."""
|
||||
|
||||
def test_publish_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing own post returns 200 with published=True.
|
||||
|
||||
TC-API-022: Positive — owner publishes own post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
assert created_post["published"] is False
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is True
|
||||
assert data["id"] == post_id
|
||||
|
||||
def test_publish_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing another user's post returns 403.
|
||||
|
||||
TC-API-023: Policy — user2 cannot publish user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=user2_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_publish_admin_can_publish_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can publish any user's post."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is True
|
||||
|
||||
def test_publish_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing a post without auth returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(f"{API_PREFIX}/{post_id}/publish")
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_publish_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing a post with guest token returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=guest_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_publish_post_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test publishing a nonexistent post returns 404."""
|
||||
fake_id = "00000000-0000-0000-0000-000000000000"
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{fake_id}/publish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
|
||||
class TestUnpublishPost:
|
||||
"""Tests for POST /api/v1/posts/{post_id}/unpublish — unpublish a post."""
|
||||
|
||||
def test_unpublish_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test unpublishing own post returns 200 with published=False.
|
||||
|
||||
TC-API-024: Positive — owner unpublishes own post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
client.post(f"{API_PREFIX}/{post_id}/publish", headers=user_headers)
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/unpublish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is False
|
||||
assert data["id"] == post_id
|
||||
|
||||
def test_unpublish_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test unpublishing another user's post returns 403.
|
||||
|
||||
TC-API-025: Policy — user2 cannot unpublish user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/unpublish",
|
||||
headers=user2_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_unpublish_admin_can_unpublish_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can unpublish any user's post."""
|
||||
post_id = created_post["id"]
|
||||
|
||||
client.post(f"{API_PREFIX}/{post_id}/publish", headers=admin_headers)
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/unpublish",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is False
|
||||
|
||||
def test_unpublish_post_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test unpublishing a nonexistent post returns 404."""
|
||||
fake_id = "00000000-0000-0000-0000-000000000000"
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{fake_id}/unpublish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
def test_unpublish_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test unpublishing a post without auth returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(f"{API_PREFIX}/{post_id}/unpublish")
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
Reference in New Issue
Block a user