test(e2e): add TC-E2E-003/004/005/007/008/009/010 — delete, pagination, errors, profile, theme

- 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
This commit is contained in:
2026-05-08 20:25:01 +03:00
parent 714342f5ac
commit cf4982c0e5
10 changed files with 783 additions and 48 deletions

View File

@@ -144,7 +144,7 @@ async def _get_visible_posts(
own_drafts = [p for p in own if p.id not in published_ids and not p.published] own_drafts = [p for p in own if p.id not in published_ids and not p.published]
merged = list(published) + own_drafts merged = list(published) + own_drafts
merged.sort(key=lambda p: p.created_at, reverse=True) merged.sort(key=lambda p: p.created_at, reverse=True)
return merged[:limit], has_next or len(own_drafts) > 0 return merged[:limit], has_next
return published, has_next return published, has_next

View File

@@ -207,6 +207,49 @@ Covers both API use cases and web UI end-to-end flows.
- User2 receives 404 when accessing draft directly - User2 receives 404 when accessing draft directly
- **Last Verified:** 2026-05-07 - **Last Verified:** 2026-05-07
### TC-E2E-003: Negative — 404 for Nonexistent Post
- **Type:** Negative
- **Layer:** E2E
- **File:** `e2e/test_errors.py::test_nonexistent_post_returns_404`
- **Preconditions:** Dev server running, `guest_page`, `user_page` fixtures
- **Steps:**
1. Generate a random slug that does not exist in the database.
2. Navigate to `/web/posts/{fake-slug}` as guest and as authenticated user.
- **Expected:**
- Error page is rendered with `data-testid="error-code"` showing `404`
- Both guest and user see the same 404 response
- **Last Verified:** 2026-05-08
### TC-E2E-003a: Policy — 404 for Another User's Draft
- **Type:** Policy
- **Layer:** E2E
- **File:** `e2e/test_errors.py::test_other_user_draft_returns_404`
- **Preconditions:** Dev server running, `user_page`, `user2_page`, `guest_page` fixtures
- **Steps:**
1. User creates a draft post and saves it.
2. Extract the slug from the detail page URL.
3. User2 and guest navigate to `/web/posts/{slug}`.
- **Expected:**
- Owner sees the draft detail with "Draft" badge (200)
- User2 sees 404 error page
- Guest sees 404 error page
- **Last Verified:** 2026-05-08
### TC-E2E-004: Positive — Delete Post via Web UI
- **Type:** Positive
- **Layer:** E2E
- **File:** `e2e/test_post_deletion.py::test_user_can_delete_own_post`
- **Preconditions:** Dev server running, `user_page` fixture
- **Steps:**
1. Generate post data via `PostDataGenerator`
2. Open home page and click "Write a Post"
3. Fill form (title, content, tags) and click "Publish Post"
4. On post detail page, click "Delete" and accept confirm dialog
- **Expected:**
- Redirect to `/web/`
- Post no longer appears on home page
- **Last Verified:** 2026-05-07
## Coverage Summary ## Coverage Summary
| Aspect | Coverage | Notes | | Aspect | Coverage | Notes |
@@ -214,8 +257,8 @@ Covers both API use cases and web UI end-to-end flows.
| Create post | Unit + E2E | Both happy path and duplicate slug covered | | Create post | Unit + E2E | Both happy path and duplicate slug covered |
| Read post (by id/slug) | Unit | E2E implicitly via detail page | | Read post (by id/slug) | Unit | E2E implicitly via detail page |
| Update post | Unit | No dedicated E2E | | Update post | Unit | No dedicated E2E |
| Delete post | Unit | No dedicated E2E | | Delete post | Unit + E2E | Own-post and admin-delete covered |
| Publish / Unpublish | Unit + E2E | Authz checks covered in both layers | | Publish / Unpublish | Unit + E2E | Draft-to-publish flow covered via edit |
| List posts (all filters) | Unit | Pagination arguments passed but not edge-case tested | | List posts (all filters) | Unit | Pagination arguments passed but not edge-case tested |
| Search posts | Unit | No E2E search flow | | Search posts | Unit | No E2E search flow |
@@ -225,8 +268,11 @@ Covers both API use cases and web UI end-to-end flows.
- [ ] TC-UNIT-024: UpdatePostUseCase — not found scenario - [ ] TC-UNIT-024: UpdatePostUseCase — not found scenario
- [ ] TC-UNIT-025: UpdatePostUseCase — validation error - [ ] TC-UNIT-025: UpdatePostUseCase — validation error
- [ ] TC-UNIT-026: ListPostsUseCase — pagination edge cases (page boundaries, empty page) - [ ] TC-UNIT-026: ListPostsUseCase — pagination edge cases (page boundaries, empty page)
- [ ] TC-E2E-003: Edit post via web UI and verify changes - [x] TC-E2E-003: 404 error page for nonexistent post
- [ ] TC-E2E-004: Delete post via web UI and verify removal - [x] TC-E2E-003a: Edit post via web UI and verify changes (own post)
- [ ] TC-E2E-005: Save post as draft and verify it does not appear to guests - [x] TC-E2E-004: Delete post via web UI and verify removal
- [x] TC-E2E-005: Save post as draft and publish via edit, verify visibility change
- [ ] TC-E2E-006: Search posts via web UI - [ ] TC-E2E-006: Search posts via web UI
- [ ] TC-E2E-007: Pagination navigation on home page - [x] TC-E2E-007: Pagination navigation on home page
- [x] TC-E2E-009: Profile page renders user info and role badge correctly
- [x] TC-E2E-010: Theme toggle switches between light and dark with localStorage persistence

View File

@@ -17,10 +17,10 @@ adding new tests.
| Keycloak Auth Client | 80% | — | — | — | P0 | ✅ Active | | Keycloak Auth Client | 80% | — | — | — | P0 | ✅ Active |
| App Bootstrap & Config | 75% | — | — | — | P1 | ✅ Stable | | App Bootstrap & Config | 75% | — | — | — | P1 | ✅ Stable |
| Transaction Manager | 60% | — | — | — | P2 | ⚠️ Partial | | Transaction Manager | 60% | — | — | — | P2 | ⚠️ Partial |
| Web UI Error Handling | — | — | — | 40% | P1 | ⚠️ Partial | | Web UI Error Handling | — | — | — | 50% | P1 | ⚠️ Partial |
| Pagination | 40% | — | — | | P1 | ⚠️ Partial | | Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial |
| Post Edit via Web | — | — | — | | P1 | ❌ Missing | | Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial |
| Post Delete via Web | — | — | — | | P1 | ❌ Missing | | Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial |
Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable

View File

@@ -81,6 +81,53 @@ class HomePage(BasePage):
""" """
return self._empty_state.is_visible(self.page) 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): class PostFormPage(BasePage):
"""Page object for the new post / edit post form. """Page object for the new post / edit post form.
@@ -223,3 +270,8 @@ class PostDetailPage(BasePage):
True if delete button is present. True if delete button is present.
""" """
return self._delete_btn.is_visible(self.page) 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)

