Compare commits
2 Commits
3cf6c94da2
...
30d9e287a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 30d9e287a7 | |||
| c8e19e3ce5 |
@@ -91,6 +91,7 @@ class CreatePostUseCase:
|
|||||||
slug=post.slug.value,
|
slug=post.slug.value,
|
||||||
author_id=post.author_id,
|
author_id=post.author_id,
|
||||||
published=post.published,
|
published=post.published,
|
||||||
|
like_count=post.like_count,
|
||||||
tags=post.tags.copy(),
|
tags=post.tags.copy(),
|
||||||
created_at=post.created_at,
|
created_at=post.created_at,
|
||||||
updated_at=post.updated_at,
|
updated_at=post.updated_at,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class GetPostUseCase:
|
|||||||
slug=post.slug.value,
|
slug=post.slug.value,
|
||||||
author_id=post.author_id,
|
author_id=post.author_id,
|
||||||
published=post.published,
|
published=post.published,
|
||||||
|
like_count=post.like_count,
|
||||||
tags=post.tags.copy(),
|
tags=post.tags.copy(),
|
||||||
created_at=post.created_at,
|
created_at=post.created_at,
|
||||||
updated_at=post.updated_at,
|
updated_at=post.updated_at,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class ListPostsUseCase:
|
|||||||
slug=post.slug.value,
|
slug=post.slug.value,
|
||||||
author_id=post.author_id,
|
author_id=post.author_id,
|
||||||
published=post.published,
|
published=post.published,
|
||||||
|
like_count=post.like_count,
|
||||||
tags=post.tags.copy(),
|
tags=post.tags.copy(),
|
||||||
created_at=post.created_at,
|
created_at=post.created_at,
|
||||||
updated_at=post.updated_at,
|
updated_at=post.updated_at,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ class PublishPostUseCase:
|
|||||||
slug=post.slug.value,
|
slug=post.slug.value,
|
||||||
author_id=post.author_id,
|
author_id=post.author_id,
|
||||||
published=post.published,
|
published=post.published,
|
||||||
|
like_count=post.like_count,
|
||||||
tags=post.tags.copy(),
|
tags=post.tags.copy(),
|
||||||
created_at=post.created_at,
|
created_at=post.created_at,
|
||||||
updated_at=post.updated_at,
|
updated_at=post.updated_at,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class UpdatePostUseCase:
|
|||||||
slug=post.slug.value,
|
slug=post.slug.value,
|
||||||
author_id=post.author_id,
|
author_id=post.author_id,
|
||||||
published=post.published,
|
published=post.published,
|
||||||
|
like_count=post.like_count,
|
||||||
tags=post.tags.copy(),
|
tags=post.tags.copy(),
|
||||||
created_at=post.created_at,
|
created_at=post.created_at,
|
||||||
updated_at=post.updated_at,
|
updated_at=post.updated_at,
|
||||||
|
|||||||
@@ -52,6 +52,9 @@
|
|||||||
<span class="post-card-meta-item" data-testid="post-date-{{ post.id }}">
|
<span class="post-card-meta-item" data-testid="post-date-{{ post.id }}">
|
||||||
{{ post.created_at.strftime('%B %d, %Y') }}
|
{{ post.created_at.strftime('%B %d, %Y') }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="post-card-meta-item" data-testid="like-count-{{ post.id }}">
|
||||||
|
👍 {{ post.like_count }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
|
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
|
||||||
|
|||||||
@@ -33,6 +33,13 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
|
<span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="post-card-meta-item" data-testid="post-detail-like-count">
|
||||||
|
<button id="like-button" class="btn-like" data-testid="like-button"
|
||||||
|
data-post-slug="{{ post.slug }}"
|
||||||
|
data-liked="false">
|
||||||
|
👍 <span id="like-count">{{ post.like_count }}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -83,3 +90,42 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script data-testid="like-script">
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var likeButton = document.getElementById('like-button');
|
||||||
|
if (!likeButton) return;
|
||||||
|
|
||||||
|
likeButton.addEventListener('click', function() {
|
||||||
|
var slug = this.getAttribute('data-post-slug');
|
||||||
|
var countSpan = document.getElementById('like-count');
|
||||||
|
|
||||||
|
fetch('/web/posts/' + slug + '/like', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/auth/dev-login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Like request failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
if (data && data.like_count !== undefined) {
|
||||||
|
countSpan.textContent = data.like_count;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Like error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.application.use_cases import (
|
|||||||
GetPostUseCase,
|
GetPostUseCase,
|
||||||
ListPostsUseCase,
|
ListPostsUseCase,
|
||||||
PublishPostUseCase,
|
PublishPostUseCase,
|
||||||
|
TogglePostLikeUseCase,
|
||||||
UpdatePostUseCase,
|
UpdatePostUseCase,
|
||||||
)
|
)
|
||||||
from app.domain.exceptions import (
|
from app.domain.exceptions import (
|
||||||
@@ -523,6 +524,39 @@ async def delete_post(
|
|||||||
return RedirectResponse(url="/web/", status_code=303)
|
return RedirectResponse(url="/web/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/posts/{post_slug}/like")
|
||||||
|
async def toggle_like_web(
|
||||||
|
post_slug: str,
|
||||||
|
user: OptionalUserDep,
|
||||||
|
get_use_case: FromDishka[GetPostUseCase],
|
||||||
|
toggle_use_case: FromDishka[TogglePostLikeUseCase],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Toggle like on a post via web UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post_slug: The URL-friendly slug of the post.
|
||||||
|
user: Current user from cookie or None.
|
||||||
|
get_use_case: Use case for retrieving posts.
|
||||||
|
toggle_use_case: Use case for toggling likes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON dict with updated like_count.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If post not found or user not authenticated.
|
||||||
|
"""
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = await get_use_case.by_slug(post_slug)
|
||||||
|
except NotFoundException:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found") from None
|
||||||
|
|
||||||
|
result = await toggle_use_case.execute(post.id, user.user_id)
|
||||||
|
return {"like_count": result.like_count}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile", response_class=HTMLResponse)
|
@router.get("/profile", response_class=HTMLResponse)
|
||||||
async def profile(
|
async def profile(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ guest identification, and anti-bot protection via JS-only POST.
|
|||||||
- **Layer:** E2E
|
- **Layer:** E2E
|
||||||
- **File:** `tests/e2e/test_likes.py::test_like_unlike_flow`
|
- **File:** `tests/e2e/test_likes.py::test_like_unlike_flow`
|
||||||
- **Scenario:** Create post → like → verify count → unlike → verify count
|
- **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
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
#### TC-E2E-107: Separate users can both like
|
#### 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
|
- **Expected:** Count increments per user
|
||||||
- **Last Verified:** 2026-05-10
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
#### TC-E2E-108: Guest like with different sessions
|
#### TC-E2E-108: Guest redirect on like
|
||||||
- **Type:** Positive
|
- **Type:** Positive
|
||||||
- **Layer:** E2E
|
- **Layer:** E2E
|
||||||
- **File:** `tests/e2e/test_likes.py::test_guest_like_different_sessions`
|
- **File:** `tests/e2e/test_likes.py::test_guest_redirect_on_like`
|
||||||
- **Scenario:** Guest1 likes → count=1 → different device context
|
- **Scenario:** Guest opens published post → clicks like → redirected to login
|
||||||
- **Expected:** Different guests count separately
|
- **Expected:** 401 redirects to `/auth/dev-login`
|
||||||
- **Last Verified:** 2026-05-10
|
- **Last Verified:** 2026-05-10
|
||||||
|
|
||||||
## Coverage Summary
|
## Coverage Summary
|
||||||
@@ -200,10 +200,9 @@ guest identification, and anti-bot protection via JS-only POST.
|
|||||||
| Domain Entities (PostLike, Post) | 2 | ✅ Verified |
|
| Domain Entities (PostLike, Post) | 2 | ✅ Verified |
|
||||||
| API Endpoints | 4 | ✅ Verified |
|
| API Endpoints | 4 | ✅ Verified |
|
||||||
| Web Display | 3 | ⬜ Planned |
|
| Web Display | 3 | ⬜ Planned |
|
||||||
| E2E Flows | 3 | ⬜ Planned |
|
| E2E Flows | 3 | ✅ Verified |
|
||||||
|
|
||||||
## Gaps (Not Yet Covered)
|
## Gaps (Not Yet Covered)
|
||||||
|
|
||||||
- [ ] Web tests (TC-WEB-001–003) — test infrastructure pending
|
- [ ] 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
|
- [ ] Full device_id middleware for guest like support
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class HomePage(BasePage):
|
|||||||
tag = self.page.locator('[data-testid="pagination-next"]').evaluate(
|
tag = self.page.locator('[data-testid="pagination-next"]').evaluate(
|
||||||
"el => el.tagName.toLowerCase()"
|
"el => el.tagName.toLowerCase()"
|
||||||
)
|
)
|
||||||
return tag == "a"
|
return bool(tag == "a")
|
||||||
|
|
||||||
def can_go_prev(self) -> bool:
|
def can_go_prev(self) -> bool:
|
||||||
"""Check if the previous page link is enabled.
|
"""Check if the previous page link is enabled.
|
||||||
@@ -118,7 +118,7 @@ class HomePage(BasePage):
|
|||||||
tag = self.page.locator('[data-testid="pagination-prev"]').evaluate(
|
tag = self.page.locator('[data-testid="pagination-prev"]').evaluate(
|
||||||
"el => el.tagName.toLowerCase()"
|
"el => el.tagName.toLowerCase()"
|
||||||
)
|
)
|
||||||
return tag == "a"
|
return bool(tag == "a")
|
||||||
|
|
||||||
def go_to_next_page(self) -> None:
|
def go_to_next_page(self) -> None:
|
||||||
"""Click the next page pagination link."""
|
"""Click the next page pagination link."""
|
||||||
@@ -208,6 +208,7 @@ class PostDetailPage(BasePage):
|
|||||||
self._content = SmartLocator.by_testid("post-detail-content")
|
self._content = SmartLocator.by_testid("post-detail-content")
|
||||||
self._edit_btn = SmartLocator.by_testid("btn-edit-post")
|
self._edit_btn = SmartLocator.by_testid("btn-edit-post")
|
||||||
self._delete_btn = SmartLocator.by_testid("btn-delete-post")
|
self._delete_btn = SmartLocator.by_testid("btn-delete-post")
|
||||||
|
self._like_button = SmartLocator.by_testid("like-button")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
@@ -275,3 +276,16 @@ class PostDetailPage(BasePage):
|
|||||||
"""Click the delete button and accept the confirmation dialog."""
|
"""Click the delete button and accept the confirmation dialog."""
|
||||||
self.page.on("dialog", lambda dialog: dialog.accept())
|
self.page.on("dialog", lambda dialog: dialog.accept())
|
||||||
self._delete_btn.click(self.page)
|
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
183
tests/e2e/test_likes.py
Normal 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
|
||||||
Reference in New Issue
Block a user