diff --git a/app/application/use_cases/create_post.py b/app/application/use_cases/create_post.py index 141da46..e636a24 100644 --- a/app/application/use_cases/create_post.py +++ b/app/application/use_cases/create_post.py @@ -91,6 +91,7 @@ class CreatePostUseCase: slug=post.slug.value, author_id=post.author_id, published=post.published, + like_count=post.like_count, tags=post.tags.copy(), created_at=post.created_at, updated_at=post.updated_at, diff --git a/app/application/use_cases/get_post.py b/app/application/use_cases/get_post.py index 4c7b6c8..e345ebe 100644 --- a/app/application/use_cases/get_post.py +++ b/app/application/use_cases/get_post.py @@ -93,6 +93,7 @@ class GetPostUseCase: slug=post.slug.value, author_id=post.author_id, published=post.published, + like_count=post.like_count, tags=post.tags.copy(), created_at=post.created_at, updated_at=post.updated_at, diff --git a/app/application/use_cases/list_posts.py b/app/application/use_cases/list_posts.py index bd5795e..46ddc51 100644 --- a/app/application/use_cases/list_posts.py +++ b/app/application/use_cases/list_posts.py @@ -138,6 +138,7 @@ class ListPostsUseCase: slug=post.slug.value, author_id=post.author_id, published=post.published, + like_count=post.like_count, tags=post.tags.copy(), created_at=post.created_at, updated_at=post.updated_at, diff --git a/app/application/use_cases/publish_post.py b/app/application/use_cases/publish_post.py index 258ec45..51229f8 100644 --- a/app/application/use_cases/publish_post.py +++ b/app/application/use_cases/publish_post.py @@ -125,6 +125,7 @@ class PublishPostUseCase: slug=post.slug.value, author_id=post.author_id, published=post.published, + like_count=post.like_count, tags=post.tags.copy(), created_at=post.created_at, updated_at=post.updated_at, diff --git a/app/application/use_cases/update_post.py b/app/application/use_cases/update_post.py index 70ca509..a3315c9 100644 --- a/app/application/use_cases/update_post.py +++ b/app/application/use_cases/update_post.py @@ -108,6 +108,7 @@ class UpdatePostUseCase: slug=post.slug.value, author_id=post.author_id, published=post.published, + like_count=post.like_count, tags=post.tags.copy(), created_at=post.created_at, updated_at=post.updated_at, diff --git a/tests/FEATURE_LIKES.md b/tests/FEATURE_LIKES.md index 7bb7491..c46e493 100644 --- a/tests/FEATURE_LIKES.md +++ b/tests/FEATURE_LIKES.md @@ -173,7 +173,7 @@ guest identification, and anti-bot protection via JS-only POST. - **Layer:** E2E - **File:** `tests/e2e/test_likes.py::test_like_unlike_flow` - **Scenario:** Create post → like → verify count → unlike → verify count -- **Expected:** Count toggles correctly +- **Expected:** Count toggles correctly (0→1→0) - **Last Verified:** 2026-05-10 #### TC-E2E-107: Separate users can both like @@ -184,12 +184,12 @@ guest identification, and anti-bot protection via JS-only POST. - **Expected:** Count increments per user - **Last Verified:** 2026-05-10 -#### TC-E2E-108: Guest like with different sessions +#### TC-E2E-108: Guest redirect on like - **Type:** Positive - **Layer:** E2E -- **File:** `tests/e2e/test_likes.py::test_guest_like_different_sessions` -- **Scenario:** Guest1 likes → count=1 → different device context -- **Expected:** Different guests count separately +- **File:** `tests/e2e/test_likes.py::test_guest_redirect_on_like` +- **Scenario:** Guest opens published post → clicks like → redirected to login +- **Expected:** 401 redirects to `/auth/dev-login` - **Last Verified:** 2026-05-10 ## Coverage Summary @@ -200,10 +200,9 @@ guest identification, and anti-bot protection via JS-only POST. | Domain Entities (PostLike, Post) | 2 | ✅ Verified | | API Endpoints | 4 | ✅ Verified | | Web Display | 3 | ⬜ Planned | -| E2E Flows | 3 | ⬜ Planned | +| E2E Flows | 3 | ✅ Verified | ## Gaps (Not Yet Covered) - [ ] Web tests (TC-WEB-001–003) — test infrastructure pending -- [ ] E2E tests (TC-E2E-106–108) — test infrastructure pending - [ ] Full device_id middleware for guest like support diff --git a/tests/e2e/pages/__init__.py b/tests/e2e/pages/__init__.py index ab5fddf..9cbe525 100644 --- a/tests/e2e/pages/__init__.py +++ b/tests/e2e/pages/__init__.py @@ -107,7 +107,7 @@ class HomePage(BasePage): tag = self.page.locator('[data-testid="pagination-next"]').evaluate( "el => el.tagName.toLowerCase()" ) - return tag == "a" + return bool(tag == "a") def can_go_prev(self) -> bool: """Check if the previous page link is enabled. @@ -118,7 +118,7 @@ class HomePage(BasePage): tag = self.page.locator('[data-testid="pagination-prev"]').evaluate( "el => el.tagName.toLowerCase()" ) - return tag == "a" + return bool(tag == "a") def go_to_next_page(self) -> None: """Click the next page pagination link.""" @@ -208,6 +208,7 @@ class PostDetailPage(BasePage): self._content = SmartLocator.by_testid("post-detail-content") self._edit_btn = SmartLocator.by_testid("btn-edit-post") self._delete_btn = SmartLocator.by_testid("btn-delete-post") + self._like_button = SmartLocator.by_testid("like-button") @property def url(self) -> str: @@ -275,3 +276,16 @@ class PostDetailPage(BasePage): """Click the delete button and accept the confirmation dialog.""" self.page.on("dialog", lambda dialog: dialog.accept()) self._delete_btn.click(self.page) + + def get_like_count(self) -> int: + """Get the current like count from the detail page. + + Returns: + Current like count as integer. + """ + text = self.page.locator("#like-count").text_content() + return int(text.strip()) if text else 0 + + def click_like(self) -> None: + """Click the like/unlike button to toggle the like state.""" + self._like_button.click(self.page) diff --git a/tests/e2e/test_likes.py b/tests/e2e/test_likes.py new file mode 100644 index 0000000..e004ce6 --- /dev/null +++ b/tests/e2e/test_likes.py @@ -0,0 +1,183 @@ +"""End-to-end tests for post likes via web UI. + +Tests the like/unlike toggle flow, multi-user like isolation, +and guest authentication redirect. +""" + +from __future__ import annotations + +import uuid + +import pytest +from playwright.sync_api import Page, expect +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_like_unlike_flow( + user_page: Page, + base_url: str, +) -> None: + """Test like/unlike toggle through the web UI. + + Steps: + 1. Create and publish a post. + 2. Verify initial like count is 0. + 3. Click the like button. + 4. Verify like count becomes 1. + 5. Click the like button again. + 6. Verify like count returns to 0. + + 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 + + # Initial like count should be 0 for a new post + assert detail.get_like_count() == 0 + + # Like the post + detail.click_like() + expect(user_page.locator("#like-count")).to_have_text("1", timeout=15000) + + # Unlike the post + detail.click_like() + expect(user_page.locator("#like-count")).to_have_text("0", timeout=15000) + + +@pytest.mark.e2e +def test_multiple_users_can_like( + user_page: Page, + user2_page: Page, + base_url: str, +) -> None: + """Test that two users can independently like the same post. + + Steps: + 1. User creates and publishes a post. + 2. User likes the post (count becomes 1). + 3. User2 opens the same post (sees count=1). + 4. User2 clicks like (count becomes 2). + + Args: + user_page: Playwright page authenticated as first regular user. + user2_page: Playwright page authenticated as 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 + + # User likes the post + assert detail.get_like_count() == 0 + detail.click_like() + expect(user_page.locator("#like-count")).to_have_text("1", timeout=15000) + + # Verify like_count persists after page reload + user_page.reload(wait_until="networkidle") + user_page.wait_for_selector('[data-testid="post-detail-title"]') + assert detail.get_like_count() == 1 + + # User2 opens same post and likes + user2_detail = PostDetailPage(user2_page, base_url, slug) + user2_detail.open() + user2_page.wait_for_selector('[data-testid="post-detail-title"]') + assert user2_detail.get_like_count() == 1 + + user2_detail.click_like() + expect(user2_page.locator("#like-count")).to_have_text("2", timeout=15000) + + +@pytest.mark.e2e +def test_guest_redirect_on_like( + user_page: Page, + guest_page: Page, + base_url: str, +) -> None: + """Test that unauthenticated guests are redirected to login when liking. + + Steps: + 1. User creates and publishes a post. + 2. Guest opens the post detail page. + 3. Guest clicks the like button. + 4. Guest is redirected to the development login page. + + 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.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] + + # Guest opens the published post + guest_detail = PostDetailPage(guest_page, base_url, slug) + guest_detail.open() + guest_page.wait_for_selector('[data-testid="post-detail-title"]') + + # Guest clicks like -> should be redirected to dev login page + with guest_page.expect_navigation(wait_until="networkidle", timeout=15000): + guest_detail.click_like() + + assert "dev-login" in guest_page.url