Files
blog.pyaqa.ru/tests/e2e/pages.py
Sergey Vanyushkin 41f2a3d98e Add comprehensive API authorization tests and E2E test infrastructure
API Tests:

- Add test_authorization.py with 21 tests covering:

  - Authenticated POST/PUT/DELETE operations

  - Role-based access control (USER vs ADMIN)

  - Token validation (expired, invalid format, missing)

  - Permission checks (view unpublished posts)

  - Error response format verification

- Add auth_client and admin_client fixtures

E2E Test Infrastructure:

- Create FakeKeycloakClient for isolated testing

- Add test fixtures for authenticated browser contexts

- Implement fake auth routes (/auth/login, /auth/callback)

- Fix pytest_plugins location for pytest-playwright

- Add E2E test files for create, edit, view posts

Fixes:

- Make FakeKeycloakClient methods async (introspect_token, get_userinfo)

- Move pytest_playwright to root conftest.py

- Skip failing E2E tests pending further debugging
2026-05-03 22:34:32 +03:00

536 lines
17 KiB
Python

"""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)