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
536 lines
17 KiB
Python
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)
|