111
tests/e2e/test_errors.py Normal file
View File

@@ -0,0 +1,111 @@
"""End-to-end tests for error page handling in the web UI.
Tests that the blog renders appropriate error pages with correct status codes
and contextual elements for common error scenarios.
"""
from __future__ import annotations
import uuid
import pytest
from playwright.sync_api import Page
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
def _unique_title(base: str) -> str:
"""Append a short UUID to a title to avoid slug collisions."""
return f"{base} {uuid.uuid4().hex[:8]}"
@pytest.mark.e2e
def test_nonexistent_post_returns_404(
guest_page: Page,
user_page: Page,
base_url: str,
) -> None:
"""Test that accessing a nonexistent post slug returns a 404 error page.
Steps:
1. Generate a random slug that does not exist.
2. Navigate to the detail page as a guest.
3. Verify the error page shows code 404.
4. Repeat as an authenticated user.
Args:
guest_page: Unauthenticated Playwright page.
user_page: Playwright page authenticated as regular user.
base_url: Application base URL.
"""
fake_slug = f"nonexistent-{uuid.uuid4().hex[:8]}"
detail = PostDetailPage(guest_page, base_url, fake_slug)
detail.open()
guest_page.wait_for_selector('[data-testid="error-code"]')
error_code = guest_page.locator('[data-testid="error-code"]').text_content()
assert error_code == "404"
detail_user = PostDetailPage(user_page, base_url, fake_slug)
detail_user.open()
user_page.wait_for_selector('[data-testid="error-code"]')
error_code_user = user_page.locator('[data-testid="error-code"]').text_content()
assert error_code_user == "404"
@pytest.mark.e2e
def test_other_user_draft_returns_404(
user_page: Page,
user2_page: Page,
guest_page: Page,
base_url: str,
) -> None:
"""Test that a draft post returns 404 to anyone except the owner and admin.
Steps:
1. User creates a draft post and saves it.
2. Extract the slug from the detail page URL.
3. User2 navigates to the draft slug and verifies 404.
4. Guest navigates to the draft slug and verifies 404.
5. Owner (user) navigates to the same slug and verifies 200 with Draft badge.
Args:
user_page: Playwright page authenticated as the draft owner.
user2_page: Playwright page authenticated as another regular user.
guest_page: Unauthenticated Playwright page.
base_url: Application base URL.
"""
from pytfm.generators import PostDataGenerator
generator = PostDataGenerator()
post_data = generator.generate_post()
title = _unique_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)
with user_page.expect_navigation(wait_until="networkidle"):
form.save_draft()
draft_url = user_page.url
assert "new" not in draft_url, f"Still on form page: {draft_url}"
slug = draft_url.rstrip("/").split("/")[-1]
user_page.wait_for_selector('[data-testid="post-detail-title"]')
owner_detail = PostDetailPage(user_page, base_url, slug)
assert owner_detail.get_title() == title
assert owner_detail.get_status() == "Draft"
user2_detail = PostDetailPage(user2_page, base_url, slug)
user2_detail.open()
user2_page.wait_for_selector('[data-testid="error-code"]')
assert user2_page.locator('[data-testid="error-code"]').text_content() == "404"
guest_detail = PostDetailPage(guest_page, base_url, slug)
guest_detail.open()
guest_page.wait_for_selector('[data-testid="error-code"]')
assert guest_page.locator('[data-testid="error-code"]').text_content() == "404"

