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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
225
tests/e2e/pages/__init__.py
Normal 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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
192
tests/e2e/test_post_lifecycle.py
Normal file
192
tests/e2e/test_post_lifecycle.py
Normal 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"
|
||||
143
tests/e2e/test_post_ownership.py
Normal file
143
tests/e2e/test_post_ownership.py
Normal 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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user