feat: add e2e tests for likes and fix like_count propagation in DTO mapping
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful

- Write 3 e2e tests (TC-E2E-106-108): like/unlike flow, multi-user like, guest redirect
- Add get_like_count() and click_like() to PostDetailPage object
- Fix _map_to_dto in 5 use cases (create, get, list, publish, update) to include like_count
- Fix pre-existing mypy issues in page object (evaluate returns Any)
- Update FEATURE_LIKES.md with verified E2E status
This commit is contained in:
2026-05-10 21:11:28 +03:00
parent c8e19e3ce5
commit 30d9e287a7
8 changed files with 210 additions and 9 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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-001003) — test infrastructure pending
- [ ] E2E tests (TC-E2E-106108) — test infrastructure pending
- [ ] Full device_id middleware for guest like support

View File

@@ -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)

183
tests/e2e/test_likes.py Normal file
View File

@@ -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