- test_post_deletion.py: user delete own, admin delete any, 403 for other's - test_pagination.py: navigation across pages, boundary on last page - test_errors.py: 404 nonexistent post, 404 for other user's draft - test_post_lifecycle.py: draft-to-publish via edit flow - test_post_ownership.py: user can edit own post - test_profile_and_theme.py: profile page rendering, theme toggle with localStorage - fix(web): remove infinite pagination for USER role (routes.py) - fix(e2e): stabilize all publish() calls with expect_navigation - fix(e2e): add _unique_title() to avoid slug collisions at scale - docs: update FEATURE_POST_LIFECYCLE.md and TEST_MODEL.md coverage
278 lines
8.4 KiB
Python
278 lines
8.4 KiB
Python
"""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)
|
|
|
|
def count_posts(self) -> int:
|
|
"""Count the number of post cards on the page.
|
|
|
|
Returns:
|
|
Number of visible post cards.
|
|
"""
|
|
return self.page.locator('article[data-testid^="post-card-"]').count()
|
|
|
|
def get_current_page(self) -> int:
|
|
"""Get the current pagination page number.
|
|
|
|
Returns:
|
|
Current page number as integer.
|
|
"""
|
|
text = self.page.locator('[data-testid="pagination-current"]').text_content()
|
|
return int(text) if text else 1
|
|
|
|
def can_go_next(self) -> bool:
|
|
"""Check if the next page link is enabled.
|
|
|
|
Returns:
|
|
True if the next pagination control is a clickable link.
|
|
"""
|
|
tag = self.page.locator('[data-testid="pagination-next"]').evaluate(
|
|
"el => el.tagName.toLowerCase()"
|
|
)
|
|
return tag == "a"
|
|
|
|
def can_go_prev(self) -> bool:
|
|
"""Check if the previous page link is enabled.
|
|
|
|
Returns:
|
|
True if the previous pagination control is a clickable link.
|
|
"""
|
|
tag = self.page.locator('[data-testid="pagination-prev"]').evaluate(
|
|
"el => el.tagName.toLowerCase()"
|
|
)
|
|
return tag == "a"
|
|
|
|
def go_to_next_page(self) -> None:
|
|
"""Click the next page pagination link."""
|
|
self.page.locator('[data-testid="pagination-next"]').click()
|
|
|
|
def go_to_prev_page(self) -> None:
|
|
"""Click the previous page pagination link."""
|
|
self.page.locator('[data-testid="pagination-prev"]').click()
|
|
|
|
|
|
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)
|
|
|
|
def delete(self) -> None:
|
|
"""Click the delete button and accept the confirmation dialog."""
|
|
self.page.on("dialog", lambda dialog: dialog.accept())
|
|
self._delete_btn.click(self.page)
|