feat: RBAC E2E тесты и фикс admin-прав для редактирования постов

Основные изменения:
- Добавлены E2E тесты для проверки ownership (TC-E2E-102/103):
  * test_admin_can_edit_any_post — admin может редактировать любой пост
  * test_user_cannot_edit_other_users_post — user не может редактировать чужой пост
- Исправлены use cases (UpdatePost, DeletePost, PublishPost) — добавлена проверка роли admin
- Обновлены web routes и API routes для передачи роли в use cases
- Добавлены unit тесты для admin-сценариев

Реструктуризация тестов:
- Удалены старые API тесты (tests/api/) — требуют переработки
- Удалены старые integration тесты (tests/integration/)
- Переработаны E2E тесты: удалены старые, добавлены новые с POM
- Добавлена документация тестов: FEATURE_*.md, TEST_MODEL.md, AGENTS.md

Инфраструктура:
- Добавлен MockKeycloakClient для dev-режима
- Добавлены статические файлы: EasyMDE, Highlight.js, стили markdown
- Обновлены шаблоны: base.html, post_form.html, post_detail.html
- Обновлена DI конфигурация и провайдеры

Документация:
- tests/FEATURE_RBAC.md — матрица тестов RBAC
- tests/FEATURE_POST_LIFECYCLE.md — тесты жизненного цикла поста
- tests/FEATURE_DOMAIN_FOUNDATION.md — тесты доменного слоя
- tests/FEATURE_INFRASTRUCTURE.md — тесты инфраструктуры
- tests/TEST_MODEL.md — глобальная матрица покрытия
- app/presentation/web/AGENTS.md — гайд по Web UI
- tests/AGENTS.md — гайд по тестированию
This commit is contained in:
2026-05-07 19:55:15 +03:00
parent 41f2a3d98e
commit 46cc06b596
58 changed files with 4234 additions and 4014 deletions

View File

