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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
111
tests/e2e/test_errors.py
Normal 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"
|
||||||
111
tests/e2e/test_pagination.py
Normal file
111
tests/e2e/test_pagination.py
Normal 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
|
||||||
175
tests/e2e/test_post_deletion.py
Normal file
175
tests/e2e/test_post_deletion.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
with user_page.expect_navigation(wait_until="networkidle"):
|
||||||
form.publish()
|
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)
|
||||||
|
with user_page.expect_navigation(wait_until="networkidle"):
|
||||||
form.save_draft()
|
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)
|
||||||
|
with user_page.expect_navigation(wait_until="networkidle"):
|
||||||
form.publish()
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
with user_page.expect_navigation(wait_until="networkidle"):
|
||||||
form.publish()
|
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,12 +72,13 @@ 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)
|
||||||
|
with admin_page.expect_navigation(wait_until="networkidle"):
|
||||||
admin_form.publish()
|
admin_form.publish()
|
||||||
|
|
||||||
admin_page.wait_for_selector(
|
admin_page.wait_for_selector(
|
||||||
@@ -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)
|
||||||
|
with user_page.expect_navigation(wait_until="networkidle"):
|
||||||
form.publish()
|
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]
|
||||||
|
|||||||
114
tests/e2e/test_profile_and_theme.py
Normal file
114
tests/e2e/test_profile_and_theme.py
Normal 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
|
||||||
Reference in New Issue
Block a user