Основные изменения: - Добавлены 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 — гайд по тестированию
226 lines
6.7 KiB
Python
226 lines
6.7 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)
|
|
|
|
|
|
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)
|