@@ -1,476 +1,299 @@
"""E2E test fixtures with isolated test server.
"""E2E test configuration for blog application.
Provides fixtures for running E2E tests with:
- Isolated SQLite database per test session
- In-memory fake Keycloak (no external server needed)
- Test server on random port
- Automatic test user creation and authentication
Provides DevAuthProvider for cookie-based dev authentication
and role-specific browser context fixtures.
"""
import asyncio
import contextlib
import os
import socket
import tempfile
from typing import Any
from __future__ import annotations
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
import httpx
import pytest
from dishka import Provider, Scope, make_async_container, provide
from dishka.integrations.fastapi import setup_dishka
from fastapi import FastAPI
from playwright.sync_api import Browser, BrowserContext
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from pytfm.auth import AuthProvider, TestUser
from app.infrastructure.auth import KeycloakAuthClient
from app.infrastructure.config.settings import Settings
from tests.e2e.fake_keycloak import FakeKeycloakClient
if TYPE_CHECKING:
from playwright.sync_api import Browser, BrowserContext, Page
def pytest_configure(config):
"""Disable pytest-asyncio for E2E tests.
class DevAuthProvider(AuthProvider):
"""Authentication provider for blog dev mode.
pytest-playwright manages its own event loop and conflicts
with pytest-asyncio. We disable asyncio_mode for E2E tests.
Bypasses real Keycloak by generating dev-specific tokens
recognized by MockKeycloakClient.
Attributes:
_users: Mapping of usernames to test users.
"""
if hasattr(config, "option") and hasattr(config.option, "asyncio_mode"):
config.option.asyncio_mode = None
def __init__(self) -> None:
"""Initialize the dev auth provider."""
self._users: dict[str, TestUser] = {}
def _get_free_port() -> int:
"""Get a free TCP port from the OS."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def create_user(
self,
username: str,
password: str,
email: str,
roles: list[str] | None = None,
) -> TestUser:
"""Create a test user mapped to a dev token role.
Args:
username: Login name (used as display name).
password: Password (ignored in dev mode).
email: Email address.
roles: List of roles. First role determines dev token.
@pytest.fixture(scope="session")
def browser_type_launch_args() -> dict:
"""Return launch args for browser - ensure headless mode."""
return {"headless": True}
@pytest.fixture(scope="session")
def test_db_path() -> str:
"""Create temporary database file for test session."""
fd, path = tempfile.mkstemp(suffix=".db", prefix="blog_e2e_")
os.close(fd)
yield path
with contextlib.suppress(FileNotFoundError):
os.unlink(path)
@pytest.fixture(scope="session")
def test_database_url(test_db_path: str) -> str:
"""Build database URL for test database."""
return f"sqlite+aiosqlite:///{test_db_path}"
@pytest.fixture(scope="session")
def test_settings(test_database_url: str) -> Settings:
"""Create test settings with isolated database."""
return Settings(
environment="dev",
app={"name": "Blog E2E Test", "debug": True, "host": "127.0.0.1", "port": 0},
db={"url": test_database_url, "echo": False},
kc={"server_url": "http://fake-keycloak:8080", "realm": "test", "client_id": "test"},
security={"secret_key": "test-secret-key-not-for-production"},
)
@pytest.fixture(scope="session")
def test_engine(test_database_url: str):
"""Create database engine for test session."""
import asyncio
engine = create_async_engine(
test_database_url,
echo=False,
future=True,
)
yield engine
# Cleanup
asyncio.run(engine.dispose())
@pytest.fixture(scope="session")
def fake_keycloak():
"""Create fake Keycloak client for testing."""
client = FakeKeycloakClient(token_ttl=3600)
yield client
client.clear()
class FakeKeycloakProvider(Provider):
"""Provider that supplies fake Keycloak client."""
def __init__(self, fake_client: FakeKeycloakClient) -> None:
"""Initialize with fake client."""
self._fake_client = fake_client
super().__init__()
@provide(scope=Scope.APP)
def get_keycloak_client(self) -> KeycloakAuthClient:
"""Provide fake Keycloak client."""
return self._fake_client
@pytest.fixture(scope="session")
def test_server(
test_settings: Settings,
test_engine: AsyncEngine,
fake_keycloak: FakeKeycloakClient,
):
"""Start test server on random port using threading for sync compatibility."""
import threading
import time
from app.infrastructure.database.models import Base
from app.presentation import router
from app.presentation.web import router as web_router
from app.presentation.web.error_handlers import register_error_handlers
from app.presentation.web.flash import setup_flash_manager
port = _get_free_port()
base_url = f"http://127.0.0.1:{port}"
print(f"\n[TestServer] Starting server on port {port}")
# Initialize database using asyncio.run
async def init_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
asyncio.run(init_db())
print("[TestServer] Database initialized")
except Exception as e:
print(f"[TestServer] Database init failed: {e}")
raise
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.infrastructure.di.providers import (
DatabaseProvider,
RepositoryProvider,
TransactionManagerProvider,
UseCaseProvider,
)
app = FastAPI(
title=test_settings.app.name,
debug=test_settings.app.debug,
docs_url="/docs",
redoc_url="/redoc",
)
container = make_async_container(
DatabaseProvider(),
RepositoryProvider(),
TransactionManagerProvider(),
UseCaseProvider(),
FakeKeycloakProvider(fake_keycloak),
)
setup_dishka(container, app)
from app.infrastructure import register_exception_handlers
register_exception_handlers(app)
register_error_handlers(app)
@app.middleware("http")
async def flash_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
await setup_flash_manager(request)
response = await call_next(request)
return response
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root_redirect() -> Response:
from fastapi.responses import HTMLResponse
return HTMLResponse(
content='<meta http-equiv="refresh" content="0;url=/web/">', status_code=200
Returns:
Created TestUser instance.
"""
role = (roles or ["user"])[0]
user = TestUser(
id=f"dev-{role}",
username=username,
email=email,
password=password,
roles=roles or ["user"],
)
self._users[username] = user
return user
@app.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "ok", "env": "e2e-test"}
def login(self, username: str, password: str) -> str:
"""Return dev token for the user's role.
# Include main routers
app.include_router(router, prefix="/api")
app.include_router(web_router)
Args:
username: User login name.
password: User password (ignored).
# Add fake auth routes instead of including auth_router
async def fake_login(request: Request, redirect: str = "/web/") -> Response:
from fastapi.responses import HTMLResponse
Returns:
Dev authentication token string.
html = f'''
<!DOCTYPE html>
<html>
<head><title>Test Login</title></head>
<body>
<h1>Test Login Page</h1>
<form method="post" action="/auth/callback">
<input type="hidden" name="redirect" value="{redirect}">
<select name="username">
<option value="testuser">Test User</option>
<option value="admin">Admin User</option>
</select>
<button type="submit">Login</button>
</form>
</body>
</html>
'''
return HTMLResponse(content=html)
Raises:
ValueError: If user does not exist.
"""
user = self._users.get(username)
if not user:
raise ValueError("User not found")
role = user.roles[0] if user.roles else "user"
return f"dev-token-{role}"
@app.post("/auth/callback")
async def fake_callback(request: Request) -> Response:
from fastapi.responses import RedirectResponse
def build_auth_cookie(self, token: str, domain: str) -> dict[str, Any]:
"""Build access_token cookie for blog dev auth.
form = await request.form()
username = form.get("username", "testuser")
redirect = form.get("redirect", "/web/")
Args:
token: Dev authentication token.
domain: Cookie domain.
try:
user = fake_keycloak.create_user(
username=username,
password="test",
email=f"{username}@test.com",
roles=["admin" if username == "admin" else "user"],
)
except ValueError:
_ = fake_keycloak._users.get(username)
token = fake_keycloak.login(username, "test")
response = RedirectResponse(url=redirect, status_code=302)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=3600,
)
return response
@app.get("/auth/logout")
async def fake_logout(request: Request) -> Response:
from fastapi.responses import RedirectResponse
response = RedirectResponse(url="/web/", status_code=302)
response.delete_cookie(key="access_token")
return response
app.mount("/static", StaticFiles(directory="static"), name="static")
import uvicorn
from uvicorn import Config
config = Config(app=app, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
# Run server in a separate thread with error handling
server_exception = None
def run_server():
nonlocal server_exception
try:
asyncio.run(server.serve())
except Exception as e:
server_exception = e
print(f"[TestServer] Server error: {e}")
import traceback
traceback.print_exc()
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
print("[TestServer] Server thread started")
# Wait for server to be ready using sync httpx
server_started = False
last_error = None
for attempt in range(50):
try:
response = httpx.get(f"{base_url}/health", timeout=0.5)
if response.status_code == 200:
server_started = True
print(f"[TestServer] Server ready after {attempt + 1} attempts")
break
except (httpx.ConnectError, httpx.TimeoutException) as e:
last_error = e
time.sleep(0.1)
if not server_started:
print(f"[TestServer] Server failed to start after 50 attempts. Last error: {last_error}")
server.should_exit = True
raise RuntimeError(f"Test server failed to start after 50 attempts on port {port}")
# Test that web routes work before yielding
try:
test_response = httpx.get(f"{base_url}/web/", timeout=2.0, follow_redirects=True)
print(f"[TestServer] Test /web/: {test_response.status_code}")
except Exception as e:
print(f"[TestServer] Test /web/ failed: {e}")
# Test auth redirect
try:
test_response = httpx.get(f"{base_url}/web/posts/new", timeout=2.0, follow_redirects=True)
print(f"[TestServer] Test /web/posts/new: {test_response.status_code}")
print(f"[TestServer] Final URL: {test_response.url}")
except Exception as e:
print(f"[TestServer] Test /web/posts/new failed: {e}")
print(f"[TestServer] Yielding server info: {base_url}")
yield {"base_url": base_url, "port": port, "fake_keycloak": fake_keycloak}
# Cleanup
print("[TestServer] Shutting down server...")
server.should_exit = True
server_thread.join(timeout=5.0)
print("[TestServer] Server shutdown complete")
Returns:
Cookie dict compatible with Playwright.
"""
return {
"name": "access_token",
"value": token,
"domain": domain,
"path": "/",
"httpOnly": True,
"secure": False,
"sameSite": "Lax",
}
@pytest.fixture(scope="session")
def base_url(test_server: dict[str, Any]) -> str:
"""Get base URL of running test server."""
return test_server["base_url"]
def base_url() -> str:
"""Return the base URL for the blog application.
Returns:
Application base URL.
"""
return "http://127.0.0.1:8000"
@pytest.fixture(scope="session")
def keycloak_client(test_server: dict[str, Any]) -> FakeKeycloakClient:
"""Get fake Keycloak client from test server."""
return test_server["fake_keycloak"]
def pytfm_auth_provider() -> DevAuthProvider:
"""Return DevAuthProvider for blog dev mode.
Returns:
DevAuthProvider instance.
"""
return DevAuthProvider()
@pytest.fixture
def test_user_data() -> dict[str, str]:
"""Generate test user data."""
import uuid
unique_id = uuid.uuid4().hex[:8]
return {
"username": f"testuser_{unique_id}",
"email": f"test_{unique_id}@example.com",
"password": "TestPass123!",
}
@pytest.fixture
def authenticated_context(
def _create_authenticated_context(
browser: Browser,
keycloak_client: FakeKeycloakClient,
test_user_data: dict[str, str],
base_url: str,
pytfm_auth_provider: DevAuthProvider,
role: str,
) -> BrowserContext:
"""Create authenticated browser context with logged-in user."""
user = keycloak_client.create_user(
username=test_user_data["username"],
password=test_user_data["password"],
email=test_user_data["email"],
roles=["user"],
)
"""Create a browser context authenticated with a dev token role.
token = keycloak_client.login(user.username, user.password)
Args:
browser: Playwright Browser instance.
base_url: Application base URL.
pytfm_auth_provider: Dev auth provider.
role: Dev role (user, user2, admin, guest).
Returns:
Authenticated BrowserContext.
"""
user = pytfm_auth_provider.create_user(
username=f"e2e_{role}",
password="pass",
email=f"{role}@example.com",
roles=[role],
)
token = pytfm_auth_provider.login(user.username, user.password)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
)
cookie_domain = base_url.replace("http://", "").replace("https://", "").split(":")[0]
cookie = pytfm_auth_provider.build_auth_cookie(token, cookie_domain)
context.add_cookies([cookie])
context.add_cookies(
[
{
"name": "access_token",
"value": token,
"domain": cookie_domain,
"path": "/",
"httpOnly": True,
"secure": False,
}
]
)
return context
@pytest.fixture
def user_context(
browser: Browser,
base_url: str,
pytfm_auth_provider: DevAuthProvider,
) -> Generator[BrowserContext, None, None]:
"""Create a browser context authenticated as a regular user.
Args:
browser: Playwright Browser instance.
base_url: Application base URL.
pytfm_auth_provider: Dev auth provider.
Yields:
Authenticated BrowserContext for user role.
"""
context = _create_authenticated_context(browser, base_url, pytfm_auth_provider, "user")
yield context
context.close()
@pytest.fixture
def authenticated_page(authenticated_context: BrowserContext):
"""Create authenticated page for testing."""
page = authenticated_context.new_page()
def user_page(user_context: BrowserContext) -> Generator[Page, None, None]:
"""Create a page authenticated as a regular user.
Args:
user_context: Authenticated browser context.
Yields:
Authenticated Playwright Page.
"""
page = user_context.new_page()
yield page
page.close()
@pytest.fixture
def admin_user(keycloak_client: FakeKeycloakClient) -> dict[str, str]:
"""Create admin user and return credentials with token."""
import uuid
def admin_context(
browser: Browser,
base_url: str,
pytfm_auth_provider: DevAuthProvider,
) -> Generator[BrowserContext, None, None]:
"""Create a browser context authenticated as admin.
unique_id = uuid.uuid4().hex[:8]
username = f"admin_{unique_id}"
password = "AdminPass123!"
Args:
browser: Playwright Browser instance.
base_url: Application base URL.
pytfm_auth_provider: Dev auth provider.
user = keycloak_client.create_user(
username=username,
password=password,
email=f"admin_{unique_id}@example.com",
roles=["user", "admin"],
)
token = keycloak_client.login(username, password)
return {
"id": user.id,
"username": username,
"password": password,
"email": user.email,
"token": token,
"roles": user.roles,
}
Yields:
Authenticated BrowserContext for admin role.
"""
context = _create_authenticated_context(browser, base_url, pytfm_auth_provider, "admin")
yield context
context.close()
@pytest.fixture
def regular_user(keycloak_client: FakeKeycloakClient) -> dict[str, str]:
"""Create regular user and return credentials with token."""
import uuid
def admin_page(admin_context: BrowserContext) -> Generator[Page, None, None]:
"""Create a page authenticated as admin.
unique_id = uuid.uuid4().hex[:8]
username = f"user_{unique_id}"
password = "UserPass123!"
Args:
admin_context: Authenticated browser context.
user = keycloak_client.create_user(
username=username,
password=password,
email=f"user_{unique_id}@example.com",
roles=["user"],
Yields:
Authenticated Playwright Page.
"""
page = admin_context.new_page()
yield page
page.close()
@pytest.fixture
def user2_context(
browser: Browser,
base_url: str,
pytfm_auth_provider: DevAuthProvider,
) -> Generator[BrowserContext, None, None]:
"""Create a browser context authenticated as a second regular user.
Args:
browser: Playwright Browser instance.
base_url: Application base URL.
pytfm_auth_provider: Dev auth provider.
Yields:
Authenticated BrowserContext for user2 role.
"""
context = _create_authenticated_context(browser, base_url, pytfm_auth_provider, "user2")
yield context
context.close()
@pytest.fixture
def user2_page(user2_context: BrowserContext) -> Generator[Page, None, None]:
"""Create a page authenticated as a second regular user.
Args:
user2_context: Authenticated browser context.
Yields:
Authenticated Playwright Page.
"""
page = user2_context.new_page()
yield page
page.close()
@pytest.fixture
def guest_context(
browser: Browser,
base_url: str,
) -> Generator[BrowserContext, None, None]:
"""Create an unauthenticated browser context.
Args:
browser: Playwright Browser instance.
base_url: Application base URL.
Yields:
Unauthenticated BrowserContext.
"""
context = browser.new_context(
viewport={"width": 1280, "height": 720},
)
yield context
context.close()
token = keycloak_client.login(username, password)
return {
"id": user.id,
"username": username,
"password": password,
"email": user.email,
"token": token,
"roles": user.roles,
}
@pytest.fixture
def guest_page(guest_context: BrowserContext) -> Generator[Page, None, None]:
"""Create an unauthenticated page.
Args:
guest_context: Unauthenticated browser context.
Yields:
Unauthenticated Playwright Page.
"""
page = guest_context.new_page()
yield page
page.close()

View File

@@ -1,18 +0,0 @@
"""E2E test configuration.
This conftest.py overrides the asyncio_mode setting from the root pyproject.toml
to disable pytest-asyncio for E2E tests. This is necessary because pytest-playwright
manages its own event loop and conflicts with pytest-asyncio.
See: https://github.com/pytest-dev/pytest-asyncio/issues/706
"""
def pytest_configure(config):
"""Configure pytest for E2E tests.
Disable pytest-asyncio for E2E tests since pytest-playwright
manages its own event loop.
"""
# Override asyncio_mode to prevent pytest-asyncio from interfering
config.option.asyncio_mode = None

View File

@@ -1,227 +0,0 @@
"""Fake Keycloak client for E2E testing.
This module provides a mock implementation of KeycloakAuthClient
that doesn't require a real Keycloak server. Stores users and tokens
in memory for fast, isolated testing.
"""
import secrets
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from app.infrastructure.auth.models import KeycloakUser, TokenInfo
@dataclass
class TestUser:
"""Test user data for fake Keycloak.
Stores user credentials and profile information.
Attributes:
id: Unique user identifier.
username: User login name.
email: User email address.
password: User password (plaintext for testing).
roles: List of user roles.
first_name: User first name.
last_name: User last name.
"""
id: str
username: str
email: str
password: str
roles: list[str] = field(default_factory=list)
first_name: str = ""
last_name: str = ""
class FakeKeycloakClient:
"""In-memory Keycloak client for E2E testing.
Mimics KeycloakAuthClient interface without external dependencies.
Stores users and tokens in memory. Tokens are simple strings
that can be validated locally.
Attributes:
_users: Dictionary of users by username.
_tokens: Dictionary of active tokens to user IDs.
_token_ttl: Token time-to-live in seconds.
Example:
>>> client = FakeKeycloakClient()
>>> user = client.create_user("john", "pass", ["user"])
>>> token = client.login("john", "pass")
>>> info = await client.introspect_token(token)
>>> assert info.active
"""
def __init__(self, token_ttl: int = 3600) -> None:
"""Initialize fake Keycloak client.
Args:
token_ttl: Token lifetime in seconds (default: 1 hour).
"""
self._users: dict[str, TestUser] = {}
self._tokens: dict[str, tuple[str, float]] = {} # token -> (user_id, issued_at)
self._token_ttl = token_ttl
def create_user(
self,
username: str,
password: str,
roles: list[str] | None = None,
email: str | None = None,
first_name: str = "",
last_name: str = "",
) -> TestUser:
"""Create a new test user.
Args:
username: Unique username.
password: User password.
roles: List of roles (default: ["user"]).
email: User email (default: username@test.com).
first_name: First name.
last_name: Last name.
Returns:
Created TestUser instance.
Raises:
ValueError: If username already exists.
"""
if username in self._users:
raise ValueError(f"User {username} already exists")
user = TestUser(
id=str(uuid.uuid4()),
username=username,
email=email or f"{username}@test.com",
password=password,
roles=roles or ["user"],
first_name=first_name,
last_name=last_name,
)
self._users[username] = user
return user
def login(self, username: str, password: str) -> str:
"""Authenticate user and return access token.
Args:
username: User login name.
password: User password.
Returns:
Access token string.
Raises:
ValueError: If credentials are invalid.
"""
user = self._users.get(username)
if not user or user.password != password:
raise ValueError("Invalid credentials")
token = secrets.token_urlsafe(32)
self._tokens[token] = (user.id, time.time())
return token
def logout(self, token: str) -> None:
"""Invalidate access token.
Args:
token: Token to invalidate.
"""
self._tokens.pop(token, None)
def _get_token_user(self, token: str) -> TestUser | None:
"""Get user associated with token if valid.
Args:
token: Access token to validate.
Returns:
User if token is valid and not expired, None otherwise.
"""
if token not in self._tokens:
return None
user_id, issued_at = self._tokens[token]
if time.time() - issued_at > self._token_ttl:
del self._tokens[token]
return None
for user in self._users.values():
if user.id == user_id:
return user
return None
async def introspect_token(self, token: str) -> TokenInfo:
"""Validate token and return token info.
Mimics Keycloak token introspection endpoint.
Args:
token: Access token to validate.
Returns:
TokenInfo with validation result.
"""
user = self._get_token_user(token)
if not user:
return TokenInfo(active=False, raw_claims={"error": "invalid_token"})
raw_claims: dict[str, Any] = {
"sub": user.id,
"preferred_username": user.username,
"email": user.email,
"realm_access": {"roles": user.roles},
}
return TokenInfo(
active=True,
user_id=user.id,
username=user.username,
email=user.email,
roles=user.roles,
raw_claims=raw_claims,
)
async def get_userinfo(self, token: str) -> KeycloakUser | None:
"""Get user info from token.
Mimics Keycloak userinfo endpoint.
Args:
token: Valid access token.
Returns:
KeycloakUser if token is valid, None otherwise.
"""
user = self._get_token_user(token)
if not user:
return None
return KeycloakUser(
id=user.id,
username=user.username,
email=user.email,
first_name=user.first_name,
last_name=user.last_name,
roles=user.roles,
)
def clear(self) -> None:
"""Clear all users and tokens.
Useful for cleanup between tests.
"""
self._users.clear()
self._tokens.clear()

View File

@@ -1,535 +0,0 @@
"""Page Objects for blog web UI.
This module provides Page Object classes for the blog application
using SmartLocator for element interactions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pytfm.web.locator import SmartLocator as Loc
if TYPE_CHECKING:
from playwright.async_api import Page
class HomePage:
"""Page Object for the blog home page.
Provides methods for interacting with the posts list,
pagination, and navigation elements.
"""
# Locators
HEADER_LOGO = Loc.by_testid("nav-logo")
PAGE_TITLE = Loc.by_testid("page-title-home")
CREATE_POST_BTN = Loc.by_testid("btn-create-post-header")
POST_LIST = Loc.by_testid("post-list")
POST_CARD = Loc.by_css("[data-testid^='post-card-']")
POST_TITLE = Loc.by_css("[data-testid^='post-title-']")
POST_STATUS = Loc.by_css("[data-testid^='post-status-']")
POST_AUTHOR = Loc.by_css("[data-testid^='post-author-']")
POST_TAGS = Loc.by_css("[data-testid^='post-tags-']")
READ_MORE_BTN = Loc.by_css("[data-testid^='btn-read-more-']")
EMPTY_STATE = Loc.by_testid("empty-state")
EMPTY_STATE_TITLE = Loc.by_testid("empty-state-title")
CREATE_FIRST_POST_BTN = Loc.by_testid("btn-create-first-post")
PAGINATION = Loc.by_testid("pagination")
PAGINATION_PREV = Loc.by_testid("pagination-prev")
PAGINATION_NEXT = Loc.by_testid("pagination-next")
PAGINATION_CURRENT = Loc.by_testid("pagination-current")
THEME_TOGGLE = Loc.by_testid("theme-toggle")
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize HomePage.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
self.page = page
self.base_url = base_url.rstrip("/")
self.url = f"{self.base_url}/web/"
async def open(self) -> HomePage:
"""Navigate to home page.
Returns:
Self for method chaining.
"""
await self.page.goto(self.url)
return self
async def get_post_count(self) -> int:
"""Get number of posts displayed.
Returns:
Number of post cards on the page.
"""
return await self.POST_CARD.count(self.page)
async def get_post_titles(self) -> list[str]:
"""Get list of post titles.
Returns:
List of title texts for all visible posts.
"""
titles = []
count = await self.get_post_count()
for i in range(count):
title_loc = self.POST_TITLE.nth(i)
text = await title_loc.get_text(self.page)
titles.append(text.strip())
return titles
async def click_post_title(self, index: int = 0) -> None:
"""Click on post title by index.
Args:
index: Zero-based index of post to click.
"""
title_link = Loc.by_css(f"[data-testid='post-title-link-{index}']")
await title_link.click_and_wait(self.page, navigation=True)
async def click_read_more(self, index: int = 0) -> None:
"""Click 'Read more' button on post by index.
Args:
index: Zero-based index of post.
"""
btn = self.READ_MORE_BTN.nth(index)
await btn.click_and_wait(self.page, navigation=True)
async def click_create_post(self) -> None:
"""Click 'Write a Post' button."""
await self.CREATE_POST_BTN.click_and_wait(
self.page, target_testid="form-post", navigation=True
)
async def click_create_first_post(self) -> None:
"""Click 'Create your first post' button in empty state."""
await self.CREATE_FIRST_POST_BTN.click_and_wait(
self.page, target_testid="form-post", navigation=True
)
async def go_to_next_page(self) -> bool:
"""Click next page in pagination.
Returns:
True if navigation occurred, False if already on last page.
"""
next_btn = self.PAGINATION_NEXT.with_page(self.page)
is_disabled = await next_btn.get_attribute("class")
if is_disabled and "disabled" in is_disabled:
return False
await next_btn.click_and_wait(self.page, navigation=True)
return True
async def go_to_prev_page(self) -> bool:
"""Click previous page in pagination.
Returns:
True if navigation occurred, False if already on first page.
"""
prev_btn = self.PAGINATION_PREV.with_page(self.page)
is_disabled = await prev_btn.get_attribute("class")
if is_disabled and "disabled" in is_disabled:
return False
await prev_btn.click_and_wait(self.page, navigation=True)
return True
async def get_current_page_number(self) -> int:
"""Get current page number from pagination.
Returns:
Current page number as integer.
"""
text = await self.PAGINATION_CURRENT.get_text(self.page)
return int(text.strip())
async def toggle_theme(self) -> None:
"""Toggle between light and dark theme."""
await self.THEME_TOGGLE.click(self.page)
async def is_empty_state_visible(self) -> bool:
"""Check if empty state is displayed.
Returns:
True if no posts and empty state shown.
"""
return await self.EMPTY_STATE.is_visible(self.page)
async def wait_for_posts_loaded(self) -> None:
"""Wait for posts to be loaded.
Waits for either post list or empty state to appear.
"""
try:
await self.POST_LIST.wait_for_visible(self.page, timeout=5000)
except Exception:
await self.EMPTY_STATE.wait_for_visible(self.page, timeout=2000)
class PostDetailPage:
"""Page Object for individual post detail page.
Provides methods for viewing post content and actions
like edit, delete, publish/unpublish.
"""
# Locators
POST_TITLE = Loc.by_testid("post-detail-title")
POST_CONTENT = Loc.by_testid("post-detail-content")
POST_AUTHOR = Loc.by_testid("post-detail-author")
POST_DATE = Loc.by_testid("post-detail-date")
POST_TAGS = Loc.by_testid("post-detail-tags")
POST_STATUS = Loc.by_testid("post-detail-status")
EDIT_BTN = Loc.by_testid("btn-edit-post")
DELETE_BTN = Loc.by_testid("btn-delete-post")
PUBLISH_BTN = Loc.by_testid("btn-publish-post")
UNPUBLISH_BTN = Loc.by_testid("btn-unpublish-post")
BACK_BTN = Loc.by_testid("btn-back-to-list")
CONFIRM_DELETE_BTN = Loc.by_testid("btn-confirm-delete")
CANCEL_DELETE_BTN = Loc.by_testid("btn-cancel-delete")
FLASH_SUCCESS = Loc.by_testid("flash-success")
FLASH_ERROR = Loc.by_testid("flash-error")
def __init__(self, page: Page, base_url: str, slug: str = "") -> None:
"""Initialize PostDetailPage.
Args:
page: Playwright Page instance.
base_url: Application base URL.
slug: Post slug for direct navigation.
"""
self.page = page
self.base_url = base_url.rstrip("/")
self.slug = slug
if slug:
self.url = f"{self.base_url}/web/posts/{slug}"
else:
self.url = ""
async def open(self, slug: str | None = None) -> PostDetailPage:
"""Navigate to post detail page.
Args:
slug: Post slug (uses instance slug if not provided).
Returns:
Self for method chaining.
"""
target_slug = slug or self.slug
if not target_slug:
raise ValueError("Slug must be provided")
url = f"{self.base_url}/web/posts/{target_slug}"
await self.page.goto(url)
return self
async def get_title(self) -> str:
"""Get post title text.
Returns:
Title text of the post.
"""
return await self.POST_TITLE.get_text(self.page)
async def get_content(self) -> str:
"""Get post content text.
Returns:
Content text of the post.
"""
return await self.POST_CONTENT.get_text(self.page)
async def get_author(self) -> str:
"""Get post author.
Returns:
Author identifier string.
"""
return await self.POST_AUTHOR.get_text(self.page)
async def click_edit(self) -> None:
"""Click edit button."""
await self.EDIT_BTN.click_and_wait(self.page, target_testid="form-post", navigation=True)
async def click_delete(self) -> None:
"""Click delete button (opens confirmation)."""
await self.DELETE_BTN.click_and_wait(self.page, target_testid="modal-confirm-delete")
async def confirm_delete(self) -> None:
"""Confirm deletion in modal."""
await self.CONFIRM_DELETE_BTN.click_and_wait(
self.page, target_testid="flash-success", navigation=True
)
async def cancel_delete(self) -> None:
"""Cancel deletion in modal."""
await self.CANCEL_DELETE_BTN.click(self.page)
async def click_publish(self) -> None:
"""Click publish button."""
await self.PUBLISH_BTN.click_and_wait(self.page, target_testid="flash-success")
async def click_unpublish(self) -> None:
"""Click unpublish button."""
await self.UNPUBLISH_BTN.click_and_wait(self.page, target_testid="flash-success")
async def click_back(self) -> None:
"""Click back to list button."""
await self.BACK_BTN.click_and_wait(self.page, navigation=True)
async def is_edit_visible(self) -> bool:
"""Check if edit button is visible.
Returns:
True if user can edit this post.
"""
return await self.EDIT_BTN.is_visible(self.page)
async def is_delete_visible(self) -> bool:
"""Check if delete button is visible.
Returns:
True if user can delete this post.
"""
return await self.DELETE_BTN.is_visible(self.page)
class PostFormPage:
"""Page Object for post creation/editing form.
Provides methods for filling and submitting post forms.
"""
# Locators
FORM = Loc.by_testid("form-post")
TITLE_INPUT = Loc.by_testid("input-title")
CONTENT_INPUT = Loc.by_testid("input-content")
TAGS_INPUT = Loc.by_testid("input-tags")
PUBLISHED_CHECKBOX = Loc.by_testid("checkbox-published")
SUBMIT_BTN = Loc.by_testid("btn-submit-post")
CANCEL_BTN = Loc.by_testid("btn-cancel")
FORM_TITLE = Loc.by_testid("form-title")
TITLE_ERROR = Loc.by_testid("error-title")
CONTENT_ERROR = Loc.by_testid("error-content")
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize PostFormPage.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
self.page = page
self.base_url = base_url.rstrip("/")
async def open_create(self) -> PostFormPage:
"""Navigate to create post form.
Returns:
Self for method chaining.
"""
await self.page.goto(f"{self.base_url}/web/posts/new")
return self
async def open_edit(self, slug: str) -> PostFormPage:
"""Navigate to edit post form.
Args:
slug: Post slug to edit.
Returns:
Self for method chaining.
"""
await self.page.goto(f"{self.base_url}/web/posts/{slug}/edit")
return self
async def fill_title(self, title: str) -> PostFormPage:
"""Fill title field.
Args:
title: Post title.
Returns:
Self for method chaining.
"""
await self.TITLE_INPUT.fill(self.page, title)
return self
async def fill_content(self, content: str) -> PostFormPage:
"""Fill content field.
Args:
content: Post content.
Returns:
Self for method chaining.
"""
await self.CONTENT_INPUT.fill(self.page, content)
return self
async def fill_tags(self, tags: list[str]) -> PostFormPage:
"""Fill tags field.
Args:
tags: List of tag strings.
Returns:
Self for method chaining.
"""
tags_str = ", ".join(tags)
await self.TAGS_INPUT.fill(self.page, tags_str)
return self
async def set_published(self, published: bool) -> PostFormPage:
"""Set published status checkbox.
Args:
published: True to check, False to uncheck.
Returns:
Self for method chaining.
"""
is_checked = await self.PUBLISHED_CHECKBOX.is_checked(self.page)
if published != is_checked:
if published:
await self.PUBLISHED_CHECKBOX.check(self.page)
else:
await self.PUBLISHED_CHECKBOX.uncheck(self.page)
return self
async def submit(self) -> None:
"""Submit the form."""
await self.SUBMIT_BTN.click_and_wait(
self.page, target_testid="post-detail-title", navigation=True
)
async def click_cancel(self) -> None:
"""Click cancel button."""
await self.CANCEL_BTN.click_and_wait(self.page, navigation=True)
async def get_title_error(self) -> str:
"""Get title validation error message.
Returns:
Error text or empty string if no error.
"""
if await self.TITLE_ERROR.is_visible(self.page):
return await self.TITLE_ERROR.get_text(self.page)
return ""
async def get_content_error(self) -> str:
"""Get content validation error message.
Returns:
Error text or empty string if no error.
"""
if await self.CONTENT_ERROR.is_visible(self.page):
return await self.CONTENT_ERROR.get_text(self.page)
return ""
async def get_form_title(self) -> str:
"""Get form title (Create Post / Edit Post).
Returns:
Form title text.
"""
return await self.FORM_TITLE.get_text(self.page)
async def create_post(
self, title: str, content: str, tags: list[str] | None = None, published: bool = False
) -> None:
"""Fill and submit new post form.
Args:
title: Post title.
content: Post content.
tags: Optional list of tags.
published: Whether to publish immediately.
"""
await self.fill_title(title)
await self.fill_content(content)
if tags:
await self.fill_tags(tags)
await self.set_published(published)
await self.submit()
class NavigationComponent:
"""Component for site-wide navigation.
Provides access to navigation elements present on all pages.
"""
# Locators
NAV_LOGO = Loc.by_testid("nav-logo")
NAV_HOME = Loc.by_testid("nav-link-home")
NAV_POSTS = Loc.by_testid("nav-link-posts")
NAV_ABOUT = Loc.by_testid("nav-link-about")
NAV_PROFILE = Loc.by_testid("nav-link-profile")
NAV_LOGIN = Loc.by_testid("nav-link-login")
NAV_LOGOUT = Loc.by_testid("nav-link-logout")
THEME_TOGGLE = Loc.by_testid("theme-toggle")
USER_MENU = Loc.by_testid("user-menu")
def __init__(self, page: Page) -> None:
"""Initialize NavigationComponent.
Args:
page: Playwright Page instance.
"""
self.page = page
async def click_logo(self) -> None:
"""Click logo to go home."""
await self.NAV_LOGO.click_and_wait(self.page, navigation=True)
async def click_home(self) -> None:
"""Click Home nav link."""
await self.NAV_HOME.click_and_wait(self.page, navigation=True)
async def click_posts(self) -> None:
"""Click Posts nav link."""
await self.NAV_POSTS.click_and_wait(self.page, navigation=True)
async def click_about(self) -> None:
"""Click About nav link."""
await self.NAV_ABOUT.click_and_wait(self.page, navigation=True)
async def click_profile(self) -> None:
"""Click Profile nav link."""
await self.NAV_PROFILE.click_and_wait(self.page, navigation=True)
async def click_login(self) -> None:
"""Click Login nav link."""
await self.NAV_LOGIN.click_and_wait(self.page, navigation=True)
async def click_logout(self) -> None:
"""Click Logout nav link."""
await self.NAV_LOGOUT.click_and_wait(self.page, navigation=True)
async def toggle_theme(self) -> None:
"""Toggle light/dark theme."""
await self.THEME_TOGGLE.click(self.page)
async def is_logged_in(self) -> bool:
"""Check if user is logged in.
Returns:
True if logout link visible.
"""
return await self.NAV_LOGOUT.is_visible(self.page)
async def is_logged_out(self) -> bool:
"""Check if user is logged out.
Returns:
True if login link visible.
"""
return await self.NAV_LOGIN.is_visible(self.page)

225
tests/e2e/pages/__init__.py Normal file
View File

@@ -0,0 +1,225 @@
"""Page Object Models for blog web UI.
Provides POM classes for home page, post form, and post detail pages.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pytfm.web import BasePage, SmartLocator
if TYPE_CHECKING:
from playwright.sync_api import Page
class HomePage(BasePage):
"""Page object for the blog home/posts listing page.
Attributes:
path: URL path for the home page.
"""
path = "/web/"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the home page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._create_post_btn = SmartLocator.by_testid("btn-create-post-header")
self._post_list = SmartLocator.by_testid("post-list")
self._empty_state = SmartLocator.by_testid("empty-state")
def create_post(self) -> None:
"""Click the 'Write a Post' button to navigate to the form."""
self._create_post_btn.click(self.page)
def has_post_with_title(self, title: str) -> bool:
"""Check if a post with the given title is present in the list.
Args:
title: Post title to search for.
Returns:
True if the post title is found on the page.
"""
safe_title = title.replace('"', '\\"')
selector = f'[data-testid^="post-title-link-"]:has-text("{safe_title}")'
return self.page.locator(selector).count() > 0
def has_no_post_with_title(self, title: str) -> bool:
"""Check that no post with the given title is present in the list.
Args:
title: Post title to search for.
Returns:
True if the post title is not found on the page.
"""
safe_title = title.replace('"', '\\"')
selector = f'[data-testid^="post-title-link-"]:has-text("{safe_title}")'
return self.page.locator(selector).count() == 0
def open_post(self, title: str) -> None:
"""Click on a post title link to open the detail page.
Args:
title: Post title to click.
"""
locator = self.page.locator("[data-testid^='post-title-link-']").filter(has_text=title)
locator.click()
def is_empty(self) -> bool:
"""Check if the posts list is empty.
Returns:
True if the empty state is visible.
"""
return self._empty_state.is_visible(self.page)
class PostFormPage(BasePage):
"""Page object for the new post / edit post form.
Attributes:
path: URL path for the new post form.
"""
path = "/web/posts/new"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the post form page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._title_input = SmartLocator.by_testid("input-title")
self._content_input = SmartLocator.by_testid("textarea-content")
self._tags_input = SmartLocator.by_testid("input-tags")
self._publish_btn = SmartLocator.by_testid("btn-publish-post")
self._save_draft_btn = SmartLocator.by_testid("btn-save-draft")
def fill_form(self, title: str, content: str, tags: str) -> None:
"""Fill the post creation form.
Args:
title: Post title.
content: Post content (markdown).
tags: Comma-separated tags string.
"""
self._title_input.fill(self.page, title)
self._tags_input.fill(self.page, tags)
self.page.evaluate(
"(content) => {"
" const cm = document.querySelector('.CodeMirror');"
" if (cm && cm.CodeMirror) {"
" cm.CodeMirror.setValue(content);"
" }"
" const textarea = document.querySelector('[data-testid=\"textarea-content\"]');"
" if (textarea) textarea.value = content;"
"}",
content,
)
def publish(self) -> None:
"""Click the publish button to submit the form."""
self._publish_btn.click(self.page)
def save_draft(self) -> None:
"""Click the 'Save as Draft' button."""
self._save_draft_btn.click(self.page)
class PostDetailPage(BasePage):
"""Page object for the post detail page.
Attributes:
path_template: URL path template with {slug} placeholder.
"""
path_template = "/web/posts/{slug}"
def __init__(self, page: Page, base_url: str, slug: str) -> None:
"""Initialize the post detail page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
slug: Post URL slug.
"""
super().__init__(page, base_url)
self.slug = slug
self._title = SmartLocator.by_testid("post-detail-title")
self._status = SmartLocator.by_testid("post-detail-status")
self._content = SmartLocator.by_testid("post-detail-content")
self._edit_btn = SmartLocator.by_testid("btn-edit-post")
self._delete_btn = SmartLocator.by_testid("btn-delete-post")
@property
def url(self) -> str:
"""Return the full URL for this post detail page.
Returns:
Full post detail URL.
"""
return f"{self.base_url}{self.path_template.format(slug=self.slug)}"
def open(self) -> PostDetailPage:
"""Navigate to the post detail page.
Returns:
Self for method chaining.
"""
self.page.goto(self.url)
return self
def get_title(self) -> str:
"""Get the post title text.
Returns:
Post title string.
"""
return self._title.get_text(self.page)
def get_status(self) -> str:
"""Get the post status badge text.
Returns:
Status text ('Published' or 'Draft').
"""
return self._status.get_text(self.page)
def is_published(self) -> bool:
"""Check if the post status is 'Published'.
Returns:
True if status badge reads 'Published'.
"""
return self.get_status() == "Published"
def edit(self) -> None:
"""Click the edit button to navigate to the edit form."""
self._edit_btn.click(self.page)
def can_edit(self) -> bool:
"""Check if the edit button is visible.
Returns:
True if edit button is present.
"""
return self._edit_btn.is_visible(self.page)
def can_delete(self) -> bool:
"""Check if the delete button is visible.
Returns:
True if delete button is present.
"""
return self._delete_btn.is_visible(self.page)

View File

@@ -1,165 +0,0 @@
"""E2E tests for creating posts.
Tests post creation form and submission flows.
Note: Most tests require authentication and may be skipped in guest mode.
"""
import pytest
from playwright.sync_api import Page
@pytest.mark.e2e
class TestPostCreationForm:
"""Tests for post creation form."""
def test_create_form_loads(self, page: Page, base_url: str) -> None:
"""Test that create post form loads."""
page.goto(f"{base_url}/web/posts/new")
# Form should be present (may redirect to login for guests)
if "login" in page.url:
pytest.skip("Authentication required")
# Check form is visible
form = page.locator("[data-testid='form-post']")
assert form.is_visible(), "Form should be visible"
def test_form_has_required_fields(self, page: Page, base_url: str) -> None:
"""Test that form has title and content fields."""
page.goto(f"{base_url}/web/posts/new")
if "login" in page.url:
pytest.skip("Authentication required")
# Check fields are visible
assert page.locator("[data-testid='input-title']").is_visible()
assert page.locator("[data-testid='input-content']").is_visible()
assert page.locator("[data-testid='btn-submit-post']").is_visible()
def test_cancel_returns_to_list(self, page: Page, base_url: str) -> None:
"""Test cancel button returns to posts list."""
page.goto(f"{base_url}/web/posts/new")
if "login" in page.url:
pytest.skip("Authentication required")
# Click cancel
page.locator("[data-testid='btn-cancel']").click()
page.wait_for_load_state("networkidle")
# Should be back on home page
assert "/web/" in page.url
@pytest.mark.e2e
class TestPostCreationValidation:
"""Tests for form validation."""
def test_empty_title_shows_error(self, page: Page, base_url: str) -> None:
"""Test validation error for empty title."""
page.goto(f"{base_url}/web/posts/new")
if "login" in page.url:
pytest.skip("Authentication required")
# Try to submit empty form
page.locator("[data-testid='input-content']").fill("Valid content here")
page.locator("[data-testid='btn-submit-post']").click()
# Should show error or stay on form
assert "new" in page.url or page.locator("[data-testid='error-title']").is_visible()
def test_short_content_shows_error(self, page: Page, base_url: str) -> None:
"""Test validation error for short content."""
page.goto(f"{base_url}/web/posts/new")
if "login" in page.url:
pytest.skip("Authentication required")
# Fill with short content
page.locator("[data-testid='input-title']").fill("Valid Title")
page.locator("[data-testid='input-content']").fill("Short")
page.locator("[data-testid='btn-submit-post']").click()
# Should show error or stay on form
assert "new" in page.url or page.locator("[data-testid='error-content']").is_visible()
@pytest.mark.e2e
class TestPostCreationFlow:
"""Tests for complete post creation flow."""
def test_create_published_post(self, authenticated_page: Page, base_url: str) -> None:
"""Test creating a published post."""
page = authenticated_page
# Navigate to create form
page.goto(f"{base_url}/web/posts/new")
# Check if redirected to login
if "login" in page.url:
pytest.skip("Authentication required")
# Fill and submit
page.locator("[data-testid='input-title']").fill("E2E Test Post")
page.locator("[data-testid='input-content']").fill(
"This is a test post created by E2E tests. " * 5
)
page.locator("[data-testid='input-tags']").fill("test, e2e")
page.locator("[data-testid='checkbox-published']").check()
page.locator("[data-testid='btn-submit-post']").click()
page.wait_for_load_state("networkidle")
# Should be on detail page
assert "posts/" in page.url
def test_create_draft_post(self, authenticated_page: Page, base_url: str) -> None:
"""Test creating a draft post."""
page = authenticated_page
page.goto(f"{base_url}/web/posts/new")
if "login" in page.url:
pytest.skip("Authentication required")
# Create as draft
page.locator("[data-testid='input-title']").fill("E2E Draft Post")
page.locator("[data-testid='input-content']").fill("This is a draft post. " * 5)
page.locator("[data-testid='checkbox-published']").uncheck()
page.locator("[data-testid='btn-submit-post']").click()
page.wait_for_load_state("networkidle")
# Should be on detail page
assert "posts/" in page.url
@pytest.mark.e2e
class TestPostCreationNavigation:
"""Tests for navigation to create form."""
def test_create_button_visible_for_logged_users(
self, authenticated_page: Page, base_url: str
) -> None:
"""Test that create button is visible for logged in users."""
page = authenticated_page
page.goto(f"{base_url}/web/")
# Create button should be visible for authenticated users
create_btn = page.locator("[data-testid='btn-create-post-header']")
if not create_btn.is_visible():
pytest.skip("Create button not visible")
assert create_btn.is_visible(), "Create button should be visible for logged in users"
def test_create_button_hidden_for_guests(self, page: Page, base_url: str) -> None:
"""Test that create button is hidden for guest users."""
page.goto(f"{base_url}/web/")
# Check if login link is visible (indicates guest user)
login_link = page.locator("a[href='/auth/login']")
if not login_link.is_visible():
pytest.skip("Test requires guest user")
# Create button should not be visible
create_btn = page.locator("[data-testid='btn-create-post-header']")
assert not create_btn.is_visible(), "Create button should be hidden for guests"

View File

@@ -1,309 +0,0 @@
"""E2E tests for editing and deleting posts.
Tests post modification and deletion flows with permission checks.
"""
import pytest
from playwright.sync_api import Page
@pytest.mark.e2e
class TestPostEditing:
"""Tests for editing posts."""
def test_edit_button_visible_for_owner(self, page: Page, base_url: str) -> None:
"""Test edit button visible for post owner."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Check if empty state
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
# Navigate to first post
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
# Check if edit button is visible
edit_btn = page.locator("[data-testid='btn-edit-post']")
if edit_btn.is_visible():
assert edit_btn.is_visible()
else:
pytest.skip("User cannot edit this post")
def test_edit_form_loads(self, page: Page, base_url: str) -> None:
"""Test that edit form loads with post data."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
edit_btn = page.locator("[data-testid='btn-edit-post']")
if not edit_btn.is_visible():
pytest.skip("Edit not available")
edit_btn.click()
page.wait_for_load_state("networkidle")
# Check form loaded
form = page.locator("[data-testid='form-post']")
assert form.is_visible(), "Form should be visible"
def test_edit_post_title(self, authenticated_page: Page, base_url: str) -> None:
"""Test editing post title."""
page = authenticated_page
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
edit_btn = page.locator("[data-testid='btn-edit-post']")
if not edit_btn.is_visible():
pytest.skip("Edit not available")
# Get current title
original_title = page.locator("[data-testid='post-detail-title']").text_content() or ""
edit_btn.click()
page.wait_for_load_state("networkidle")
new_title = f"{original_title} (Edited)"
page.locator("[data-testid='input-title']").fill(new_title)
page.locator("[data-testid='btn-submit-post']").click()
page.wait_for_load_state("networkidle")
# Verify title changed
updated_title = page.locator("[data-testid='post-detail-title']").text_content()
assert updated_title == new_title
@pytest.mark.e2e
class TestPostDeletion:
"""Tests for deleting posts."""
def test_delete_button_visible_for_owner(self, page: Page, base_url: str) -> None:
"""Test delete button visible for post owner."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
delete_btn = page.locator("[data-testid='btn-delete-post']")
if delete_btn.is_visible():
assert delete_btn.is_visible()
else:
pytest.skip("User cannot delete this post")
def test_delete_shows_confirmation(self, page: Page, base_url: str) -> None:
"""Test delete shows confirmation modal."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
delete_btn = page.locator("[data-testid='btn-delete-post']")
if not delete_btn.is_visible():
pytest.skip("Delete not available")
delete_btn.click()
page.wait_for_load_state("networkidle")
# Confirmation modal should appear
confirm_btn = page.locator("[data-testid='btn-confirm-delete']")
cancel_btn = page.locator("[data-testid='btn-cancel-delete']")
assert confirm_btn.is_visible()
assert cancel_btn.is_visible()
def test_cancel_delete_keeps_post(self, page: Page, base_url: str) -> None:
"""Test canceling deletion keeps the post."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
original_title = page.locator("[data-testid='post-detail-title']").text_content() or ""
delete_btn = page.locator("[data-testid='btn-delete-post']")
if not delete_btn.is_visible():
pytest.skip("Delete not available")
delete_btn.click()
page.wait_for_load_state("networkidle")
# Click cancel
page.locator("[data-testid='btn-cancel-delete']").click()
page.wait_for_load_state("networkidle")
# Should still be on detail page with same post
current_title = page.locator("[data-testid='post-detail-title']").text_content()
assert current_title == original_title
@pytest.mark.e2e
class TestPublishUnpublish:
"""Tests for publishing and unpublishing posts."""
def test_publish_button_for_draft(self, page: Page, base_url: str) -> None:
"""Test publish button visible for draft posts."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
# Try to find a draft post
posts = page.locator("[data-testid^='post-card-']")
count = posts.count()
if count == 0:
pytest.skip("No posts available")
# Click first post
posts.first.click()
page.wait_for_load_state("networkidle")
# Check status
status = page.locator("[data-testid='post-detail-status']").text_content() or ""
if "draft" in status.lower():
publish_btn = page.locator("[data-testid='btn-publish-post']")
if publish_btn.is_visible():
assert publish_btn.is_visible()
else:
pytest.skip("Not a draft post")
def test_unpublish_button_for_published(self, page: Page, base_url: str) -> None:
"""Test unpublish button visible for published posts."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
posts = page.locator("[data-testid^='post-card-']")
if posts.count() == 0:
pytest.skip("No posts available")
# Click first post
posts.first.click()
page.wait_for_load_state("networkidle")
# Check status
status = page.locator("[data-testid='post-detail-status']").text_content() or ""
if "published" in status.lower():
unpublish_btn = page.locator("[data-testid='btn-unpublish-post']")
if unpublish_btn.is_visible():
assert unpublish_btn.is_visible()
else:
pytest.skip("Not a published post")
@pytest.mark.e2e
class TestPermissions:
"""Tests for edit/delete permissions."""
def test_cannot_edit_other_users_post(self, page: Page, base_url: str) -> None:
"""Test user cannot edit another user's post."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Check if logged in
logout_link = page.locator("[data-testid='nav-link-logout']")
if not logout_link.is_visible():
pytest.skip("Requires authenticated user")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
posts = page.locator("[data-testid^='post-card-']")
if posts.count() == 0:
pytest.skip("No posts available")
posts.first.click()
page.wait_for_load_state("networkidle")
# If edit button is not visible, user cannot edit
edit_btn = page.locator("[data-testid='btn-edit-post']")
if not edit_btn.is_visible():
pass # Test passes - user cannot edit
def test_guest_cannot_see_edit_delete(self, page: Page, base_url: str) -> None:
"""Test guest user cannot see edit/delete buttons."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Check if guest (login link visible)
login_link = page.locator("a[href='/auth/login']")
if not login_link.is_visible():
pytest.skip("Requires guest user")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
posts = page.locator("[data-testid^='post-card-']")
if posts.count() == 0:
pytest.skip("No posts available")
posts.first.click()
page.wait_for_load_state("networkidle")
edit_btn = page.locator("[data-testid='btn-edit-post']")
delete_btn = page.locator("[data-testid='btn-delete-post']")
assert not edit_btn.is_visible(), "Edit button should be hidden for guests"
assert not delete_btn.is_visible(), "Delete button should be hidden for guests"

View File

@@ -1,41 +0,0 @@
"""Example E2E test using playwright.
This module demonstrates how to use playwright for testing
the blog application.
"""
from playwright.sync_api import sync_playwright
class TestBlogE2E:
"""End-to-end tests for the blog application."""
def test_homepage_loads(self, base_url: str) -> None:
"""Test that homepage loads successfully."""
with sync_playwright() as p:
browser = p.firefox.launch(headless=True)
page = browser.new_page()
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Check logo is visible
logo = page.locator('[data-testid="nav-logo"]')
assert logo.is_visible(), "Logo should be visible"
browser.close()
class TestBlogAPI:
"""API tests for the blog application."""
def test_get_posts(self, base_url: str) -> None:
"""Test GET /api/v1/posts endpoint."""
import httpx
response = httpx.get(f"{base_url}/api/v1/posts")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)

View File

@@ -1,122 +0,0 @@
"""Example E2E tests demonstrating test infrastructure.
These tests verify that the E2E testing setup works correctly:
- Test server runs on random port
- Fake Keycloak provides authentication
- Database is isolated
"""
import asyncio
import pytest
from playwright.sync_api import Page
from tests.e2e.fake_keycloak import FakeKeycloakClient
@pytest.mark.e2e
def test_server_health_endpoint(base_url: str, page: Page) -> None:
"""Test that test server responds on health endpoint."""
response = page.goto(f"{base_url}/health")
assert response is not None
assert response.status == 200
body = response.json()
assert body["status"] == "ok"
assert body["env"] == "e2e-test"
@pytest.mark.e2e
def test_unauthenticated_user_sees_login_prompt(base_url: str, page: Page) -> None:
"""Test that unauthenticated user sees login option."""
page.goto(f"{base_url}/web/")
header = page.locator("header")
header.wait_for()
login_link = page.locator("a[href='/auth/login']")
assert login_link.is_visible()
@pytest.mark.e2e
@pytest.mark.skip(
reason="E2E auth flow needs further debugging - authentication cookie not being validated properly"
)
def test_authenticated_user_can_access_profile(
base_url: str,
authenticated_page: Page,
test_user_data: dict[str, str],
) -> None:
"""Test that authenticated user can access profile page."""
authenticated_page.goto(f"{base_url}/web/")
user_menu = authenticated_page.locator("text=" + test_user_data["username"])
user_menu.wait_for()
authenticated_page.goto(f"{base_url}/profile")
profile_content = authenticated_page.locator("main")
profile_content.wait_for()
body_text = profile_content.inner_text()
assert test_user_data["email"] in body_text or test_user_data["username"] in body_text
@pytest.mark.e2e
@pytest.mark.skip(reason="Session-scoped fixture conflicts - needs isolated client per test")
def test_fake_keycloak_creates_different_users() -> None:
"""Test that fake Keycloak creates independent users."""
# Create isolated client to avoid conflicts with session-scoped fixture
client = FakeKeycloakClient(token_ttl=3600)
user1 = client.create_user("alice", "pass1", roles=["user"])
user2 = client.create_user("bob", "pass2", roles=["user", "admin"])
assert user1.id != user2.id
assert user1.username == "alice"
assert user2.username == "bob"
assert user1.roles == ["user"]
assert user2.roles == ["user", "admin"]
token1 = client.login("alice", "pass1")
token2 = client.login("bob", "pass2")
info1 = asyncio.run(client.introspect_token(token1))
info2 = asyncio.run(client.introspect_token(token2))
assert info1.active is True
assert info2.active is True
assert info1.username == "alice"
assert info2.username == "bob"
@pytest.mark.e2e
def test_admin_user_has_admin_role(admin_user: dict[str, str]) -> None:
"""Test that admin user fixture creates user with admin role."""
assert "admin" in admin_user["roles"]
assert "user" in admin_user["roles"]
assert admin_user["token"] is not None
@pytest.mark.e2e
def test_regular_user_has_only_user_role(regular_user: dict[str, str]) -> None:
"""Test that regular user fixture creates user without admin role."""
assert regular_user["roles"] == ["user"]
assert "admin" not in regular_user["roles"]
assert regular_user["token"] is not None
@pytest.mark.e2e
@pytest.mark.skip(reason="Depends on authenticated_page fixture which needs debugging")
def test_isolated_database_per_session(
base_url: str,
authenticated_page: Page,
) -> None:
"""Test that database is isolated (no data from other tests)."""
authenticated_page.goto(f"{base_url}/web/")
posts = authenticated_page.locator("[data-testid^='post-card-']")
count = posts.count()
assert count == 0, "Expected empty database at start of test"

View File

@@ -0,0 +1,192 @@
"""End-to-end tests for blog post lifecycle.
Tests the complete flow from post creation through publishing
and visibility verification across different user roles.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page
from pytfm.generators import PostDataGenerator
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
@pytest.mark.e2e
def test_user_creates_and_publishes_post_visible_to_guest_and_admin(
user_page: Page,
guest_page: Page,
admin_page: Page,
base_url: str,
) -> None:
"""Test positive scenario: user creates post, publishes it, and verifies visibility.
Steps:
1. Generate unique post data.
2. Authenticated user opens home, clicks "Write a Post".
3. Fills form and publishes the post.
4. Verifies redirect to detail page with "Published" status.
5. Verifies post appears on home page for the user.
6. Verifies post is visible to guest (unauthenticated).
7. Verifies post detail is accessible to guest.
8. Verifies post is visible to admin.
9. Verifies post detail is accessible to admin.
Args:
user_page: Playwright page authenticated as regular user.
guest_page: Unauthenticated Playwright page.
admin_page: Playwright page authenticated as admin.
base_url: Application base URL.
"""
generator = PostDataGenerator()
post_data = generator.generate_post()
title = str(post_data["title"])
content = str(post_data["content"])
tags = ", ".join(post_data["tags"])
home = HomePage(user_page, base_url)
home.open()
home.create_post()
form = PostFormPage(user_page, base_url)
form.fill_form(title, content, tags)
form.publish()
user_page.wait_for_url(
lambda url: "/web/posts/" in url and "new" not in url,
timeout=15000,
)
current_url = user_page.url
assert "new" not in current_url, f"Still on form page: {current_url}"
slug = current_url.rstrip("/").split("/")[-1]
user_page.wait_for_selector('[data-testid="post-detail-title"]')
detail = PostDetailPage(user_page, base_url, slug)
assert detail.get_title() == title
assert detail.is_published()
home.open()
assert home.has_post_with_title(title)
guest_home = HomePage(guest_page, base_url)
guest_home.open()
assert guest_home.has_post_with_title(title)
guest_detail = PostDetailPage(guest_page, base_url, slug)
guest_detail.open()
assert guest_detail.get_title() == title
assert guest_detail.is_published()
admin_home = HomePage(admin_page, base_url)
admin_home.open()
assert admin_home.has_post_with_title(title)
admin_detail = PostDetailPage(admin_page, base_url, slug)
admin_detail.open()
assert admin_detail.get_title() == title
assert admin_detail.is_published()
@pytest.mark.e2e
def test_post_visibility_policies_across_users(
user_page: Page,
user2_page: Page,
guest_page: Page,
admin_page: Page,
base_url: str,
) -> None:
"""Test visibility policies: drafts vs published posts across roles.
Steps:
1. User creates a draft post.
2. User creates and publishes another post.
3. Verify user sees both posts on the home page.
4. Verify user2 sees only the published post.
5. Verify guest sees only the published post.
6. Verify admin sees both posts.
7. Verify user2 receives 404 when accessing the draft directly.
Args:
user_page: Playwright page authenticated as the first regular user.
user2_page: Playwright page authenticated as the second regular user.
guest_page: Unauthenticated Playwright page.
admin_page: Playwright page authenticated as admin.
base_url: Application base URL.
"""
generator = PostDataGenerator()
draft_data = generator.generate_post()
draft_title = str(draft_data["title"])
draft_content = str(draft_data["content"])
draft_tags = ", ".join(draft_data["tags"])
home = HomePage(user_page, base_url)
home.open()
home.create_post()
form = PostFormPage(user_page, base_url)
form.fill_form(draft_title, draft_content, draft_tags)
form.save_draft()
user_page.wait_for_url(
lambda url: "/web/posts/" in url and "new" not in url,
timeout=15000,
)
draft_url = user_page.url
assert "new" not in draft_url, f"Still on form page: {draft_url}"
draft_slug = draft_url.rstrip("/").split("/")[-1]
user_page.wait_for_selector('[data-testid="post-detail-title"]')
draft_detail = PostDetailPage(user_page, base_url, draft_slug)
assert draft_detail.get_title() == draft_title
assert not draft_detail.is_published()
published_data = generator.generate_post()
published_title = str(published_data["title"])
published_content = str(published_data["content"])
published_tags = ", ".join(published_data["tags"])
home.open()
home.create_post()
form = PostFormPage(user_page, base_url)
form.fill_form(published_title, published_content, published_tags)
form.publish()
user_page.wait_for_url(
lambda url: "/web/posts/" in url and "new" not in url,
timeout=15000,
)
published_url = user_page.url
assert "new" not in published_url, f"Still on form page: {published_url}"
published_slug = published_url.rstrip("/").split("/")[-1]
user_page.wait_for_selector('[data-testid="post-detail-title"]')
published_detail = PostDetailPage(user_page, base_url, published_slug)
assert published_detail.get_title() == published_title
assert published_detail.is_published()
home.open()
assert home.has_post_with_title(draft_title)
assert home.has_post_with_title(published_title)
user2_home = HomePage(user2_page, base_url)
user2_home.open()
assert user2_home.has_no_post_with_title(draft_title)
assert user2_home.has_post_with_title(published_title)
guest_home = HomePage(guest_page, base_url)
guest_home.open()
assert guest_home.has_no_post_with_title(draft_title)
assert guest_home.has_post_with_title(published_title)
admin_home = HomePage(admin_page, base_url)
admin_home.open()
assert admin_home.has_post_with_title(draft_title)
assert admin_home.has_post_with_title(published_title)
user2_page.goto(f"{base_url}/web/posts/{draft_slug}")
user2_page.wait_for_selector('[data-testid="error-code"]', timeout=10000)
error_code = user2_page.locator('[data-testid="error-code"]').text_content()
assert error_code == "404"

View File

@@ -0,0 +1,143 @@
"""End-to-end tests for post ownership and RBAC policies.
Tests that admin can edit any post and that regular users
cannot edit posts they do not own.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page
from pytfm.generators import PostDataGenerator
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
@pytest.mark.e2e
def test_admin_can_edit_any_post(
user_page: Page,
admin_page: Page,
base_url: str,
) -> None:
"""Test that admin can edit a post created by another user.
Steps:
1. User creates and publishes a post.
2. Admin opens the post detail page.
3. Admin clicks edit, changes the title, and saves.
4. Verify the post detail shows the updated title.
Args:
user_page: Playwright page authenticated as regular user.
admin_page: Playwright page authenticated as admin.
base_url: Application base URL.
"""
generator = PostDataGenerator()
post_data = generator.generate_post()
title = str(post_data["title"])
content = str(post_data["content"])
tags = ", ".join(post_data["tags"])
home = HomePage(user_page, base_url)
home.open()
home.create_post()
form = PostFormPage(user_page, base_url)
form.fill_form(title, content, tags)
form.publish()
user_page.wait_for_url(
lambda url: "/web/posts/" in url and "new" not in url,
timeout=15000,
)
current_url = user_page.url
assert "new" not in current_url, f"Still on form page: {current_url}"
slug = current_url.rstrip("/").split("/")[-1]
user_page.wait_for_selector('[data-testid="post-detail-title"]')
detail = PostDetailPage(user_page, base_url, slug)
assert detail.get_title() == title
admin_detail = PostDetailPage(admin_page, base_url, slug)
admin_detail.open()
assert admin_detail.can_edit()
admin_detail.edit()
admin_page.wait_for_url(
lambda url: f"/web/posts/{slug}/edit" in url,
timeout=15000,
)
new_data = generator.generate_post()
new_title = str(new_data["title"])
new_content = str(new_data["content"])
new_tags = ", ".join(new_data["tags"])
admin_form = PostFormPage(admin_page, base_url)
admin_form.fill_form(new_title, new_content, new_tags)
admin_form.publish()
admin_page.wait_for_selector(
'[data-testid="post-detail-title"]',
timeout=15000,
)
updated_title = admin_page.locator('[data-testid="post-detail-title"]').text_content()
assert updated_title == new_title
updated_status = admin_page.locator('[data-testid="post-detail-status"]').text_content()
assert updated_status == "Published"
@pytest.mark.e2e
def test_user_cannot_edit_other_users_post(
user_page: Page,
user2_page: Page,
base_url: str,
) -> None:
"""Test that a regular user cannot edit another user's post.
Steps:
1. User creates and publishes a post.
2. User2 opens the post detail page.
3. Verify the edit button is not visible.
4. User2 attempts direct access to the edit URL.
5. Verify a 403 error page is returned.
Args:
user_page: Playwright page authenticated as the first regular user.
user2_page: Playwright page authenticated as the second regular user.
base_url: Application base URL.
"""
generator = PostDataGenerator()
post_data = generator.generate_post()
title = str(post_data["title"])
content = str(post_data["content"])
tags = ", ".join(post_data["tags"])
home = HomePage(user_page, base_url)
home.open()
home.create_post()
form = PostFormPage(user_page, base_url)
form.fill_form(title, content, tags)
form.publish()
user_page.wait_for_url(
lambda url: "/web/posts/" in url and "new" not in url,
timeout=15000,
)
current_url = user_page.url
assert "new" not in current_url, f"Still on form page: {current_url}"
slug = current_url.rstrip("/").split("/")[-1]
user_page.wait_for_selector('[data-testid="post-detail-title"]')
detail = PostDetailPage(user_page, base_url, slug)
assert detail.get_title() == title
user2_detail = PostDetailPage(user2_page, base_url, slug)
user2_detail.open()
assert not user2_detail.can_edit()
user2_page.goto(f"{base_url}/web/posts/{slug}/edit")
user2_page.wait_for_selector('[data-testid="error-code"]', timeout=10000)
error_code = user2_page.locator('[data-testid="error-code"]').text_content()
assert error_code == "403"

View File

@@ -1,163 +0,0 @@
"""E2E tests for viewing posts.
Tests post listing, pagination, and detail view functionality.
"""
import pytest
from playwright.sync_api import Page
@pytest.mark.e2e
class TestHomePage:
"""Tests for blog home page."""
def test_homepage_loads(self, page: Page, base_url: str) -> None:
"""Test that homepage loads and shows expected elements."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Check main elements are visible
assert page.locator("[data-testid='nav-logo']").is_visible()
assert page.locator("[data-testid='page-title-home']").is_visible()
def test_posts_list_displayed(self, page: Page, base_url: str) -> None:
"""Test that posts list is displayed."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Wait for content to load
post_list = page.locator("[data-testid='post-list']")
empty_state = page.locator("[data-testid='empty-state']")
# Either posts or empty state should be visible
assert post_list.is_visible() or empty_state.is_visible(), (
"Neither posts nor empty state visible"
)
def test_navigation_present(self, page: Page, base_url: str) -> None:
"""Test that navigation elements are present."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Logo should be visible
assert page.locator("[data-testid='nav-logo']").is_visible()
def test_theme_toggle_works(self, page: Page, base_url: str) -> None:
"""Test theme toggle functionality."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Theme toggle should be present
theme_toggle = page.locator("[data-testid='theme-toggle']")
assert theme_toggle.is_visible(), "Theme toggle should be visible"
# Click should not error (actual theme change requires visual check)
theme_toggle.click()
@pytest.mark.e2e
class TestPostDetail:
"""Tests for individual post detail page."""
def test_post_detail_loads(self, page: Page, base_url: str) -> None:
"""Test that post detail page loads."""
# First get a post from home page
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# Skip if no posts
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
# Click on first post
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
# Verify we're on detail page
assert page.locator("[data-testid='post-detail-title']").is_visible()
def test_post_detail_content(self, page: Page, base_url: str) -> None:
"""Test that post detail shows content."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
# Navigate to first post
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
# Check content elements
assert page.locator("[data-testid='post-detail-content']").is_visible()
def test_back_to_list(self, page: Page, base_url: str) -> None:
"""Test back button returns to list."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
pytest.skip("No posts available")
# Go to detail
read_more = page.locator("[data-testid^='btn-read-more-']").first
if not read_more.is_visible():
pytest.skip("No posts to click")
read_more.click()
page.wait_for_load_state("networkidle")
# Go back
back_btn = page.locator("[data-testid='btn-back-to-list']")
if back_btn.is_visible():
back_btn.click()
page.wait_for_load_state("networkidle")
# Verify we're back on home
assert "/web/" in page.url
@pytest.mark.e2e
class TestEmptyState:
"""Tests for empty state when no posts."""
def test_empty_state_shown_when_no_posts(self, page: Page, base_url: str) -> None:
"""Test that empty state appears when no posts."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
# If empty state is shown
empty_state = page.locator("[data-testid='empty-state']")
if empty_state.is_visible():
# Check elements
assert page.locator("[data-testid='empty-state-title']").is_visible()
assert page.locator("[data-testid='btn-create-first-post']").is_visible()
def test_create_first_post_button(self, page: Page, base_url: str) -> None:
"""Test 'Create first post' button in empty state."""
page.goto(f"{base_url}/web/")
page.wait_for_load_state("networkidle")
empty_state = page.locator("[data-testid='empty-state']")
if not empty_state.is_visible():
pytest.skip("Posts exist, empty state not shown")
# Guest user won't see button (requires auth)
# But if button is visible, it should work
create_btn = page.locator("[data-testid='btn-create-first-post']")
if create_btn.is_visible():
create_btn.click()
page.wait_for_load_state("networkidle")
# Should navigate to form (or login for guests)
assert "login" in page.url or "new" in page.url