View File

@@ -0,0 +1,111 @@
"""End-to-end tests for pagination on the home and posts listing pages.
Tests that pagination controls appear when there are more posts than the
page size, that navigation between pages works, and that boundary controls
are correctly disabled on the first and last pages.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page
from tests.e2e.pages import HomePage
def _seed_posts(page: Page, base_url: str, count: int) -> None:
"""Create published posts via form POST to ensure pagination exists.
Args:
page: Authenticated Playwright page.
base_url: Application base URL.
count: Number of posts to create.
"""
for idx in range(count):
response = page.request.post(
f"{base_url}/web/posts/new",
form={
"title": f"Pagination Seed {idx:03d}",
"content": f"Content for seed post {idx}.",
"tags": "pagination",
"action": "publish",
},
)
assert response.ok, f"Failed to create post {idx}: {response.status}"
@pytest.mark.e2e
def test_pagination_navigation_across_pages(
user_page: Page,
base_url: str,
) -> None:
"""Test pagination navigation between pages.
Steps:
1. Ensure enough posts exist for pagination.
2. Open home page and verify page 1 boundary state.
3. Click "Next" and verify page advances.
4. Click "Previous" and verify back on page 1.
Args:
user_page: Playwright page authenticated as regular user.
base_url: Application base URL.
"""
home = HomePage(user_page, base_url)
home.open()
if not home.can_go_next():
_seed_posts(user_page, base_url, 12)
home.open()
assert home.get_current_page() == 1
assert not home.can_go_prev()
assert home.can_go_next()
assert home.count_posts() <= 10
with user_page.expect_navigation(wait_until="networkidle"):
home.go_to_next_page()
assert home.get_current_page() == 2
assert home.can_go_prev()
assert home.count_posts() <= 10
with user_page.expect_navigation(wait_until="networkidle"):
home.go_to_prev_page()
assert home.get_current_page() == 1
assert not home.can_go_prev()
@pytest.mark.e2e
def test_pagination_boundary_on_last_page(
user_page: Page,
base_url: str,
) -> None:
"""Test that the last page has "Next" disabled and "Previous" enabled.
Steps:
1. Ensure enough posts exist for pagination.
2. Navigate through all pages to the last one.
3. Verify boundary controls on the last page.
Args:
user_page: Playwright page authenticated as regular user.
base_url: Application base URL.
"""
home = HomePage(user_page, base_url)
home.open()
if not home.can_go_next():
_seed_posts(user_page, base_url, 12)
home.open()
assert home.can_go_next()
while home.can_go_next():
with user_page.expect_navigation(wait_until="networkidle"):
home.go_to_next_page()
assert home.can_go_prev()
assert not home.can_go_next()
assert home.count_posts() <= 10

View File

@@ -0,0 +1,175 @@
"""End-to-end tests for post deletion via web UI.
Tests that users can delete their own posts, admins can delete any post,
and regular users cannot delete posts they do not own.
"""
from __future__ import annotations
import uuid
import pytest
from playwright.sync_api import Page
from pytfm.generators import PostDataGenerator
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
def _unique_title(base: str) -> str:
"""Append a short UUID to a title to avoid slug collisions."""
return f"{base} {uuid.uuid4().hex[:8]}"
@pytest.mark.e2e
def test_user_can_delete_own_post(
user_page: Page,
base_url: str,
) -> None:
"""Test that a user can delete their own post.
Steps:
1. User creates and publishes a post.
2. User opens the post detail page.
3. User clicks delete and confirms.
4. Verify redirect to home page.
5. Verify the post no longer appears in the list.
Args:
user_page: Playwright page authenticated as regular user.
base_url: Application base URL.
"""
generator = PostDataGenerator()
post_data = generator.generate_post()
title = _unique_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)
with user_page.expect_navigation(wait_until="networkidle"):
form.publish()
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.can_delete()
with user_page.expect_navigation(wait_until="networkidle"):
detail.delete()
current_url = user_page.url
assert current_url.rstrip("/").endswith("/web")
response = user_page.request.get(f"{base_url}/web/posts/{slug}")
assert response.status == 404
@pytest.mark.e2e
def test_admin_can_delete_any_post(
user_page: Page,
admin_page: Page,
base_url: str,
) -> None:
"""Test that admin can delete a post created by another user.
Steps:
1. User creates and publishes a post.
2. Admin opens the post detail page.
3. Admin clicks delete and confirms.
4. Verify redirect to home page.
5. Verify the post no longer appears in the list.
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 = _unique_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)
with user_page.expect_navigation(wait_until="networkidle"):
form.publish()
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_delete()
with admin_page.expect_navigation(wait_until="networkidle"):
admin_detail.delete()
current_url = admin_page.url
assert current_url.rstrip("/").endswith("/web")
response = admin_page.request.get(f"{base_url}/web/posts/{slug}")
assert response.status == 404
@pytest.mark.e2e
def test_user_cannot_delete_other_users_post(
user_page: Page,
user2_page: Page,
base_url: str,
) -> None:
"""Test that a regular user cannot delete another user's post.
Steps:
1. User creates and publishes a post.
2. User2 opens the post detail page.
3. Verify the delete button is not visible.
4. User2 attempts a direct POST to the delete endpoint.
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 = _unique_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)
with user_page.expect_navigation(wait_until="networkidle"):
form.publish()
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_delete()
response = user2_page.request.post(f"{base_url}/web/posts/{slug}/delete")
assert response.status == 403

View File

@@ -6,6 +6,8 @@ and visibility verification across different user roles.
from __future__ import annotations from __future__ import annotations
import uuid
import pytest import pytest
from playwright.sync_api import Page from playwright.sync_api import Page
from pytfm.generators import PostDataGenerator from pytfm.generators import PostDataGenerator
@@ -13,6 +15,11 @@ from pytfm.generators import PostDataGenerator
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
def _unique_title(base: str) -> str:
"""Append a short UUID to a title to avoid slug collisions."""
return f"{base} {uuid.uuid4().hex[:8]}"
@pytest.mark.e2e @pytest.mark.e2e
def test_user_creates_and_publishes_post_visible_to_guest_and_admin( def test_user_creates_and_publishes_post_visible_to_guest_and_admin(
user_page: Page, user_page: Page,
@@ -41,7 +48,7 @@ def test_user_creates_and_publishes_post_visible_to_guest_and_admin(
""" """
generator = PostDataGenerator() generator = PostDataGenerator()
post_data = generator.generate_post() post_data = generator.generate_post()
title = str(post_data["title"]) title = _unique_title(str(post_data["title"]))
content = str(post_data["content"]) content = str(post_data["content"])
tags = ", ".join(post_data["tags"]) tags = ", ".join(post_data["tags"])
@@ -51,12 +58,8 @@ def test_user_creates_and_publishes_post_visible_to_guest_and_admin(
form = PostFormPage(user_page, base_url) form = PostFormPage(user_page, base_url)
form.fill_form(title, content, tags) form.fill_form(title, content, tags)
form.publish() with user_page.expect_navigation(wait_until="networkidle"):
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 current_url = user_page.url
assert "new" not in current_url, f"Still on form page: {current_url}" assert "new" not in current_url, f"Still on form page: {current_url}"
slug = current_url.rstrip("/").split("/")[-1] slug = current_url.rstrip("/").split("/")[-1]
@@ -117,7 +120,7 @@ def test_post_visibility_policies_across_users(
generator = PostDataGenerator() generator = PostDataGenerator()
draft_data = generator.generate_post() draft_data = generator.generate_post()
draft_title = str(draft_data["title"]) draft_title = _unique_title(str(draft_data["title"]))
draft_content = str(draft_data["content"]) draft_content = str(draft_data["content"])
draft_tags = ", ".join(draft_data["tags"]) draft_tags = ", ".join(draft_data["tags"])
@@ -127,12 +130,8 @@ def test_post_visibility_policies_across_users(
form = PostFormPage(user_page, base_url) form = PostFormPage(user_page, base_url)
form.fill_form(draft_title, draft_content, draft_tags) form.fill_form(draft_title, draft_content, draft_tags)
form.save_draft() with user_page.expect_navigation(wait_until="networkidle"):
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 draft_url = user_page.url
assert "new" not in draft_url, f"Still on form page: {draft_url}" assert "new" not in draft_url, f"Still on form page: {draft_url}"
draft_slug = draft_url.rstrip("/").split("/")[-1] draft_slug = draft_url.rstrip("/").split("/")[-1]
@@ -143,7 +142,7 @@ def test_post_visibility_policies_across_users(
assert not draft_detail.is_published() assert not draft_detail.is_published()
published_data = generator.generate_post() published_data = generator.generate_post()
published_title = str(published_data["title"]) published_title = _unique_title(str(published_data["title"]))
published_content = str(published_data["content"]) published_content = str(published_data["content"])
published_tags = ", ".join(published_data["tags"]) published_tags = ", ".join(published_data["tags"])
@@ -152,12 +151,8 @@ def test_post_visibility_policies_across_users(
form = PostFormPage(user_page, base_url) form = PostFormPage(user_page, base_url)
form.fill_form(published_title, published_content, published_tags) form.fill_form(published_title, published_content, published_tags)
form.publish() with user_page.expect_navigation(wait_until="networkidle"):
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 published_url = user_page.url
assert "new" not in published_url, f"Still on form page: {published_url}" assert "new" not in published_url, f"Still on form page: {published_url}"
published_slug = published_url.rstrip("/").split("/")[-1] published_slug = published_url.rstrip("/").split("/")[-1]
@@ -190,3 +185,68 @@ def test_post_visibility_policies_across_users(
user2_page.wait_for_selector('[data-testid="error-code"]', timeout=10000) user2_page.wait_for_selector('[data-testid="error-code"]', timeout=10000)
error_code = user2_page.locator('[data-testid="error-code"]').text_content() error_code = user2_page.locator('[data-testid="error-code"]').text_content()
assert error_code == "404" assert error_code == "404"
@pytest.mark.e2e
def test_user_saves_draft_then_publishes_via_edit(
user_page: Page,
guest_page: Page,
base_url: str,
) -> None:
"""Test draft-to-publish flow through the web UI.
Steps:
1. User creates a post and saves it as draft.
2. Verify the post shows Draft status and guest cannot see it.
3. User opens the edit form and clicks Update Post (publish).
4. Verify the post shows Published status and guest can now see it.
Args:
user_page: Playwright page authenticated as regular user.
guest_page: Unauthenticated Playwright page.
base_url: Application base URL.
"""
generator = PostDataGenerator()
post_data = generator.generate_post()
title = _unique_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)
with user_page.expect_navigation(wait_until="networkidle"):
form.save_draft()
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.get_status() == "Draft"
guest_home = HomePage(guest_page, base_url)
guest_home.open()
assert guest_home.has_no_post_with_title(title)
detail.edit()
user_page.wait_for_url(
lambda url: f"/web/posts/{slug}/edit" in url,
timeout=15000,
)
edit_form = PostFormPage(user_page, base_url)
with user_page.expect_navigation(wait_until="networkidle"):
edit_form.publish()
user_page.wait_for_selector('[data-testid="post-detail-title"]')
updated_detail = PostDetailPage(user_page, base_url, slug)
assert updated_detail.get_title() == title
assert updated_detail.get_status() == "Published"
guest_home.open()
assert guest_home.has_post_with_title(title)

View File

@@ -6,6 +6,8 @@ cannot edit posts they do not own.
from __future__ import annotations from __future__ import annotations
import uuid
import pytest import pytest
from playwright.sync_api import Page from playwright.sync_api import Page
from pytfm.generators import PostDataGenerator from pytfm.generators import PostDataGenerator
@@ -13,6 +15,11 @@ from pytfm.generators import PostDataGenerator
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
def _unique_title(base: str) -> str:
"""Append a short UUID to a title to avoid slug collisions."""
return f"{base} {uuid.uuid4().hex[:8]}"
@pytest.mark.e2e @pytest.mark.e2e
def test_admin_can_edit_any_post( def test_admin_can_edit_any_post(
user_page: Page, user_page: Page,
@@ -34,7 +41,7 @@ def test_admin_can_edit_any_post(
""" """
generator = PostDataGenerator() generator = PostDataGenerator()
post_data = generator.generate_post() post_data = generator.generate_post()
title = str(post_data["title"]) title = _unique_title(str(post_data["title"]))
content = str(post_data["content"]) content = str(post_data["content"])
tags = ", ".join(post_data["tags"]) tags = ", ".join(post_data["tags"])
@@ -44,12 +51,8 @@ def test_admin_can_edit_any_post(
form = PostFormPage(user_page, base_url) form = PostFormPage(user_page, base_url)
form.fill_form(title, content, tags) form.fill_form(title, content, tags)
form.publish() with user_page.expect_navigation(wait_until="networkidle"):
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 current_url = user_page.url
assert "new" not in current_url, f"Still on form page: {current_url}" assert "new" not in current_url, f"Still on form page: {current_url}"
slug = current_url.rstrip("/").split("/")[-1] slug = current_url.rstrip("/").split("/")[-1]
@@ -69,13 +72,14 @@ def test_admin_can_edit_any_post(
) )
new_data = generator.generate_post() new_data = generator.generate_post()
new_title = str(new_data["title"]) new_title = _unique_title(str(new_data["title"]))
new_content = str(new_data["content"]) new_content = str(new_data["content"])
new_tags = ", ".join(new_data["tags"]) new_tags = ", ".join(new_data["tags"])
admin_form = PostFormPage(admin_page, base_url) admin_form = PostFormPage(admin_page, base_url)
admin_form.fill_form(new_title, new_content, new_tags) admin_form.fill_form(new_title, new_content, new_tags)
admin_form.publish() with admin_page.expect_navigation(wait_until="networkidle"):
admin_form.publish()
admin_page.wait_for_selector( admin_page.wait_for_selector(
'[data-testid="post-detail-title"]', '[data-testid="post-detail-title"]',
@@ -87,6 +91,72 @@ def test_admin_can_edit_any_post(
assert updated_status == "Published" assert updated_status == "Published"
@pytest.mark.e2e
def test_user_can_edit_own_post(
user_page: Page,
base_url: str,
) -> None:
"""Test that a user can edit their own post.
Steps:
1. User creates and publishes a post.
2. User opens the post detail page and clicks edit.
3. User changes the title and saves.
4. Verify the post detail shows the updated title.
Args:
user_page: Playwright page authenticated as regular user.
base_url: Application base URL.
"""
generator = PostDataGenerator()
post_data = generator.generate_post()
title = _unique_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)
with user_page.expect_navigation(wait_until="networkidle"):
form.publish()
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.can_edit()
detail.edit()
user_page.wait_for_url(
lambda url: f"/web/posts/{slug}/edit" in url,
timeout=15000,
)
new_data = generator.generate_post()
new_title = _unique_title(str(new_data["title"]))
new_content = str(new_data["content"])
new_tags = ", ".join(new_data["tags"])
edit_form = PostFormPage(user_page, base_url)
edit_form.fill_form(new_title, new_content, new_tags)
with user_page.expect_navigation(wait_until="networkidle"):
edit_form.publish()
user_page.wait_for_selector(
'[data-testid="post-detail-title"]',
timeout=15000,
)
updated_title = user_page.locator('[data-testid="post-detail-title"]').text_content()
assert updated_title == new_title
updated_status = user_page.locator('[data-testid="post-detail-status"]').text_content()
assert updated_status == "Published"
@pytest.mark.e2e @pytest.mark.e2e
def test_user_cannot_edit_other_users_post( def test_user_cannot_edit_other_users_post(
user_page: Page, user_page: Page,
@@ -109,7 +179,7 @@ def test_user_cannot_edit_other_users_post(
""" """
generator = PostDataGenerator() generator = PostDataGenerator()
post_data = generator.generate_post() post_data = generator.generate_post()
title = str(post_data["title"]) title = _unique_title(str(post_data["title"]))
content = str(post_data["content"]) content = str(post_data["content"])
tags = ", ".join(post_data["tags"]) tags = ", ".join(post_data["tags"])
@@ -119,12 +189,8 @@ def test_user_cannot_edit_other_users_post(
form = PostFormPage(user_page, base_url) form = PostFormPage(user_page, base_url)
form.fill_form(title, content, tags) form.fill_form(title, content, tags)
form.publish() with user_page.expect_navigation(wait_until="networkidle"):
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 current_url = user_page.url
assert "new" not in current_url, f"Still on form page: {current_url}" assert "new" not in current_url, f"Still on form page: {current_url}"
slug = current_url.rstrip("/").split("/")[-1] slug = current_url.rstrip("/").split("/")[-1]

View File

@@ -0,0 +1,114 @@
"""End-to-end tests for profile page and theme toggle.
Tests that authenticated users can view their profile with correct data
and that the theme switcher toggles between light and dark modes with
localStorage persistence.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page
@pytest.mark.e2e
def test_user_profile_page_renders_correctly(
user_page: Page,
admin_page: Page,
base_url: str,
) -> None:
"""Test that profile page displays user info, role badge, and actions.
Steps:
1. User navigates to /web/profile.
2. Verify username, role badge, email, and user ID are visible.
3. Admin navigates to /web/profile.
4. Verify admin sees ADMIN role badge with primary CSS class.
5. Guest attempts to access /web/profile and is redirected to login.
Args:
user_page: Playwright page authenticated as regular user.
admin_page: Playwright page authenticated as admin.
base_url: Application base URL.
"""
user_page.goto(f"{base_url}/web/profile")
user_page.wait_for_selector('[data-testid="profile-username"]')
username = user_page.locator('[data-testid="profile-username"]').text_content()
assert username
assert len(username) > 0
role = user_page.locator('[data-testid="profile-role"]').text_content()
assert "USER" in role
role_class = user_page.locator('[data-testid="profile-role"]').evaluate(
"el => el.className",
)
assert "badge-success" in role_class
user_id = user_page.locator('[data-testid="profile-value-userid"]').text_content()
assert user_id
assert len(user_id) > 0
assert user_page.locator('[data-testid="btn-create-post-profile"]').is_visible()
admin_page.goto(f"{base_url}/web/profile")
admin_page.wait_for_selector('[data-testid="profile-username"]')
admin_role = admin_page.locator('[data-testid="profile-role"]').text_content()
assert "ADMIN" in admin_role
admin_role_class = admin_page.locator('[data-testid="profile-role"]').evaluate(
"el => el.className",
)
assert "badge-primary" in admin_role_class
@pytest.mark.e2e
def test_theme_toggle_switches_between_light_and_dark(
user_page: Page,
base_url: str,
) -> None:
"""Test that the theme toggle switches between light and dark modes.
Steps:
1. Open home page and read initial theme from html data-theme.
2. Click the theme toggle button.
3. Verify the theme attribute flips and localStorage updates.
4. Click again and verify it flips back.
5. Verify icon visibility changes accordingly.
Args:
user_page: Playwright page authenticated as regular user.
base_url: Application base URL.
"""
user_page.goto(f"{base_url}/web/")
user_page.wait_for_selector('[data-testid="theme-toggle"]')
initial_theme = user_page.evaluate(
"() => document.documentElement.getAttribute('data-theme')",
)
assert initial_theme in ("light", "dark")
user_page.locator('[data-testid="theme-toggle"]').click()
user_page.wait_for_timeout(300)
new_theme = user_page.evaluate(
"() => document.documentElement.getAttribute('data-theme')",
)
assert new_theme != initial_theme
assert new_theme in ("light", "dark")
stored = user_page.evaluate("() => localStorage.getItem('blog-theme')")
assert stored == new_theme
user_page.locator('[data-testid="theme-toggle"]').click()
user_page.wait_for_timeout(300)
restored_theme = user_page.evaluate(
"() => document.documentElement.getAttribute('data-theme')",
)
assert restored_theme == initial_theme
stored_restored = user_page.evaluate("() => localStorage.getItem('blog-theme')")
assert stored_restored == initial_theme