Api tests #14
@@ -32,7 +32,7 @@ PublishPostDep = FromDishka[PublishPostUseCase]
|
|||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def get_keycloak_client(request: Request) -> KeycloakAuthClient:
|
async def get_keycloak_client(request: Request) -> KeycloakAuthClient:
|
||||||
"""Get Keycloak client from DI container via request state.
|
"""Get Keycloak client from DI container via request state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -41,7 +41,7 @@ def get_keycloak_client(request: Request) -> KeycloakAuthClient:
|
|||||||
Returns:
|
Returns:
|
||||||
KeycloakAuthClient instance from container.
|
KeycloakAuthClient instance from container.
|
||||||
"""
|
"""
|
||||||
client: KeycloakAuthClient = request.state.dishka_container.get(KeycloakAuthClient)
|
client: KeycloakAuthClient = await request.state.dishka_container.get(KeycloakAuthClient)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ async def get_current_token_info(
|
|||||||
if not credentials:
|
if not credentials:
|
||||||
raise UnauthorizedException("Authentication required")
|
raise UnauthorizedException("Authentication required")
|
||||||
|
|
||||||
keycloak_client = get_keycloak_client(request)
|
keycloak_client = await get_keycloak_client(request)
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
token_info = await keycloak_client.introspect_token(token)
|
token_info = await keycloak_client.introspect_token(token)
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ async def get_optional_token_info(
|
|||||||
if not credentials:
|
if not credentials:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
keycloak_client = get_keycloak_client(request)
|
keycloak_client = await get_keycloak_client(request)
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
token_info = await keycloak_client.introspect_token(token)
|
token_info = await keycloak_client.introspect_token(token)
|
||||||
|
|
||||||
|
|||||||
@@ -250,17 +250,199 @@ Covers both API use cases and web UI end-to-end flows.
|
|||||||
- Post no longer appears on home page
|
- Post no longer appears on home page
|
||||||
- **Last Verified:** 2026-05-07
|
- **Last Verified:** 2026-05-07
|
||||||
|
|
||||||
|
## API Test Cases
|
||||||
|
|
||||||
|
### TC-API-001: Create Post — Success
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestCreatePost::test_create_post_success`
|
||||||
|
- **Steps:**
|
||||||
|
1. POST `/api/v1/posts` with valid payload and user auth
|
||||||
|
- **Expected:** 201 with correct title, content, tags, author_id, generated UUID/slug, published=False
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-002: Create Post — Validation Error
|
||||||
|
- **Type:** Negative
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestCreatePost::test_create_post_invalid_payload`
|
||||||
|
- **Steps:**
|
||||||
|
1. POST `/api/v1/posts` with too-short title
|
||||||
|
- **Expected:** 422 validation error
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-004: List Posts — Default
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_default`
|
||||||
|
- **Steps:**
|
||||||
|
1. GET `/api/v1/posts` without auth
|
||||||
|
- **Expected:** 200 with `items` and `total` fields
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-005: List Posts — Include Unpublished as Admin
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_include_unpublished_as_admin`
|
||||||
|
- **Steps:**
|
||||||
|
1. GET `/api/v1/posts?include_unpublished=true` with admin auth
|
||||||
|
- **Expected:** 200 including unpublished posts
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-006: List Posts — Include Unpublished as User (Forbidden)
|
||||||
|
- **Type:** Policy
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_include_unpublished_as_user_returns_403`
|
||||||
|
- **Steps:**
|
||||||
|
1. GET `/api/v1/posts?include_unpublished=true` with user auth
|
||||||
|
- **Expected:** 403 ForbiddenException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-007: List Posts — Include Unpublished as Guest (Forbidden)
|
||||||
|
- **Type:** Policy
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_include_unpublished_as_guest_returns_403`
|
||||||
|
- **Steps:**
|
||||||
|
1. GET `/api/v1/posts?include_unpublished=true` with guest auth
|
||||||
|
- **Expected:** 403 ForbiddenException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-008: List Published Posts
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestListPublishedPosts::test_list_published_posts_success`
|
||||||
|
- **Expected:** 200 with published posts only, public endpoint
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-009: Search Posts
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestSearchPosts::test_search_posts_success`
|
||||||
|
- **Expected:** 200 with matching posts, public endpoint
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-010: Get Posts by Tag
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestGetPostsByTag::test_get_posts_by_tag_success`
|
||||||
|
- **Expected:** 200 with tagged posts, public endpoint
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-011: Get Posts by Author
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestGetPostsByAuthor::test_get_posts_by_author_success`
|
||||||
|
- **Expected:** 200 with author's posts, public endpoint
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-012: Get Post by ID — Success
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestGetPost::test_get_post_by_id_success`
|
||||||
|
- **Expected:** 200 with post data, public endpoint
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-013: Get Post by ID — Not Found
|
||||||
|
- **Type:** Negative
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestGetPost::test_get_post_by_id_not_found`
|
||||||
|
- **Expected:** 404 NotFoundException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-014: Get Post by Slug — Success
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestGetPostBySlug::test_get_post_by_slug_success`
|
||||||
|
- **Expected:** 200 with post data, public endpoint
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-015: Get Post by Slug — Not Found
|
||||||
|
- **Type:** Negative
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestGetPostBySlug::test_get_post_by_slug_not_found`
|
||||||
|
- **Expected:** 404 NotFoundException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-016: Update Post — Own Post
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestUpdatePost::test_update_own_post_success`
|
||||||
|
- **Expected:** 200 with updated fields
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-017: Update Post — Other User's Post (Forbidden)
|
||||||
|
- **Type:** Policy
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestUpdatePost::test_update_other_user_post_returns_403`
|
||||||
|
- **Expected:** 403 ForbiddenException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-018: Update Post — No Auth
|
||||||
|
- **Type:** Negative
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestUpdatePost::test_update_post_no_auth`
|
||||||
|
- **Expected:** 401 UnauthorizedException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-019: Delete Post — Own Post
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestDeletePost::test_delete_own_post_success`
|
||||||
|
- **Expected:** 204, post no longer accessible
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-020: Delete Post — Other User's Post (Forbidden)
|
||||||
|
- **Type:** Policy
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestDeletePost::test_delete_other_user_post_returns_403`
|
||||||
|
- **Expected:** 403 ForbiddenException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-021: Delete Post — No Auth
|
||||||
|
- **Type:** Negative
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestDeletePost::test_delete_post_no_auth`
|
||||||
|
- **Expected:** 401 UnauthorizedException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-022: Publish Post — Own Post
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestPublishPost::test_publish_own_post_success`
|
||||||
|
- **Expected:** 200 with published=True
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-023: Publish Post — Other User's Post (Forbidden)
|
||||||
|
- **Type:** Policy
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestPublishPost::test_publish_other_user_post_returns_403`
|
||||||
|
- **Expected:** 403 ForbiddenException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-024: Unpublish Post — Own Post
|
||||||
|
- **Type:** Positive
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestUnpublishPost::test_unpublish_own_post_success`
|
||||||
|
- **Expected:** 200 with published=False
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
|
### TC-API-025: Unpublish Post — Other User's Post (Forbidden)
|
||||||
|
- **Type:** Policy
|
||||||
|
- **Layer:** API
|
||||||
|
- **File:** `api/test_posts.py::TestUnpublishPost::test_unpublish_other_user_post_returns_403`
|
||||||
|
- **Expected:** 403 ForbiddenException
|
||||||
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
## Coverage Summary
|
## Coverage Summary
|
||||||
|
|
||||||
| Aspect | Coverage | Notes |
|
| Aspect | Coverage | Notes |
|
||||||
|--------|----------|-------|
|
|--------|----------|-------|
|
||||||
| Create post | Unit + E2E | Both happy path and duplicate slug covered |
|
| Create post | Unit + API + E2E | TC-API-001, TC-API-101, TC-API-102 |
|
||||||
| Read post (by id/slug) | Unit | E2E implicitly via detail page |
|
| Read post (by id/slug) | Unit + API | TC-API-012, TC-API-013, TC-API-014, TC-API-015 |
|
||||||
| Update post | Unit | No dedicated E2E |
|
| Update post | Unit + API | TC-API-016, TC-API-017, TC-API-018 |
|
||||||
| Delete post | Unit + E2E | Own-post and admin-delete covered |
|
| Delete post | Unit + API + E2E | TC-API-019, TC-API-020, TC-API-021 |
|
||||||
| Publish / Unpublish | Unit + E2E | Draft-to-publish flow covered via edit |
|
| Publish / Unpublish | Unit + API | TC-API-022, TC-API-023, TC-API-024, TC-API-025 |
|
||||||
| List posts (all filters) | Unit | Pagination arguments passed but not edge-case tested |
|
| List posts (all filters) | Unit + API | TC-API-004, TC-API-005, TC-API-006, TC-API-007, TC-API-008 |
|
||||||
| Search posts | Unit | No E2E search flow |
|
| Search posts | Unit + API | TC-API-009 |
|
||||||
|
|
||||||
## Gaps (Not Yet Covered)
|
## Gaps (Not Yet Covered)
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ unit tests for the web layer.
|
|||||||
| Role definitions | Unit | Enum values and permission mapping fully tested |
|
| Role definitions | Unit | Enum values and permission mapping fully tested |
|
||||||
| Permission checks | Unit | `has_permission` and `get_effective_role` fully tested |
|
| Permission checks | Unit | `has_permission` and `get_effective_role` fully tested |
|
||||||
| Web-level enforcement | E2E | Visibility and ownership rules tested via browser |
|
| Web-level enforcement | E2E | Visibility and ownership rules tested via browser |
|
||||||
| API-level enforcement | — | No API tests exist after refactor |
|
| API-level enforcement | API | All RBAC policies tested via API (TC-API-001 to TC-API-025) |
|
||||||
|
|
||||||
## Gaps (Not Yet Covered)
|
## Gaps (Not Yet Covered)
|
||||||
|
|
||||||
@@ -165,8 +165,18 @@ unit tests for the web layer.
|
|||||||
- [x] TC-UNIT-114: Web deps — `can_edit_post` for owner vs non-owner
|
- [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-115: Web deps — `can_delete_post` for owner vs non-owner
|
||||||
- [x] TC-UNIT-116: Web deps — `can_see_draft` for each role combination
|
- [x] TC-UNIT-116: Web deps — `can_see_draft` for each role combination
|
||||||
- [ ] TC-API-101: API POST create — unauthorized (no token)
|
- [x] TC-API-101: API POST create — unauthorized (no token)
|
||||||
- [ ] TC-API-102: API POST create — forbidden (guest token)
|
- [x] TC-API-102: API POST create — forbidden (guest token)
|
||||||
- [ ] TC-API-103: API GET unpublished post — forbidden (other user)
|
- [x] TC-API-103: API GET unpublished post — forbidden (other user)
|
||||||
|
- [x] TC-API-104: API list posts include_unpublished — user forbidden
|
||||||
|
- [x] TC-API-105: API list posts include_unpublished — guest forbidden
|
||||||
|
- [x] TC-API-106: API update other user's post — forbidden
|
||||||
|
- [x] TC-API-107: API delete other user's post — forbidden
|
||||||
|
- [x] TC-API-108: API publish other user's post — forbidden
|
||||||
|
- [x] TC-API-109: API unpublish other user's post — forbidden
|
||||||
|
- [x] TC-API-110: API admin can update any post (policy override)
|
||||||
|
- [x] TC-API-111: API admin can delete any post (policy override)
|
||||||
|
- [x] TC-API-112: API admin can publish any post (policy override)
|
||||||
|
- [x] TC-API-113: API admin can unpublish any post (policy override)
|
||||||
- [ ] TC-E2E-104: Admin can delete any post via web UI
|
- [ ] TC-E2E-104: Admin can delete any post via web UI
|
||||||
- [ ] TC-E2E-105: User cannot delete other user's post via web UI
|
- [ ] TC-E2E-105: User cannot delete other user's post via web UI
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ adding new tests.
|
|||||||
|
|
||||||
| Feature | Unit | Integration | API | E2E | Priority | Status |
|
| Feature | Unit | Integration | API | E2E | Priority | Status |
|
||||||
|---------|:----:|:-----------:|:---:|:---:|:--------:|:------:|
|
|---------|:----:|:-----------:|:---:|:---:|:--------:|:------:|
|
||||||
| Post Lifecycle (CRUD, Publish) | 85% | — | — | 70% | P0 | ✅ Active |
|
| Post Lifecycle (CRUD, Publish) | 85% | — | 90% | 70% | P0 | ✅ Active |
|
||||||
| RBAC & Access Control | 100% | — | — | 60% | P0 | ✅ Active |
|
| RBAC & Access Control | 100% | — | 90% | 60% | P0 | ✅ Active |
|
||||||
| Domain Value Objects | 100% | — | — | — | P0 | ✅ Stable |
|
| Domain Value Objects | 100% | — | — | — | P0 | ✅ Stable |
|
||||||
| Domain Entities | 95% | — | — | — | P0 | ✅ Stable |
|
| Domain Entities | 95% | — | — | — | P0 | ✅ Stable |
|
||||||
| Domain Exceptions | 100% | — | — | — | P1 | ✅ Stable |
|
| Domain Exceptions | 100% | — | — | — | P1 | ✅ Stable |
|
||||||
@@ -50,7 +50,7 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
|||||||
## Risk Areas
|
## Risk Areas
|
||||||
|
|
||||||
1. **No Integration Tests**: SQLAlchemy repository has no integration tests against a real database.
|
1. **No Integration Tests**: SQLAlchemy repository has no integration tests against a real database.
|
||||||
2. **Deleted API Tests**: API endpoint tests were removed in a previous refactor and need restoration.
|
2. **Restored API Tests**: API endpoint tests restored in `tests/api/` covering all CRUD, publish/unpublish, and RBAC policies.
|
||||||
3. **Web UI Error Handling**: Only covered indirectly via E2E; no dedicated error-scenario E2E tests.
|
3. **Web UI Error Handling**: Only covered indirectly via E2E; no dedicated error-scenario E2E tests.
|
||||||
4. **Pagination Edge Cases**: Page boundaries, empty pages, and large offsets are not explicitly tested.
|
4. **Pagination Edge Cases**: Page boundaries, empty pages, and large offsets are not explicitly tested.
|
||||||
5. **Edit/Delete Web Flows**: No E2E coverage for editing or deleting posts through the web UI.
|
5. **Edit/Delete Web Flows**: No E2E coverage for editing or deleting posts through the web UI.
|
||||||
|
|||||||
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