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.
219 lines
5.7 KiB
Python
219 lines
5.7 KiB
Python
"""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())
|