From cf4982c0e540e861c3714c6abeb80ca94a3df14a Mon Sep 17 00:00:00 2001 From: Sergey Vanyushkin Date: Fri, 8 May 2026 20:25:01 +0300 Subject: [PATCH] =?UTF-8?q?test(e2e):=20add=20TC-E2E-003/004/005/007/008/0?= =?UTF-8?q?09/010=20=E2=80=94=20delete,=20pagination,=20errors,=20profile,?= =?UTF-8?q?=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/presentation/web/routes.py | 2 +- tests/FEATURE_POST_LIFECYCLE.md | 58 ++++++++- tests/TEST_MODEL.md | 8 +- tests/e2e/pages/__init__.py | 52 +++++++++ tests/e2e/test_errors.py | 111 ++++++++++++++++++ tests/e2e/test_pagination.py | 111 ++++++++++++++++++ tests/e2e/test_post_deletion.py | 175 ++++++++++++++++++++++++++++ tests/e2e/test_post_lifecycle.py | 102 ++++++++++++---- tests/e2e/test_post_ownership.py | 98 +++++++++++++--- tests/e2e/test_profile_and_theme.py | 114 ++++++++++++++++++ 10 files changed, 783 insertions(+), 48 deletions(-) create mode 100644 tests/e2e/test_errors.py create mode 100644 tests/e2e/test_pagination.py create mode 100644 tests/e2e/test_post_deletion.py create mode 100644 tests/e2e/test_profile_and_theme.py diff --git a/app/presentation/web/routes.py b/app/presentation/web/routes.py index 10919aa..c560e6d 100644 --- a/app/presentation/web/routes.py +++ b/app/presentation/web/routes.py @@ -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] merged = list(published) + own_drafts 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 diff --git a/tests/FEATURE_POST_LIFECYCLE.md b/tests/FEATURE_POST_LIFECYCLE.md index 18775f0..045cd6f 100644 --- a/tests/FEATURE_POST_LIFECYCLE.md +++ b/tests/FEATURE_POST_LIFECYCLE.md @@ -207,6 +207,49 @@ Covers both API use cases and web UI end-to-end flows. - User2 receives 404 when accessing draft directly - **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 | 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 | | Read post (by id/slug) | Unit | E2E implicitly via detail page | | Update post | Unit | No dedicated E2E | -| Delete post | Unit | No dedicated E2E | -| Publish / Unpublish | Unit + E2E | Authz checks covered in both layers | +| Delete post | Unit + E2E | Own-post and admin-delete covered | +| Publish / Unpublish | Unit + E2E | Draft-to-publish flow covered via edit | | List posts (all filters) | Unit | Pagination arguments passed but not edge-case tested | | 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-025: UpdatePostUseCase — validation error - [ ] TC-UNIT-026: ListPostsUseCase — pagination edge cases (page boundaries, empty page) -- [ ] TC-E2E-003: Edit post via web UI and verify changes -- [ ] TC-E2E-004: Delete post via web UI and verify removal -- [ ] TC-E2E-005: Save post as draft and verify it does not appear to guests +- [x] TC-E2E-003: 404 error page for nonexistent post +- [x] TC-E2E-003a: Edit post via web UI and verify changes (own post) +- [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-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 diff --git a/tests/TEST_MODEL.md b/tests/TEST_MODEL.md index 7099f6b..0e44cad 100644 --- a/tests/TEST_MODEL.md +++ b/tests/TEST_MODEL.md @@ -17,10 +17,10 @@ adding new tests. | Keycloak Auth Client | 80% | — | — | — | P0 | ✅ Active | | App Bootstrap & Config | 75% | — | — | — | P1 | ✅ Stable | | Transaction Manager | 60% | — | — | — | P2 | ⚠️ Partial | -| Web UI Error Handling | — | — | — | 40% | P1 | ⚠️ Partial | -| Pagination | 40% | — | — | — | P1 | ⚠️ Partial | -| Post Edit via Web | — | — | — | — | P1 | ❌ Missing | -| Post Delete via Web | — | — | — | — | P1 | ❌ Missing | +| Web UI Error Handling | — | — | — | 50% | P1 | ⚠️ Partial | +| Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial | +| Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial | +| Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial | Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable diff --git a/tests/e2e/pages/__init__.py b/tests/e2e/pages/__init__.py index 0734a87..ab5fddf 100644 --- a/tests/e2e/pages/__init__.py +++ b/tests/e2e/pages/__init__.py @@ -81,6 +81,53 @@ class HomePage(BasePage): """ 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. @@ -223,3 +270,8 @@ class PostDetailPage(BasePage): 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) diff --git a/tests/e2e/test_errors.py b/tests/e2e/test_errors.py new file mode 100644 index 0000000..3620a63 --- /dev/null +++ b/tests/e2e/test_errors.py @@ -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" diff --git a/tests/e2e/test_pagination.py b/tests/e2e/test_pagination.py new file mode 100644 index 0000000..3755c63 --- /dev/null +++ b/tests/e2e/test_pagination.py @@ -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 diff --git a/tests/e2e/test_post_deletion.py b/tests/e2e/test_post_deletion.py new file mode 100644 index 0000000..9fef892 --- /dev/null +++ b/tests/e2e/test_post_deletion.py @@ -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 diff --git a/tests/e2e/test_post_lifecycle.py b/tests/e2e/test_post_lifecycle.py index 36111e7..1b72faa 100644 --- a/tests/e2e/test_post_lifecycle.py +++ b/tests/e2e/test_post_lifecycle.py @@ -6,6 +6,8 @@ and visibility verification across different user roles. from __future__ import annotations +import uuid + import pytest from playwright.sync_api import Page from pytfm.generators import PostDataGenerator @@ -13,6 +15,11 @@ 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_creates_and_publishes_post_visible_to_guest_and_admin( user_page: Page, @@ -41,7 +48,7 @@ def test_user_creates_and_publishes_post_visible_to_guest_and_admin( """ generator = PostDataGenerator() post_data = generator.generate_post() - title = str(post_data["title"]) + title = _unique_title(str(post_data["title"])) content = str(post_data["content"]) 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.fill_form(title, content, tags) - form.publish() - - user_page.wait_for_url( - lambda url: "/web/posts/" in url and "new" not in url, - timeout=15000, - ) + 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] @@ -117,7 +120,7 @@ def test_post_visibility_policies_across_users( generator = PostDataGenerator() 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_tags = ", ".join(draft_data["tags"]) @@ -127,12 +130,8 @@ def test_post_visibility_policies_across_users( form = PostFormPage(user_page, base_url) form.fill_form(draft_title, draft_content, draft_tags) - form.save_draft() - - user_page.wait_for_url( - lambda url: "/web/posts/" in url and "new" not in url, - timeout=15000, - ) + 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}" draft_slug = draft_url.rstrip("/").split("/")[-1] @@ -143,7 +142,7 @@ def test_post_visibility_policies_across_users( assert not draft_detail.is_published() 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_tags = ", ".join(published_data["tags"]) @@ -152,12 +151,8 @@ def test_post_visibility_policies_across_users( form = PostFormPage(user_page, base_url) form.fill_form(published_title, published_content, published_tags) - form.publish() - - user_page.wait_for_url( - lambda url: "/web/posts/" in url and "new" not in url, - timeout=15000, - ) + with user_page.expect_navigation(wait_until="networkidle"): + form.publish() published_url = user_page.url assert "new" not in published_url, f"Still on form page: {published_url}" 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) error_code = user2_page.locator('[data-testid="error-code"]').text_content() 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) diff --git a/tests/e2e/test_post_ownership.py b/tests/e2e/test_post_ownership.py index 2fb5f82..e5b050a 100644 --- a/tests/e2e/test_post_ownership.py +++ b/tests/e2e/test_post_ownership.py @@ -6,6 +6,8 @@ cannot edit posts they do not own. from __future__ import annotations +import uuid + import pytest from playwright.sync_api import Page from pytfm.generators import PostDataGenerator @@ -13,6 +15,11 @@ 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_admin_can_edit_any_post( user_page: Page, @@ -34,7 +41,7 @@ def test_admin_can_edit_any_post( """ generator = PostDataGenerator() post_data = generator.generate_post() - title = str(post_data["title"]) + title = _unique_title(str(post_data["title"])) content = str(post_data["content"]) tags = ", ".join(post_data["tags"]) @@ -44,12 +51,8 @@ def test_admin_can_edit_any_post( form = PostFormPage(user_page, base_url) form.fill_form(title, content, tags) - form.publish() - - user_page.wait_for_url( - lambda url: "/web/posts/" in url and "new" not in url, - timeout=15000, - ) + 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] @@ -69,13 +72,14 @@ def test_admin_can_edit_any_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_tags = ", ".join(new_data["tags"]) admin_form = PostFormPage(admin_page, base_url) 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( '[data-testid="post-detail-title"]', @@ -87,6 +91,72 @@ def test_admin_can_edit_any_post( 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 def test_user_cannot_edit_other_users_post( user_page: Page, @@ -109,7 +179,7 @@ def test_user_cannot_edit_other_users_post( """ generator = PostDataGenerator() post_data = generator.generate_post() - title = str(post_data["title"]) + title = _unique_title(str(post_data["title"])) content = str(post_data["content"]) tags = ", ".join(post_data["tags"]) @@ -119,12 +189,8 @@ def test_user_cannot_edit_other_users_post( form = PostFormPage(user_page, base_url) form.fill_form(title, content, tags) - form.publish() - - user_page.wait_for_url( - lambda url: "/web/posts/" in url and "new" not in url, - timeout=15000, - ) + 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] diff --git a/tests/e2e/test_profile_and_theme.py b/tests/e2e/test_profile_and_theme.py new file mode 100644 index 0000000..1bb68f8 --- /dev/null +++ b/tests/e2e/test_profile_and_theme.py @@ -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