Compare commits

...

16 Commits

Author SHA1 Message Date
99acd9d287 Merge pull request 'refactor: update e2e page objects to use SmartLocator .loc() API' (#20) from feature/e2e-smartlocator-update into dev
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
Reviewed-on: #20
2026-05-15 18:10:07 +00:00
96ecad0c6f refactor: update e2e page objects to use SmartLocator .loc() API
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Ultraworked with Sisyphus(https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-15 20:28:55 +03:00
ca84bd7fac Pr commenting (#19)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-13 19:21:43 +00:00
9124aa17d5 feat: add pr-comment step to post CI results on pull requests
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Adds a Woodpecker pipeline step that posts a formatted comment with test results and coverage to Gitea PRs after CI completes.

Comment includes: commit SHA (linked), source/target branches, pipeline link, and a status table for lint, type check, unit tests, integration tests, e2e tests, and coverage percentage.
2026-05-13 21:22:17 +03:00
0e46a5f41b pytfm as external deps (#18)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-11 14:13:40 +00:00
7bf9cce337 fix: replace pytfm workspace dependency with git source for CI compatibility
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Remove the synthetic workspace hack from CI pipeline (fake pyproject.toml
+ git clone was fragile and complex). pytfm is now a clean git dependency
in [tool.uv.sources], so uv resolves it automatically.

Local development still works via: uv add --editable ../pytfm
2026-05-11 16:48:15 +03:00
8ca36cdb44 Comments (#17)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-11 13:03:52 +00:00
7ff3fa0992 feat: add comments feature with nested replies and recursive rendering
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Implement full comments system: domain entities (Comment, CommentLike),
value objects (CommentContent), use cases (CRUD, like toggle), SQLAlchemy
repository, API v1 endpoints, web UI with comment form and nested replies,
i18n translations (EN/RU/FR/DE), and E2E tests.

Fix nested reply (reply-to-reply) not displaying — the flat reply_comments
dict was only queried for top-level comment IDs, so deeply nested replies
were saved to DB (incrementing comment count) but never rendered. Switch
to a recursive Jinja2 macro that renders any nesting depth.
2026-05-11 15:34:20 +03:00
63da25174e Like's (#16)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-10 18:25:15 +00:00
30d9e287a7 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
2026-05-10 21:11:28 +03:00
c8e19e3ce5 feat: add like count display on homepage and thumbs-up toggle on detail page
- Display like count with thumbs-up emoji on post cards in index.html
- Add clickable like/unlike button with JS fetch on post_detail.html
- Add POST /web/posts/{slug}/like endpoint in web routes for cookie-auth users
- Guests redirected to /auth/dev-login on 401
- Use block extra_js (matching base template) for inline script
2026-05-10 19:12:50 +03:00
3cf6c94da2 feat: add like/unlike toggle on blog posts with per-user tracking
- PostLike domain entity (post_id, liked_by) with BaseEntity integration
- Post entity: add like_count field (default 0) and to_dict serialization
- PostRepository interface: add get_like, add_like, remove_like methods
- TogglePostLikeUseCase: toggle logic (like → unlike, unlike → like)
- PostResponseDTO/PostResponseSchema: add like_count field
- PostLikeORM model with FK to posts and cascade delete
- SQLAlchemyPostRepository: implement like query/add/remove with ORM mapping
- DI provider registration for TogglePostLikeUseCase
- API endpoint POST /api/v1/posts/{id}/like (auth required)
- Unit tests: PostLike entity, Post.like_count, TogglePostLikeUseCase (7 tests)
- API tests: POST /api/v1/posts/{id}/like (4 tests)
- Test model files: FEATURE_LIKES.md, TEST_MODEL.md updated
2026-05-10 18:24:09 +03:00
4497f452a1 docs: add Merge & Cleanup step to TDD lifecycle workflows 2026-05-10 17:30:59 +03:00
391ecaa4b0 Localization (#15)
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
2026-05-10 14:19:07 +00:00
de92f73f58 fix(i18n): register _() Jinja2 global and current_locale in error handlers
All checks were successful
ci/woodpecker/pr/pipeline Pipeline was successful
Error handlers had a separate Jinja2Templates instance without the _
global function, causing UndefinedError when rendering base.html
(which now calls {{ _(key, current_locale) }}).

- Register _() from translator module as Jinja2 global on error_handlers templates
- Add current_locale to get_template_context() from request.state.locale
2026-05-10 16:48:56 +03:00
d32ad29abc feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Add i18n support to the blog web UI with 4 languages (en/ru/fr/de),
80 translation keys, automatic Accept-Language detection, persistent
locale cookie, and a language switcher dropdown in the header.

- Infrastructure: TranslationService, translation dicts, convenience _()
- Presentation: locale middleware, /web/lang/{locale} switcher route
- Templates: all 9 templates use {{ _(key, current_locale) }}
- Tests: 26 tests across TranslationService, locale detection helpers
- Docs: TEST_MODEL.md and FEATURE_INFRASTRUCTURE.md updated with TC-UNIT-811-821
2026-05-10 16:22:06 +03:00
76 changed files with 5436 additions and 181 deletions

View File

@@ -21,21 +21,6 @@ steps:
UV_PYTHON: "3.13"
commands:
- pip install uv
- cd ..
- |
cat > pyproject.toml << 'EOF'
[project]
name = "pyaqa"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []
[tool.uv.workspace]
members = ["blog.pyaqa.ru", "pytfm"]
EOF
- git clone https://git.pyaqa.ru/pi3c/pytfm.git
- cd $CI_WORKSPACE
- rm -rf .venv
- uv sync --group lints --group tests --group types --group dev
- name: lint
@@ -130,3 +115,33 @@ steps:
- uv run --no-sync coverage combine .coverage.unit .coverage.integration
- uv run --no-sync coverage report --fail-under=70 --include=app/*
- uv run --no-sync coverage html
- name: pr-comment
image: python:3.13
volumes:
- /tmp/uv-cache:/root/.cache/uv
environment:
UV_CACHE_DIR: /root/.cache/uv
UV_LINK_MODE: copy
UV_PYTHON: "3.13"
GITEA_API_TOKEN:
from_secret: gitea_api_token
depends_on: [coverage, lint, type]
when:
event: [pull_request]
commands:
- pip install uv
- |
SHA7=$(printf '%.7s' "${CI_COMMIT_SHA:-unknown}")
COMMIT_URL="${CI_FORGE_URL}/${CI_REPO_OWNER}/${CI_REPO_NAME}/commit/${CI_COMMIT_SHA}"
SOURCE="${CI_COMMIT_SOURCE:-${CI_COMMIT_SOURCE_BRANCH:-?}}"
TARGET="${CI_COMMIT_TARGET:-${CI_COMMIT_TARGET_BRANCH:-?}}"
PIPELINE_URL="${CI_PIPELINE_URL:-}"
COVER=$(uv run --no-sync coverage report --include='app/*' | tail -1 | awk '{print $NF}')
if [ -z "$GITEA_API_TOKEN" ]; then
echo "pr-comment: GITEA_API_TOKEN not set, skipping"
exit 0
fi
FMT='{"body": "## CI Summary\n\n**Commit:** [`%s`](%s)\n**Branch:** `%s` → `%s`\n**Pipeline:** [View](%s)\n\n### Checks\n\n| Check | Status |\n|-------|--------|\n| Lint (ruff + isort) | ✅ |\n| Type check (mypy) | ✅ |\n| Unit tests | ✅ |\n| Integration tests | ✅ |\n| E2E tests | ✅ |\n| Coverage | **%s** |\n\n---\n*Reported by Woodpecker CI*"}'
BODY=$(printf "$FMT" "$SHA7" "$COMMIT_URL" "$SOURCE" "$TARGET" "$PIPELINE_URL" "$COVER")
curl -s -X POST "${CI_FORGE_URL}/api/v1/repos/${CI_REPO_OWNER}/${CI_REPO_NAME}/issues/${CI_COMMIT_PULL_REQUEST}/comments" -H "Authorization: token $${GITEA_API_TOKEN}" -H "Content-Type: application/json" --data-binary "$BODY"

View File

@@ -406,6 +406,13 @@ User Acceptance
v
Commit (во все затронутые проекты)
|-- blog, pytfm, pyaqa (root)
|
v
Merge & Cleanup
|-- Дождаться влития PR в целевую ветку (dev/main)
|-- Переключиться на целевую ветку
|-- `git pull` — подтянуть изменения
|-- Удалить локальную фича-ветку: `git branch -d feature/{name}`
```
### Bugfix Lifecycle
@@ -441,6 +448,13 @@ User Acceptance
|
v
Commit (во все затронутые проекты)
|
v
Merge & Cleanup
|-- Дождаться влития PR в целевую ветку (dev/main)
|-- Переключиться на целевую ветку
|-- `git pull` — подтянуть изменения
|-- Удалить локальную фича-ветку: `git branch -d feature/{name}`
```
### Refactoring Lifecycle
@@ -481,6 +495,13 @@ User Acceptance (опционально)
|
v
Commit (во все затронутые проекты)
|
v
Merge & Cleanup
|-- Дождаться влития PR в целевую ветку (dev/main)
|-- Переключиться на целевую ветку
|-- `git pull` — подтянуть изменения
|-- Удалить локальную фича-ветку: `git branch -d feature/{name}`
```
### Branch Naming

View File

@@ -4,14 +4,25 @@ This module re-exports all application layer components including
DTOs, interfaces, and use cases for convenient importing.
"""
from app.application.dtos import CreatePostDTO, PostResponseDTO, UpdatePostDTO
from app.application.dtos import (
CommentResponseDTO,
CreateCommentDTO,
CreatePostDTO,
PostResponseDTO,
UpdatePostDTO,
)
from app.application.interfaces import TransactionManager
from app.application.use_cases import (
CreateCommentUseCase,
CreatePostUseCase,
DeleteCommentUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
@@ -19,6 +30,8 @@ __all__ = [
"CreatePostDTO",
"UpdatePostDTO",
"PostResponseDTO",
"CreateCommentDTO",
"CommentResponseDTO",
"TransactionManager",
"CreatePostUseCase",
"GetPostUseCase",
@@ -26,4 +39,9 @@ __all__ = [
"DeletePostUseCase",
"ListPostsUseCase",
"PublishPostUseCase",
"TogglePostLikeUseCase",
"CreateCommentUseCase",
"DeleteCommentUseCase",
"ListCommentsUseCase",
"ToggleCommentLikeUseCase",
]

View File

@@ -4,6 +4,13 @@ This module re-exports all Data Transfer Objects used in the
application layer for data communication.
"""
from app.application.dtos.comment import CommentResponseDTO, CreateCommentDTO
from app.application.dtos.post import CreatePostDTO, PostResponseDTO, UpdatePostDTO
__all__ = ["CreatePostDTO", "UpdatePostDTO", "PostResponseDTO"]
__all__ = [
"CreatePostDTO",
"UpdatePostDTO",
"PostResponseDTO",
"CreateCommentDTO",
"CommentResponseDTO",
]

View File

@@ -0,0 +1,55 @@
"""DTOs for comment use cases.
This module defines Data Transfer Objects used for communication between
application layer comment use cases and presentation layer.
"""
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass(frozen=True)
class CreateCommentDTO:
"""DTO for creating a comment.
Carries comment creation data from API to use case.
Attributes:
post_id: UUID of the post to comment on.
author_id: Identifier of the comment author.
content: Comment content string (Markdown supported).
parent_id: Optional UUID of parent comment for replies.
"""
post_id: UUID
author_id: str
content: str
parent_id: UUID | None = None
@dataclass(frozen=True)
class CommentResponseDTO:
"""DTO for comment response.
Carries complete comment data for API responses.
Attributes:
id: Unique comment identifier.
post_id: UUID of the parent post.
author_id: Comment author identifier.
content: Comment content string.
parent_id: Optional UUID of parent comment.
like_count: Number of likes on this comment.
created_at: Creation timestamp.
updated_at: Last update timestamp.
"""
id: UUID
post_id: UUID
author_id: str
content: str
parent_id: UUID | None = None
like_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None

View File

@@ -100,3 +100,5 @@ class PostResponseDTO:
tags: list[str]
created_at: datetime
updated_at: datetime
like_count: int = 0
comment_count: int = 0

View File

@@ -4,11 +4,16 @@ This module re-exports all application use cases that implement
business logic operations for the blog API.
"""
from app.application.use_cases.create_comment import CreateCommentUseCase
from app.application.use_cases.create_post import CreatePostUseCase
from app.application.use_cases.delete_comment import DeleteCommentUseCase
from app.application.use_cases.delete_post import DeletePostUseCase
from app.application.use_cases.get_post import GetPostUseCase
from app.application.use_cases.list_comments import ListCommentsUseCase
from app.application.use_cases.list_posts import ListPostsUseCase
from app.application.use_cases.publish_post import PublishPostUseCase
from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase
from app.application.use_cases.toggle_like import TogglePostLikeUseCase
from app.application.use_cases.update_post import UpdatePostUseCase
__all__ = [
@@ -18,4 +23,9 @@ __all__ = [
"DeletePostUseCase",
"ListPostsUseCase",
"PublishPostUseCase",
"TogglePostLikeUseCase",
"CreateCommentUseCase",
"DeleteCommentUseCase",
"ListCommentsUseCase",
"ToggleCommentLikeUseCase",
]

View File

@@ -0,0 +1,100 @@
"""Create comment use case.
This module implements the use case for creating comments on blog posts.
Supports both top-level comments and nested replies via parent_id.
"""
from uuid import UUID
from app.application.dtos.comment import CommentResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities.comment import Comment
from app.domain.exceptions import NotFoundException
from app.domain.repositories import CommentRepository, PostRepository
class CreateCommentUseCase:
"""Use case for creating a comment on a blog post.
Handles top-level comments and replies to existing comments.
Validates that the target post exists before creating.
Attributes:
_post_repo: Repository for post data access.
_comment_repo: Repository for comment data access.
_tx_manager: Transaction manager for commit control.
"""
def __init__(
self,
post_repo: PostRepository,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
post_repo: Repository for post operations.
comment_repo: Repository for comment operations.
tx_manager: Transaction manager instance.
"""
self._post_repo = post_repo
self._comment_repo = comment_repo
self._tx_manager = tx_manager
async def execute(
self,
post_id: UUID,
author_id: str,
content: str,
parent_id: UUID | None = None,
) -> CommentResponseDTO:
"""Execute the use case to create a comment.
Args:
post_id: UUID of the post to comment on.
author_id: Identifier of the comment author.
content: Comment content (Markdown supported).
parent_id: Optional UUID of parent comment for replies.
Returns:
CommentResponseDTO with created comment data.
Raises:
NotFoundException: If the target post does not exist.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
comment = Comment.create(
post_id=post_id,
author_id=author_id,
content_str=content,
parent_id=parent_id,
)
await self._comment_repo.add(comment)
await self._tx_manager.commit()
return self._map_to_dto(comment)
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
"""Map domain entity to response DTO.
Args:
comment: Domain Comment entity.
Returns:
CommentResponseDTO with all comment attributes.
"""
return CommentResponseDTO(
id=comment.id,
post_id=comment.post_id,
author_id=comment.author_id,
content=comment.content.value,
parent_id=comment.parent_id,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)

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

@@ -0,0 +1,60 @@
"""Delete comment use case.
This module implements the use case for deleting comments.
Users can delete their own comments.
"""
from uuid import UUID
from app.application.interfaces import TransactionManager
from app.domain.exceptions import ForbiddenException, NotFoundException
from app.domain.repositories import CommentRepository
class DeleteCommentUseCase:
"""Use case for deleting a comment.
Allows users to delete their own comments.
Attributes:
_comment_repo: Repository for comment data access.
_tx_manager: Transaction manager for commit control.
"""
def __init__(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
comment_repo: Repository for comment operations.
tx_manager: Transaction manager instance.
"""
self._comment_repo = comment_repo
self._tx_manager = tx_manager
async def execute(
self,
comment_id: UUID,
user_id: str,
) -> None:
"""Delete a comment.
Args:
comment_id: UUID of the comment to delete.
user_id: Identifier of the user requesting deletion.
Raises:
NotFoundException: If the comment does not exist.
"""
comment = await self._comment_repo.get_by_id(comment_id)
if not comment:
raise NotFoundException(f"Comment with id '{comment_id}' not found")
if comment.author_id != user_id:
raise ForbiddenException("You are not allowed to delete this comment")
await self._comment_repo.delete(comment_id)
await self._tx_manager.commit()

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

@@ -0,0 +1,63 @@
"""List comments use case.
This module implements the use case for listing comments on a blog post.
"""
from uuid import UUID
from app.application.dtos.comment import CommentResponseDTO
from app.domain.entities.comment import Comment
from app.domain.repositories import CommentRepository
class ListCommentsUseCase:
"""Use case for listing comments on a blog post.
Retrieves all comments for a given post ordered by creation time.
Attributes:
_comment_repo: Repository for comment data access.
"""
def __init__(
self,
comment_repo: CommentRepository,
) -> None:
"""Initialize use case with dependencies.
Args:
comment_repo: Repository for comment operations.
"""
self._comment_repo = comment_repo
async def execute(self, post_id: UUID) -> list[CommentResponseDTO]:
"""List all comments for a post.
Args:
post_id: UUID of the post.
Returns:
List of CommentResponseDTO for the post.
"""
comments = await self._comment_repo.get_by_post(post_id)
return [self._map_to_dto(c) for c in comments]
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
"""Map domain entity to response DTO.
Args:
comment: Domain Comment entity.
Returns:
CommentResponseDTO with all comment attributes.
"""
return CommentResponseDTO(
id=comment.id,
post_id=comment.post_id,
author_id=comment.author_id,
content=comment.content.value,
parent_id=comment.parent_id,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.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

@@ -0,0 +1,96 @@
"""Toggle comment like use case.
This module implements the use case for toggling likes on comments.
If the user already liked the comment, the like is removed (unlike).
If not, a new like is added.
"""
from uuid import UUID
from app.application.dtos.comment import CommentResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.exceptions import NotFoundException
from app.domain.repositories import CommentRepository
class ToggleCommentLikeUseCase:
"""Use case for toggling a like on a comment.
Handles like/unlike toggle logic. If the user has already liked
the comment, the like is removed. Otherwise, a new like is created.
Attributes:
_comment_repo: Repository for comment and like data access.
_tx_manager: Transaction manager for commit control.
"""
def __init__(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
comment_repo: Repository for comment and like operations.
tx_manager: Transaction manager instance.
"""
self._comment_repo = comment_repo
self._tx_manager = tx_manager
async def execute(self, comment_id: UUID, liked_by: str) -> CommentResponseDTO:
"""Toggle like on a comment.
If the user already liked the comment, remove the like.
Otherwise, add a new like.
Args:
comment_id: UUID of the comment to toggle like on.
liked_by: User ID.
Returns:
CommentResponseDTO with updated like_count.
Raises:
NotFoundException: If comment with given ID does not exist.
"""
comment = await self._comment_repo.get_by_id(comment_id)
if not comment:
raise NotFoundException(f"Comment with id '{comment_id}' not found")
existing_like = await self._comment_repo.get_like(comment_id, liked_by)
if existing_like:
await self._comment_repo.remove_like(comment_id, liked_by)
comment.like_count = max(0, comment.like_count - 1)
else:
new_like = CommentLike(comment_id=comment_id, liked_by=liked_by)
await self._comment_repo.add_like(new_like)
comment.like_count += 1
await self._comment_repo.update(comment)
await self._tx_manager.commit()
return self._map_to_dto(comment)
def _map_to_dto(self, comment: Comment) -> CommentResponseDTO:
"""Map domain entity to response DTO.
Args:
comment: Domain Comment entity.
Returns:
CommentResponseDTO with all comment attributes including like_count.
"""
return CommentResponseDTO(
id=comment.id,
post_id=comment.post_id,
author_id=comment.author_id,
content=comment.content.value,
parent_id=comment.parent_id,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)

View File

@@ -0,0 +1,102 @@
"""Toggle post like use case.
This module implements the use case for toggling likes on blog posts.
If the user already liked the post, the like is removed (unlike).
If not, a new like is added.
"""
from uuid import UUID
from app.application.dtos.post import PostResponseDTO
from app.application.interfaces import TransactionManager
from app.domain.entities import Post
from app.domain.entities.like import PostLike
from app.domain.exceptions import NotFoundException
from app.domain.repositories import PostRepository
class TogglePostLikeUseCase:
"""Use case for toggling a like on a blog post.
Handles like/unlike toggle logic. If the user or device has already
liked the post, the like is removed. Otherwise, a new like is created.
Attributes:
_post_repo: Repository for post and like data access.
_tx_manager: Transaction manager for commit control.
Example:
>>> use_case = TogglePostLikeUseCase(post_repo, tx_manager)
>>> result = await use_case.execute("my-post-slug", "user-123")
"""
def __init__(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> None:
"""Initialize use case with dependencies.
Args:
post_repo: Repository for post and like operations.
tx_manager: Transaction manager instance.
"""
self._post_repo = post_repo
self._tx_manager = tx_manager
async def execute(self, post_id: UUID, liked_by: str) -> PostResponseDTO:
"""Toggle like on a post.
If the user/device already liked the post, remove the like.
Otherwise, add a new like.
Args:
post_id: UUID of the post to toggle like on.
liked_by: User ID or device identifier.
Returns:
PostResponseDTO with updated like_count.
Raises:
NotFoundException: If post with given ID does not exist.
"""
post = await self._post_repo.get_by_id(post_id)
if not post:
raise NotFoundException(f"Post with id '{post_id}' not found")
existing_like = await self._post_repo.get_like(post_id, liked_by)
if existing_like:
await self._post_repo.remove_like(post_id, liked_by)
post.like_count = max(0, post.like_count - 1)
else:
new_like = PostLike(post_id=post_id, liked_by=liked_by)
await self._post_repo.add_like(new_like)
post.like_count += 1
await self._post_repo.update(post)
await self._tx_manager.commit()
return self._map_to_dto(post)
def _map_to_dto(self, post: Post) -> PostResponseDTO:
"""Map domain entity to response DTO.
Args:
post: Domain post entity.
Returns:
PostResponseDTO with all post attributes including like_count.
"""
return PostResponseDTO(
id=post.id,
title=post.title.value,
content=post.content.value,
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

@@ -4,7 +4,7 @@ This module re-exports all domain layer components including
entities, value objects, repositories, and exceptions.
"""
from app.domain.entities import BaseEntity, Post
from app.domain.entities import BaseEntity, Comment, CommentLike, Post, PostLike
from app.domain.exceptions import (
AlreadyExistsException,
DomainException,
@@ -13,18 +13,22 @@ from app.domain.exceptions import (
UnauthorizedException,
ValidationException,
)
from app.domain.repositories import PostRepository, Repository
from app.domain.repositories import CommentRepository, PostRepository, Repository
from app.domain.value_objects import Content, Slug, Title, ValueObject
__all__ = [
"BaseEntity",
"Post",
"PostLike",
"Comment",
"CommentLike",
"ValueObject",
"Title",
"Content",
"Slug",
"Repository",
"PostRepository",
"CommentRepository",
"DomainException",
"ValidationException",
"NotFoundException",

View File

@@ -5,6 +5,9 @@ core business objects with identity.
"""
from app.domain.entities.base import BaseEntity
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.entities.like import PostLike
from app.domain.entities.post import Post
__all__ = ["BaseEntity", "Post"]
__all__ = ["BaseEntity", "Post", "PostLike", "Comment", "CommentLike"]

View File

@@ -0,0 +1,79 @@
"""Domain entity for Comment.
This module defines the Comment entity that represents a comment on a blog
post. Comments can be top-level (parent_id=None) or replies to other
comments (parent_id set).
"""
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from app.domain.entities.base import BaseEntity
from app.domain.value_objects.comment_content import CommentContent
@dataclass(kw_only=True)
class Comment(BaseEntity):
"""Comment domain entity.
Represents a comment on a blog post with optional parent reference
for nested replies. Supports Markdown content and like tracking.
Attributes:
post_id: UUID of the post this comment belongs to.
author_id: Identifier of the comment author.
content: CommentContent value object with Markdown text.
parent_id: UUID of parent comment for replies, or None.
like_count: Number of likes on this comment.
"""
post_id: UUID
author_id: str
content: CommentContent
parent_id: UUID | None = None
like_count: int = 0
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary.
Returns:
Dictionary representation with all comment attributes.
"""
return {
"id": str(self.id),
"post_id": str(self.post_id),
"author_id": self.author_id,
"content": self.content.value,
"parent_id": str(self.parent_id) if self.parent_id else None,
"like_count": self.like_count,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def create(
cls,
post_id: UUID,
author_id: str,
content_str: str,
parent_id: UUID | None = None,
) -> "Comment":
"""Factory method to create a new comment.
Args:
post_id: UUID of the post to comment on.
author_id: Identifier of the comment author.
content_str: Comment content string (Markdown supported).
parent_id: Optional UUID of parent comment for replies.
Returns:
New Comment instance with validated content.
"""
content = CommentContent(content_str)
return cls(
post_id=post_id,
author_id=author_id,
content=content,
parent_id=parent_id,
)

View File

@@ -0,0 +1,40 @@
"""Domain entity for CommentLike.
This module defines the CommentLike entity that tracks which users
have liked which comments.
"""
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from app.domain.entities.base import BaseEntity
@dataclass(kw_only=True)
class CommentLike(BaseEntity):
"""Comment like domain entity.
Tracks a like on a comment by a user. Each like is uniquely
identified by its entity ID.
Attributes:
comment_id: UUID of the liked comment.
liked_by: Identifier of the user who liked.
"""
comment_id: UUID
liked_by: str
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary.
Returns:
Dictionary with all CommentLike attributes.
"""
return {
"id": str(self.id),
"comment_id": str(self.comment_id),
"liked_by": self.liked_by,
"created_at": self.created_at.isoformat(),
}

View File

@@ -0,0 +1,40 @@
"""Domain entity for PostLike.
This module defines the PostLike entity that tracks which users
or devices have liked which posts.
"""
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from app.domain.entities.base import BaseEntity
@dataclass(kw_only=True)
class PostLike(BaseEntity):
"""Post like domain entity.
Tracks a like on a blog post by a user or device.
Each like is uniquely identified by its entity ID.
Attributes:
post_id: UUID of the liked post.
liked_by: Identifier of the user or device that liked.
"""
post_id: UUID
liked_by: str
def to_dict(self) -> dict[str, Any]:
"""Convert entity to dictionary.
Returns:
Dictionary with all PostLike attributes.
"""
return {
"id": str(self.id),
"post_id": str(self.post_id),
"liked_by": self.liked_by,
"created_at": self.created_at.isoformat(),
}

View File

@@ -44,6 +44,7 @@ class Post(BaseEntity):
slug: Slug
author_id: str
published: bool = False
like_count: int = 0
tags: list[str] = field(default_factory=list)
def publish(self) -> None:
@@ -114,6 +115,7 @@ class Post(BaseEntity):
"slug": self.slug.value,
"author_id": self.author_id,
"published": self.published,
"like_count": self.like_count,
"tags": self.tags.copy(),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),

View File

@@ -5,6 +5,7 @@ the contract for data access operations.
"""
from app.domain.repositories.base import Repository
from app.domain.repositories.comment import CommentRepository
from app.domain.repositories.post import PostRepository
__all__ = ["Repository", "PostRepository"]
__all__ = ["Repository", "PostRepository", "CommentRepository"]

View File

@@ -0,0 +1,80 @@
"""Comment repository interface.
This module defines the repository interface for Comment entities
including nested comment queries and like management.
"""
from abc import abstractmethod
from uuid import UUID
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.repositories.base import Repository
class CommentRepository(Repository[Comment]):
"""Repository interface for Comments.
Extends the generic repository with comment-specific operations
including post-based listing and like management.
Example:
>>> comments = await repo.get_by_post(post_id)
>>> like = await repo.get_like(comment_id, "user-123")
"""
@abstractmethod
async def get_by_post(self, post_id: UUID) -> list[Comment]:
"""Get all comments for a post, ordered by creation time.
Args:
post_id: UUID of the post.
Returns:
List of Comment entities for the post.
"""
...
@abstractmethod
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
"""Get a like by comment and user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
Returns:
CommentLike if found, None otherwise.
"""
...
@abstractmethod
async def add_like(self, like: CommentLike) -> None:
"""Add a new like to a comment.
Args:
like: CommentLike entity to add.
"""
...
@abstractmethod
async def count_by_post(self, post_id: UUID) -> int:
"""Get comment count for a post.
Args:
post_id: UUID of the post.
Returns:
Number of comments on the post.
"""
...
@abstractmethod
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
"""Remove a like from a comment by user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
"""
...

View File

@@ -1,11 +1,14 @@
"""Post repository interface.
This module extends the base repository interface with post-specific
query methods including slug lookup, author filtering, and search.
query methods including slug lookup, author filtering, search, and
like management.
"""
from abc import abstractmethod
from uuid import UUID
from app.domain.entities.like import PostLike
from app.domain.entities.post import Post
from app.domain.repositories.base import Repository
@@ -101,6 +104,38 @@ class PostRepository(Repository[Post]):
"""
...
@abstractmethod
async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None:
"""Get a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
Returns:
PostLike if found, None otherwise.
"""
...
@abstractmethod
async def add_like(self, like: PostLike) -> None:
"""Add a new like.
Args:
like: PostLike entity to add.
"""
...
@abstractmethod
async def remove_like(self, post_id: UUID, liked_by: str) -> None:
"""Remove a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
"""
...
@abstractmethod
async def search(
self,

View File

@@ -5,8 +5,9 @@ immutable validated domain concepts.
"""
from app.domain.value_objects.base import ValueObject
from app.domain.value_objects.comment_content import CommentContent
from app.domain.value_objects.content import Content
from app.domain.value_objects.slug import Slug
from app.domain.value_objects.title import Title
__all__ = ["ValueObject", "Title", "Content", "Slug"]
__all__ = ["ValueObject", "Title", "Content", "Slug", "CommentContent"]

View File

@@ -0,0 +1,47 @@
"""Value object for comment content.
This module defines the CommentContent value object that validates
and encapsulates comment text content.
"""
from dataclasses import dataclass
from app.domain.value_objects.base import ValueObject
@dataclass(frozen=True, slots=True)
class CommentContent(ValueObject[str]):
"""Comment content value object.
Wraps and validates comment content ensuring it meets length
requirements and is not empty.
Attributes:
value: The comment content string.
MAX_LENGTH: Maximum allowed content length (5000 characters).
Raises:
ValueError: If content is empty or too long.
Example:
>>> content = CommentContent("This is a **bold** comment.")
>>> content.value
'This is a **bold** comment.'
"""
MAX_LENGTH: int = 5000
def _validate(self) -> None:
"""Validate comment content.
Checks that content is a non-empty string within length bounds.
Raises:
ValueError: If content fails validation criteria.
"""
if not isinstance(self.value, str):
raise ValueError("Comment content must be a string")
if not self.value.strip():
raise ValueError("Comment content cannot be empty")
if len(self.value) > self.MAX_LENGTH:
raise ValueError(f"Comment content must be at most {self.MAX_LENGTH} characters")

View File

@@ -4,11 +4,13 @@ This module defines the database ORM models that map to database tables.
Models are used by repositories for data persistence.
"""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import JSON, Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
Base = declarative_base()
@@ -42,6 +44,13 @@ class PostORM(Base): # type: ignore[valid-type,misc]
slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
like_count: Mapped[int] = mapped_column(default=0, nullable=False)
likes: Mapped[list[PostLikeORM]] = relationship(
back_populates="post", cascade="all, delete-orphan"
)
comments: Mapped[list[CommentORM]] = relationship(
back_populates="post", cascade="all, delete-orphan"
)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -54,3 +63,116 @@ class PostORM(Base): # type: ignore[valid-type,misc]
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
class PostLikeORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for PostLike.
Database table representation of post likes.
Maps to the 'post_likes' table tracking which users/devices liked which posts.
Attributes:
id: Primary key as UUID string.
post_id: Foreign key to the liked post.
liked_by: User ID or device identifier.
created_at: Creation timestamp.
"""
__tablename__ = "post_likes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
post_id: Mapped[str] = mapped_column(
String(36), ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True
)
liked_by: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
post: Mapped[PostORM] = relationship(back_populates="likes")
class CommentORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for Comment.
Database table representation of comments on blog posts.
Maps to the 'comments' table with support for nested replies.
Attributes:
id: Primary key as UUID string.
post_id: Foreign key to the parent post.
author_id: Comment author reference.
content: Comment text content (Markdown supported).
parent_id: Optional foreign key to parent comment (replies).
like_count: Number of likes on this comment.
created_at: Creation timestamp.
updated_at: Last update timestamp.
"""
__tablename__ = "comments"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
post_id: Mapped[str] = mapped_column(
String(36), ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True
)
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("comments.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
like_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
post: Mapped[PostORM] = relationship(back_populates="comments")
replies: Mapped[list[CommentORM]] = relationship(
back_populates="parent",
)
parent: Mapped[CommentORM | None] = relationship(
back_populates="replies",
remote_side="CommentORM.id",
)
class CommentLikeORM(Base): # type: ignore[valid-type,misc]
"""SQLAlchemy model for CommentLike.
Database table representation of comment likes.
Maps to the 'comment_likes' table tracking which users liked which comments.
Attributes:
id: Primary key as UUID string.
comment_id: Foreign key to the liked comment.
liked_by: User identifier.
created_at: Creation timestamp.
"""
__tablename__ = "comment_likes"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
comment_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("comments.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
liked_by: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
nullable=False,
)

View File

@@ -10,18 +10,24 @@ from dishka import Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from app.application import (
CreateCommentUseCase,
CreatePostUseCase,
DeleteCommentUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
from app.application.interfaces import TransactionManager
from app.domain.repositories import PostRepository
from app.domain.repositories import CommentRepository, PostRepository
from app.infrastructure.auth import KeycloakAuthClient, MockKeycloakClient
from app.infrastructure.config.settings import settings
from app.infrastructure.database.connection import AsyncSessionLocal, engine
from app.infrastructure.repositories.comment import SQLAlchemyCommentRepository
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
@@ -80,6 +86,18 @@ class RepositoryProvider(Provider):
"""
return SQLAlchemyPostRepository(session)
@provide(scope=Scope.REQUEST)
def get_comment_repository(self, session: AsyncSession) -> CommentRepository:
"""Provide CommentRepository implementation.
Args:
session: Database session from DI container.
Returns:
SQLAlchemyCommentRepository instance.
"""
return SQLAlchemyCommentRepository(session)
class TransactionManagerProvider(Provider):
"""Provider for transaction manager.
@@ -236,6 +254,106 @@ class UseCaseProvider(Provider):
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_toggle_like_use_case(
self,
post_repo: PostRepository,
tx_manager: TransactionManager,
) -> TogglePostLikeUseCase:
"""Provide TogglePostLikeUseCase.
Args:
post_repo: Post repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured TogglePostLikeUseCase instance.
"""
return TogglePostLikeUseCase(
post_repo=post_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_create_comment_use_case(
self,
post_repo: PostRepository,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> CreateCommentUseCase:
"""Provide CreateCommentUseCase.
Args:
post_repo: Post repository dependency.
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured CreateCommentUseCase instance.
"""
return CreateCommentUseCase(
post_repo=post_repo,
comment_repo=comment_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_list_comments_use_case(
self,
comment_repo: CommentRepository,
) -> ListCommentsUseCase:
"""Provide ListCommentsUseCase.
Args:
comment_repo: Comment repository dependency.
Returns:
Configured ListCommentsUseCase instance.
"""
return ListCommentsUseCase(
comment_repo=comment_repo,
)
@provide(scope=Scope.REQUEST)
def get_delete_comment_use_case(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> DeleteCommentUseCase:
"""Provide DeleteCommentUseCase.
Args:
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured DeleteCommentUseCase instance.
"""
return DeleteCommentUseCase(
comment_repo=comment_repo,
tx_manager=tx_manager,
)
@provide(scope=Scope.REQUEST)
def get_toggle_comment_like_use_case(
self,
comment_repo: CommentRepository,
tx_manager: TransactionManager,
) -> ToggleCommentLikeUseCase:
"""Provide ToggleCommentLikeUseCase.
Args:
comment_repo: Comment repository dependency.
tx_manager: Transaction manager dependency.
Returns:
Configured ToggleCommentLikeUseCase instance.
"""
return ToggleCommentLikeUseCase(
comment_repo=comment_repo,
tx_manager=tx_manager,
)
class KeycloakProvider(Provider):
"""Provider for Keycloak authentication client.

View File

@@ -0,0 +1,9 @@
"""Internationalization support for the blog application.
This package provides translation dictionaries and the translation service
for localizing the web UI into multiple languages.
"""
from app.infrastructure.i18n.translator import SUPPORTED_LOCALES, TranslationService, _
__all__ = ["SUPPORTED_LOCALES", "TranslationService", "_"]

View File

@@ -0,0 +1,377 @@
"""Translation dictionaries for i18n support.
This module provides translation dictionaries for all supported locales.
Translations are organized by feature area for maintainability.
Keys use dot-separated namespacing to avoid collisions.
"""
TRANSLATIONS: dict[str, dict[str, str]] = {
"en": {
"nav.home": "Home",
"nav.posts": "Posts",
"nav.about": "About",
"header.logo": "Blog",
"header.profile": "Profile",
"header.new_post": "New Post",
"header.sign_out": "Sign Out",
"header.sign_in": "Sign In",
"header.toggle_menu": "Toggle menu",
"header.toggle_theme": "Toggle dark mode",
"header.lang_switcher": "Language",
"footer.copyright": "© 2026 Blog. All rights reserved.",
"footer.about": "About",
"footer.privacy": "Privacy",
"footer.terms": "Terms",
"footer.api": "API",
"home.title": "Blog - Home",
"home.meta_description": "Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.",
"home.meta_keywords": "blog, articles, posts, writing, fastapi, python",
"home.page_title": "Latest Posts",
"home.page_subtitle": "Discover stories, thinking, and expertise from writers on any topic.",
"home.write_post": "Write a Post",
"home.status_published": "Published",
"home.status_draft": "Draft",
"home.read_more": "Read more",
"home.pagination_previous": "Previous",
"home.pagination_next": "Next",
"home.empty_title": "No posts yet",
"home.empty_description": "Be the first to write a post!",
"home.empty_action": "Create your first post",
"post.status_published": "Published",
"post.status_draft": "Draft",
"post.back_to_posts": "Back to posts",
"post.edit": "Edit",
"post.delete": "Delete",
"post.delete_confirm": "Are you sure you want to delete this post?",
"post.comments": "Comments",
"post.write_comment": "Write a Comment",
"post.comment_placeholder": "Write a comment... Markdown is supported.",
"post.replying_to": "Replying to",
"post.cancel_reply": "Cancel reply",
"post.cancel": "Cancel",
"post.submit_comment": "Submit Comment",
"post.reply": "Reply",
"post.no_comments": "No comments yet. Be the first to comment!",
"post.comment_error": "Failed to post comment. Please try again.",
"post_form.title_edit": "Edit Post",
"post_form.title_new": "New Post",
"post_form.page_title_edit": "Edit Post",
"post_form.page_title_new": "Create New Post",
"post_form.label_title": "Title",
"post_form.placeholder_title": "Enter post title",
"post_form.hint_title": "A catchy title for your post",
"post_form.label_content": "Content",
"post_form.placeholder_content": "Write your post content here...",
"post_form.hint_content": "The main content of your post. Markdown is supported.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Comma-separated list of tags",
"post_form.cancel": "Cancel",
"post_form.save_draft": "Save as Draft",
"post_form.update_post": "Update Post",
"post_form.publish_post": "Publish Post",
"profile.title": "User Profile",
"profile.email": "Email:",
"profile.not_provided": "Not provided",
"profile.user_id": "User ID:",
"profile.name": "Name:",
"profile.back_home": "Back to Home",
"profile.new_post": "New Post",
"about.title": "About",
"about.page_title": "About",
"about.description": "A modern blog built with FastAPI and Domain-Driven Design architecture.",
"about.signed_in": "Signed in as {username}.",
"about.browsing_guest": "You are browsing as a guest.",
"about.back_home": "Back to Home",
"flash.post_published": "Post published successfully!",
"flash.post_saved_draft": "Post saved as draft!",
"flash.post_updated": "Post updated successfully!",
"flash.post_deleted": "Post deleted successfully!",
"flash.post_not_found": "Post not found.",
"base.close_message": "Close message",
"base.default_title": "Blog",
"base.meta_description": "Blog - A modern blogging platform built with FastAPI",
"base.meta_keywords": "blog, articles, posts, writing",
"base.meta_author": "Blog Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"ru": {
"nav.home": "Главная",
"nav.posts": "Статьи",
"nav.about": "О нас",
"header.logo": "Блог",
"header.profile": "Профиль",
"header.new_post": "Новая статья",
"header.sign_out": "Выйти",
"header.sign_in": "Войти",
"header.toggle_menu": "Открыть меню",
"header.toggle_theme": "Сменить тему",
"header.lang_switcher": "Язык",
"footer.copyright": "© 2026 Блог. Все права защищены.",
"footer.about": "О нас",
"footer.privacy": "Конфиденциальность",
"footer.terms": "Условия",
"footer.api": "API",
"home.title": "Блог — Главная",
"home.meta_description": "Откройте для себя истории, мысли и опыт авторов на любую тему. Современный блог на FastAPI.",
"home.meta_keywords": "блог, статьи, посты, fastapi, python",
"home.page_title": "Последние статьи",
"home.page_subtitle": "Откройте для себя истории, мысли и опыт авторов на любую тему.",
"home.write_post": "Написать статью",
"home.status_published": "Опубликовано",
"home.status_draft": "Черновик",
"home.read_more": "Читать далее",
"home.pagination_previous": "Назад",
"home.pagination_next": "Вперёд",
"home.empty_title": "Статей пока нет",
"home.empty_description": "Будьте первым, кто напишет статью!",
"home.empty_action": "Создать первую статью",
"post.status_published": "Опубликовано",
"post.status_draft": "Черновик",
"post.back_to_posts": "К списку статей",
"post.edit": "Редактировать",
"post.delete": "Удалить",
"post.delete_confirm": "Вы уверены, что хотите удалить эту статью?",
"post.comments": "Комментарии",
"post.write_comment": "Написать комментарий",
"post.comment_placeholder": "Напишите комментарий... Поддерживается Markdown.",
"post.replying_to": "Ответ",
"post.cancel_reply": "Отменить ответ",
"post.cancel": "Отмена",
"post.submit_comment": "Отправить",
"post.reply": "Ответить",
"post.no_comments": "Пока нет комментариев. Будьте первым!",
"post.comment_error": "Не удалось отправить комментарий. Попробуйте снова.",
"post_form.title_edit": "Редактировать статью",
"post_form.title_new": "Новая статья",
"post_form.page_title_edit": "Редактировать статью",
"post_form.page_title_new": "Создать новую статью",
"post_form.label_title": "Заголовок",
"post_form.placeholder_title": "Введите заголовок статьи",
"post_form.hint_title": "Запоминающийся заголовок для вашей статьи",
"post_form.label_content": "Содержание",
"post_form.placeholder_content": "Напишите содержание статьи здесь...",
"post_form.hint_content": "Основное содержание вашей статьи. Поддерживается Markdown.",
"post_form.label_tags": "Теги",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Список тегов через запятую",
"post_form.cancel": "Отмена",
"post_form.save_draft": "Сохранить черновик",
"post_form.update_post": "Обновить статью",
"post_form.publish_post": "Опубликовать статью",
"profile.title": "Профиль пользователя",
"profile.email": "Email:",
"profile.not_provided": "Не указан",
"profile.user_id": "ID пользователя:",
"profile.name": "Имя:",
"profile.back_home": "На главную",
"profile.new_post": "Новая статья",
"about.title": "О нас",
"about.page_title": "О нас",
"about.description": "Современный блог на FastAPI с архитектурой Domain-Driven Design.",
"about.signed_in": "Вы вошли как {username}.",
"about.browsing_guest": "Вы просматриваете как гость.",
"about.back_home": "На главную",
"flash.post_published": "Статья успешно опубликована!",
"flash.post_saved_draft": "Статья сохранена как черновик!",
"flash.post_updated": "Статья успешно обновлена!",
"flash.post_deleted": "Статья успешно удалена!",
"flash.post_not_found": "Статья не найдена.",
"base.close_message": "Закрыть сообщение",
"base.default_title": "Блог",
"base.meta_description": "Блог — современная платформа для блогов на FastAPI",
"base.meta_keywords": "блог, статьи, посты, письмо",
"base.meta_author": "Команда блога",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"fr": {
"nav.home": "Accueil",
"nav.posts": "Articles",
"nav.about": "À propos",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Nouvel article",
"header.sign_out": "Déconnexion",
"header.sign_in": "Connexion",
"header.toggle_menu": "Menu",
"header.toggle_theme": "Changer le thème",
"header.lang_switcher": "Langue",
"footer.copyright": "© 2026 Blog. Tous droits réservés.",
"footer.about": "À propos",
"footer.privacy": "Confidentialité",
"footer.terms": "Conditions",
"footer.api": "API",
"home.title": "Blog — Accueil",
"home.meta_description": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets. Un blog moderne avec FastAPI.",
"home.meta_keywords": "blog, articles, posts, écriture, fastapi, python",
"home.page_title": "Derniers articles",
"home.page_subtitle": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets.",
"home.write_post": "Écrire un article",
"home.status_published": "Publié",
"home.status_draft": "Brouillon",
"home.read_more": "Lire la suite",
"home.pagination_previous": "Précédent",
"home.pagination_next": "Suivant",
"home.empty_title": "Aucun article pour le moment",
"home.empty_description": "Soyez le premier à écrire un article !",
"home.empty_action": "Créer votre premier article",
"post.status_published": "Publié",
"post.status_draft": "Brouillon",
"post.back_to_posts": "Retour aux articles",
"post.edit": "Modifier",
"post.delete": "Supprimer",
"post.delete_confirm": "Êtes-vous sûr de vouloir supprimer cet article ?",
"post.comments": "Commentaires",
"post.write_comment": "Écrire un commentaire",
"post.comment_placeholder": "Écrivez un commentaire... Markdown est supporté.",
"post.replying_to": "Répondre à",
"post.cancel_reply": "Annuler la réponse",
"post.cancel": "Annuler",
"post.submit_comment": "Soumettre",
"post.reply": "Répondre",
"post.no_comments": "Aucun commentaire pour le moment. Soyez le premier !",
"post.comment_error": "Échec de l'envoi du commentaire. Veuillez réessayer.",
"post_form.title_edit": "Modifier l'article",
"post_form.title_new": "Nouvel article",
"post_form.page_title_edit": "Modifier l'article",
"post_form.page_title_new": "Créer un nouvel article",
"post_form.label_title": "Titre",
"post_form.placeholder_title": "Entrez le titre de l'article",
"post_form.hint_title": "Un titre accrocheur pour votre article",
"post_form.label_content": "Contenu",
"post_form.placeholder_content": "Écrivez votre article ici...",
"post_form.hint_content": "Le contenu principal de votre article. Markdown est supporté.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Liste de tags séparés par des virgules",
"post_form.cancel": "Annuler",
"post_form.save_draft": "Sauvegarder le brouillon",
"post_form.update_post": "Mettre à jour",
"post_form.publish_post": "Publier",
"profile.title": "Profil utilisateur",
"profile.email": "Email :",
"profile.not_provided": "Non fourni",
"profile.user_id": "ID utilisateur :",
"profile.name": "Nom :",
"profile.back_home": "Retour à l'accueil",
"profile.new_post": "Nouvel article",
"about.title": "À propos",
"about.page_title": "À propos",
"about.description": "Un blog moderne construit avec FastAPI et une architecture Domain-Driven Design.",
"about.signed_in": "Connecté en tant que {username}.",
"about.browsing_guest": "Vous naviguez en tant qu'invité.",
"about.back_home": "Retour à l'accueil",
"flash.post_published": "Article publié avec succès !",
"flash.post_saved_draft": "Article sauvegardé comme brouillon !",
"flash.post_updated": "Article mis à jour avec succès !",
"flash.post_deleted": "Article supprimé avec succès !",
"flash.post_not_found": "Article non trouvé.",
"base.close_message": "Fermer le message",
"base.default_title": "Blog",
"base.meta_description": "Blog — Une plateforme de blog moderne construite avec FastAPI",
"base.meta_keywords": "blog, articles, posts, écriture",
"base.meta_author": "Équipe du blog",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
"de": {
"nav.home": "Startseite",
"nav.posts": "Beiträge",
"nav.about": "Über uns",
"header.logo": "Blog",
"header.profile": "Profil",
"header.new_post": "Neuer Beitrag",
"header.sign_out": "Abmelden",
"header.sign_in": "Anmelden",
"header.toggle_menu": "Menü umschalten",
"header.toggle_theme": "Design umschalten",
"header.lang_switcher": "Sprache",
"footer.copyright": "© 2026 Blog. Alle Rechte vorbehalten.",
"footer.about": "Über uns",
"footer.privacy": "Datenschutz",
"footer.terms": "AGB",
"footer.api": "API",
"home.title": "Blog — Startseite",
"home.meta_description": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema. Ein moderner Blog mit FastAPI.",
"home.meta_keywords": "Blog, Artikel, Beiträge, Schreiben, Fastapi, Python",
"home.page_title": "Neueste Beiträge",
"home.page_subtitle": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema.",
"home.write_post": "Beitrag schreiben",
"home.status_published": "Veröffentlicht",
"home.status_draft": "Entwurf",
"home.read_more": "Weiterlesen",
"home.pagination_previous": "Zurück",
"home.pagination_next": "Weiter",
"home.empty_title": "Noch keine Beiträge",
"home.empty_description": "Schreiben Sie den ersten Beitrag!",
"home.empty_action": "Ersten Beitrag erstellen",
"post.status_published": "Veröffentlicht",
"post.status_draft": "Entwurf",
"post.back_to_posts": "Zurück zu den Beiträgen",
"post.edit": "Bearbeiten",
"post.delete": "Löschen",
"post.delete_confirm": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
"post.comments": "Kommentare",
"post.write_comment": "Kommentar schreiben",
"post.comment_placeholder": "Schreiben Sie einen Kommentar... Markdown wird unterstützt.",
"post.replying_to": "Antwort an",
"post.cancel_reply": "Antwort abbrechen",
"post.cancel": "Abbrechen",
"post.submit_comment": "Absenden",
"post.reply": "Antworten",
"post.no_comments": "Noch keine Kommentare. Seien Sie der Erste!",
"post.comment_error": "Kommentar konnte nicht gesendet werden. Bitte versuchen Sie es erneut.",
"post_form.title_edit": "Beitrag bearbeiten",
"post_form.title_new": "Neuer Beitrag",
"post_form.page_title_edit": "Beitrag bearbeiten",
"post_form.page_title_new": "Neuen Beitrag erstellen",
"post_form.label_title": "Titel",
"post_form.placeholder_title": "Geben Sie den Beitragstitel ein",
"post_form.hint_title": "Ein eingängiger Titel für Ihren Beitrag",
"post_form.label_content": "Inhalt",
"post_form.placeholder_content": "Schreiben Sie Ihren Beitrag hier...",
"post_form.hint_content": "Der Hauptinhalt Ihres Beitrags. Markdown wird unterstützt.",
"post_form.label_tags": "Tags",
"post_form.placeholder_tags": "python, fastapi, tutorial",
"post_form.hint_tags": "Kommagetrennte Tag-Liste",
"post_form.cancel": "Abbrechen",
"post_form.save_draft": "Als Entwurf speichern",
"post_form.update_post": "Beitrag aktualisieren",
"post_form.publish_post": "Beitrag veröffentlichen",
"profile.title": "Benutzerprofil",
"profile.email": "E-Mail:",
"profile.not_provided": "Nicht angegeben",
"profile.user_id": "Benutzer-ID:",
"profile.name": "Name:",
"profile.back_home": "Zurück zur Startseite",
"profile.new_post": "Neuer Beitrag",
"about.title": "Über uns",
"about.page_title": "Über uns",
"about.description": "Ein moderner Blog, erstellt mit FastAPI und Domain-Driven Design Architektur.",
"about.signed_in": "Angemeldet als {username}.",
"about.browsing_guest": "Sie surfen als Gast.",
"about.back_home": "Zurück zur Startseite",
"flash.post_published": "Beitrag erfolgreich veröffentlicht!",
"flash.post_saved_draft": "Beitrag als Entwurf gespeichert!",
"flash.post_updated": "Beitrag erfolgreich aktualisiert!",
"flash.post_deleted": "Beitrag erfolgreich gelöscht!",
"flash.post_not_found": "Beitrag nicht gefunden.",
"base.close_message": "Nachricht schließen",
"base.default_title": "Blog",
"base.meta_description": "Blog — Eine moderne Blogging-Plattform mit FastAPI",
"base.meta_keywords": "Blog, Artikel, Beiträge, Schreiben",
"base.meta_author": "Blog-Team",
"lang.en": "English",
"lang.ru": "Русский",
"lang.fr": "Français",
"lang.de": "Deutsch",
},
}

View File

@@ -0,0 +1,78 @@
"""Translation service for i18n support.
This module provides the translation service that resolves translation keys
to localized strings using in-memory translation dictionaries. Falls back
from requested locale through English to the raw key.
"""
from app.infrastructure.i18n.translations import TRANSLATIONS
SUPPORTED_LOCALES = frozenset({"en", "ru", "fr", "de"})
DEFAULT_LOCALE = "en"
class TranslationService:
"""Service for resolving translation keys to localized strings.
Provides a singleton-like interface for translating UI strings
across the application. Falls back through requested locale to
English and finally to the raw key if no translation exists.
Attributes:
translations: Dictionary of locale to key to string mappings.
"""
_instance: "TranslationService | None" = None
def __init__(self) -> None:
"""Initialize translation service with translation data."""
self.translations = TRANSLATIONS
@classmethod
def get_instance(cls) -> "TranslationService":
"""Get or create the singleton instance.
Returns:
The shared TranslationService instance.
"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def get_text(self, key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Get translated text for a given key and locale.
Resolves the key through the locale chain: requested locale,
then English fallback, then the raw key itself.
Args:
key: Translation key (e.g. ``nav.home``).
locale: Target locale code (e.g. ``en``, ``ru``, ``fr``, ``de``).
Returns:
Translated string if found, otherwise the English version
or the key itself as last resort.
"""
locale_translations = self.translations.get(locale)
if locale_translations is not None and key in locale_translations:
return locale_translations[key]
if locale != DEFAULT_LOCALE:
fallback = self.translations.get(DEFAULT_LOCALE, {}).get(key)
if fallback is not None:
return fallback
return key
def _(key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Convenience function for translating a single key.
Args:
key: Translation key to look up.
locale: Target locale code.
Returns:
Translated string or the key itself if no translation found.
"""
return TranslationService.get_instance().get_text(key, locale)

View File

@@ -0,0 +1,240 @@
"""SQLAlchemy implementation of CommentRepository.
This module provides the concrete implementation of CommentRepository
using SQLAlchemy ORM for data persistence.
"""
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.repositories import CommentRepository
from app.domain.value_objects.comment_content import CommentContent
from app.infrastructure.database.models import CommentLikeORM, CommentORM
class SQLAlchemyCommentRepository(CommentRepository):
"""SQLAlchemy implementation of Comment repository.
Provides data access methods for Comment entities using SQLAlchemy ORM.
Handles conversion between domain entities and ORM models.
Attributes:
_session: SQLAlchemy async session for database operations.
"""
def __init__(self, session: AsyncSession) -> None:
"""Initialize repository with session.
Args:
session: SQLAlchemy async session instance.
"""
self._session = session
def _to_domain(self, orm: CommentORM) -> Comment:
"""Convert ORM model to domain entity.
Args:
orm: SQLAlchemy ORM model instance.
Returns:
Domain Comment entity with validated value objects.
"""
return Comment(
id=UUID(orm.id),
post_id=UUID(orm.post_id),
author_id=orm.author_id,
content=CommentContent(orm.content),
parent_id=UUID(orm.parent_id) if orm.parent_id else None,
like_count=orm.like_count,
created_at=orm.created_at,
updated_at=orm.updated_at,
)
def _to_orm(self, comment: Comment) -> CommentORM:
"""Convert domain entity to ORM model.
Args:
comment: Domain Comment entity.
Returns:
SQLAlchemy ORM model instance.
"""
return CommentORM(
id=str(comment.id),
post_id=str(comment.post_id),
author_id=comment.author_id,
content=comment.content.value,
parent_id=str(comment.parent_id) if comment.parent_id else None,
like_count=comment.like_count,
created_at=comment.created_at,
updated_at=comment.updated_at,
)
async def get_by_id(self, entity_id: UUID) -> Comment | None:
"""Get comment by ID.
Args:
entity_id: Unique identifier of the comment.
Returns:
Comment entity if found, None otherwise.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
return self._to_domain(orm) if orm else None
async def get_all(self) -> list[Comment]:
"""Get all comments.
Returns:
List of all Comment entities.
"""
result = await self._session.execute(select(CommentORM))
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def add(self, entity: Comment) -> None:
"""Add new comment.
Args:
entity: Comment entity to add.
"""
orm = self._to_orm(entity)
self._session.add(orm)
async def update(self, entity: Comment) -> None:
"""Update existing comment.
Args:
entity: Comment entity with updated data.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity.id))
)
orm = result.scalar_one()
orm.content = entity.content.value
orm.like_count = entity.like_count
orm.updated_at = entity.updated_at
async def delete(self, entity_id: UUID) -> None:
"""Delete comment by ID.
Args:
entity_id: Unique identifier of the comment to delete.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)
async def exists(self, entity_id: UUID) -> bool:
"""Check if comment exists.
Args:
entity_id: Unique identifier of the comment.
Returns:
True if comment exists, False otherwise.
"""
result = await self._session.execute(
select(CommentORM).where(CommentORM.id == str(entity_id))
)
return result.scalar_one_or_none() is not None
async def get_by_post(self, post_id: UUID) -> list[Comment]:
"""Get all comments for a post, ordered by creation time.
Args:
post_id: UUID of the post.
Returns:
List of Comment entities for the post.
"""
result = await self._session.execute(
select(CommentORM)
.where(CommentORM.post_id == str(post_id))
.order_by(CommentORM.created_at.asc())
)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def count_by_post(self, post_id: UUID) -> int:
"""Get comment count for a post.
Args:
post_id: UUID of the post.
Returns:
Number of comments on the post.
"""
result = await self._session.execute(
select(func.count()).select_from(CommentORM).where(CommentORM.post_id == str(post_id))
)
count: int = result.scalar() or 0
return count
async def get_like(self, comment_id: UUID, liked_by: str) -> CommentLike | None:
"""Get a like by comment and user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
Returns:
CommentLike if found, None otherwise.
"""
result = await self._session.execute(
select(CommentLikeORM).where(
CommentLikeORM.comment_id == str(comment_id),
CommentLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if not orm:
return None
return CommentLike(
id=UUID(orm.id),
comment_id=UUID(orm.comment_id),
liked_by=orm.liked_by,
created_at=orm.created_at,
)
async def add_like(self, like: CommentLike) -> None:
"""Add a new like to a comment.
Args:
like: CommentLike entity to add.
"""
orm = CommentLikeORM(
id=str(like.id),
comment_id=str(like.comment_id),
liked_by=like.liked_by,
created_at=like.created_at,
)
self._session.add(orm)
async def remove_like(self, comment_id: UUID, liked_by: str) -> None:
"""Remove a like from a comment by user.
Args:
comment_id: UUID of the comment.
liked_by: User ID.
"""
result = await self._session.execute(
select(CommentLikeORM).where(
CommentLikeORM.comment_id == str(comment_id),
CommentLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)

View File

@@ -10,9 +10,10 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities import Post
from app.domain.entities.like import PostLike
from app.domain.repositories import PostRepository
from app.domain.value_objects import Content, Slug, Title
from app.infrastructure.database.models import PostORM
from app.infrastructure.database.models import PostLikeORM, PostORM
class SQLAlchemyPostRepository(PostRepository):
@@ -53,6 +54,7 @@ class SQLAlchemyPostRepository(PostRepository):
slug=Slug(orm.slug),
author_id=orm.author_id,
published=orm.published,
like_count=orm.like_count,
tags=orm.tags or [],
created_at=orm.created_at,
updated_at=orm.updated_at,
@@ -74,6 +76,7 @@ class SQLAlchemyPostRepository(PostRepository):
slug=post.slug.value,
author_id=post.author_id,
published=post.published,
like_count=post.like_count,
tags=post.tags,
created_at=post.created_at,
updated_at=post.updated_at,
@@ -124,6 +127,7 @@ class SQLAlchemyPostRepository(PostRepository):
orm.content = entity.content.value
orm.slug = entity.slug.value
orm.published = entity.published
orm.like_count = entity.like_count
orm.tags = entity.tags
orm.updated_at = entity.updated_at
@@ -284,3 +288,60 @@ class SQLAlchemyPostRepository(PostRepository):
result = await self._session.execute(stmt)
orms = result.scalars().all()
return [self._to_domain(orm) for orm in orms]
async def get_like(self, post_id: UUID, liked_by: str) -> PostLike | None:
"""Get a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
Returns:
PostLike if found, None otherwise.
"""
result = await self._session.execute(
select(PostLikeORM).where(
PostLikeORM.post_id == str(post_id),
PostLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if not orm:
return None
return PostLike(
id=UUID(orm.id),
post_id=UUID(orm.post_id),
liked_by=orm.liked_by,
created_at=orm.created_at,
)
async def add_like(self, like: PostLike) -> None:
"""Add a new like.
Args:
like: PostLike entity to add.
"""
orm = PostLikeORM(
id=str(like.id),
post_id=str(like.post_id),
liked_by=like.liked_by,
created_at=like.created_at,
)
self._session.add(orm)
async def remove_like(self, post_id: UUID, liked_by: str) -> None:
"""Remove a like by post and user/device.
Args:
post_id: UUID of the post.
liked_by: User ID or device ID.
"""
result = await self._session.execute(
select(PostLikeORM).where(
PostLikeORM.post_id == str(post_id),
PostLikeORM.liked_by == liked_by,
)
)
orm = result.scalar_one_or_none()
if orm:
await self._session.delete(orm)

View File

@@ -28,6 +28,7 @@ from app.presentation.web import auth_router
from app.presentation.web import router as web_router
from app.presentation.web.error_handlers import register_error_handlers
from app.presentation.web.flash import setup_flash_manager
from app.presentation.web.locale import setup_locale_manager
@asynccontextmanager
@@ -86,6 +87,15 @@ def app_factory() -> FastAPI:
request.state.flash_manager.set_cookie(response)
return response
@app.middleware("http")
async def locale_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""Middleware to detect and set locale for each request."""
await setup_locale_manager(request)
response = await call_next(request)
return response
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],

View File

@@ -11,11 +11,16 @@ from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.application import (
CreateCommentUseCase,
CreatePostUseCase,
DeleteCommentUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
from app.domain.exceptions import ForbiddenException, UnauthorizedException
@@ -28,6 +33,12 @@ UpdatePostDep = FromDishka[UpdatePostUseCase]
DeletePostDep = FromDishka[DeletePostUseCase]
ListPostsDep = FromDishka[ListPostsUseCase]
PublishPostDep = FromDishka[PublishPostUseCase]
ToggleLikeDep = FromDishka[TogglePostLikeUseCase]
CreateCommentDep = FromDishka[CreateCommentUseCase]
DeleteCommentDep = FromDishka[DeleteCommentUseCase]
ListCommentsDep = FromDishka[ListCommentsUseCase]
ToggleCommentLikeDep = FromDishka[ToggleCommentLikeUseCase]
security = HTTPBearer(auto_error=False)

View File

@@ -6,7 +6,9 @@ all v1 endpoint routers.
from fastapi import APIRouter
from app.presentation.api.v1.comments import router as comments_router
from app.presentation.api.v1.posts import router as posts_router
router = APIRouter(prefix="/v1")
router.include_router(posts_router)
router.include_router(comments_router)

View File

@@ -0,0 +1,131 @@
"""Comments API routes.
This module defines FastAPI routes for comment operations including
CRUD and like/unlike toggle.
"""
from uuid import UUID
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, status
from app.presentation.api.deps import (
CreateCommentDep,
CurrentRoleDep,
CurrentUserDep,
DeleteCommentDep,
ListCommentsDep,
ToggleCommentLikeDep,
)
from app.presentation.schemas import (
CommentCreateSchema,
CommentLikeResponseSchema,
CommentResponseSchema,
)
router = APIRouter(tags=["comments"], route_class=DishkaRoute)
@router.post(
"/posts/{post_id}/comments",
response_model=CommentResponseSchema,
status_code=status.HTTP_201_CREATED,
summary="Create a comment on a post",
)
async def create_comment(
post_id: UUID,
schema: CommentCreateSchema,
use_case: CreateCommentDep,
current_user_id: CurrentUserDep,
) -> CommentResponseSchema:
"""Create a comment on a blog post.
Args:
post_id: UUID of the post to comment on.
schema: Comment creation data.
use_case: CreateCommentUseCase dependency.
current_user_id: Authenticated user ID.
Returns:
CommentResponseSchema with created comment data.
"""
result = await use_case.execute(
post_id=post_id,
author_id=current_user_id,
content=schema.content,
parent_id=schema.parent_id,
)
return CommentResponseSchema(**result.__dict__)
@router.get(
"/posts/{post_id}/comments",
response_model=list[CommentResponseSchema],
summary="List comments for a post",
)
async def list_comments(
post_id: UUID,
use_case: ListCommentsDep,
) -> list[CommentResponseSchema]:
"""Get all comments for a blog post.
Args:
post_id: UUID of the post.
use_case: ListCommentsUseCase dependency.
Returns:
List of CommentResponseSchema for the post.
"""
results = await use_case.execute(post_id=post_id)
return [CommentResponseSchema(**r.__dict__) for r in results]
@router.delete(
"/comments/{comment_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a comment",
)
async def delete_comment(
comment_id: UUID,
use_case: DeleteCommentDep,
current_user_id: CurrentUserDep,
role: CurrentRoleDep,
) -> None:
"""Delete a comment.
Users can delete their own comments.
Args:
comment_id: UUID of the comment to delete.
use_case: DeleteCommentUseCase dependency.
current_user_id: Authenticated user ID.
role: Current user role.
"""
await use_case.execute(comment_id=comment_id, user_id=current_user_id)
@router.post(
"/comments/{comment_id}/like",
response_model=CommentLikeResponseSchema,
summary="Toggle like on a comment",
)
async def toggle_comment_like(
comment_id: UUID,
use_case: ToggleCommentLikeDep,
current_user_id: CurrentUserDep,
) -> CommentLikeResponseSchema:
"""Toggle like/unlike on a comment.
If the user already liked the comment, the like is removed (unlike).
Otherwise, a new like is added.
Args:
comment_id: UUID of the comment.
use_case: ToggleCommentLikeUseCase dependency.
current_user_id: Authenticated user ID.
Returns:
CommentLikeResponseSchema with updated like_count.
"""
result = await use_case.execute(comment_id, current_user_id)
return CommentLikeResponseSchema(id=result.id, like_count=result.like_count)

View File

@@ -20,6 +20,7 @@ from app.presentation.api.deps import (
GetPostDep,
ListPostsDep,
PublishPostDep,
ToggleLikeDep,
UpdatePostDep,
)
from app.presentation.schemas import (
@@ -344,3 +345,30 @@ async def unpublish_post(
"""
result = await use_case.unpublish(post_id, current_user_id, role)
return PostResponseSchema(**result.__dict__)
@router.post(
"/{post_id}/like",
response_model=PostResponseSchema,
summary="Toggle like on a post",
)
async def toggle_like(
post_id: UUID,
use_case: ToggleLikeDep,
current_user_id: CurrentUserDep,
) -> PostResponseSchema:
"""Toggle like/unlike on a post.
If the user already liked the post, the like is removed (unlike).
Otherwise, a new like is added.
Args:
post_id: Unique identifier of the post.
use_case: TogglePostLikeUseCase dependency.
current_user_id: Authenticated user ID.
Returns:
PostResponseSchema with updated like_count.
"""
result = await use_case.execute(post_id, current_user_id)
return PostResponseSchema(**result.__dict__)

View File

@@ -4,6 +4,11 @@ This module re-exports all Pydantic schemas used for
request/response validation in the API layer.
"""
from app.presentation.schemas.comment import (
CommentCreateSchema,
CommentLikeResponseSchema,
CommentResponseSchema,
)
from app.presentation.schemas.post import (
PostBaseSchema,
PostCreateSchema,
@@ -22,4 +27,7 @@ __all__ = [
"PostListResponseSchema",
"PostSearchSchema",
"PostPublishSchema",
"CommentCreateSchema",
"CommentResponseSchema",
"CommentLikeResponseSchema",
]

View File

@@ -0,0 +1,58 @@
"""Pydantic schemas for comments.
This module defines Pydantic models for comment request/response
validation in the API layer.
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class CommentCreateSchema(BaseModel):
"""Schema for creating a comment.
Attributes:
content: Comment text content (Markdown supported).
parent_id: Optional parent comment ID for replies.
"""
content: str = Field(..., min_length=1, max_length=5000, description="Comment content")
parent_id: UUID | None = Field(default=None, description="Parent comment ID for replies")
class CommentResponseSchema(BaseModel):
"""Schema for comment response.
Attributes:
id: Unique comment identifier.
post_id: UUID of the parent post.
author_id: Comment author identifier.
content: Comment content text.
parent_id: Optional parent comment ID.
like_count: Number of likes on this comment.
created_at: Creation timestamp.
updated_at: Last update timestamp.
"""
id: UUID
post_id: UUID
author_id: str
content: str
parent_id: UUID | None = None
like_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class CommentLikeResponseSchema(BaseModel):
"""Schema for comment like response.
Attributes:
id: Comment identifier.
like_count: Updated like count.
"""
id: UUID
like_count: int

View File

@@ -81,6 +81,7 @@ class PostResponseSchema(BaseModel):
slug: str
author_id: str
published: bool
like_count: int = 0
tags: list[str]
created_at: datetime
updated_at: datetime

View File

@@ -1,11 +1,11 @@
<!DOCTYPE html>
<html lang="en" data-testid="html-root">
<html lang="{{ current_locale }}" data-testid="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block meta_description %}Blog - A modern blogging platform built with FastAPI{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}blog, articles, posts, writing{% endblock %}">
<meta name="author" content="{% block meta_author %}Blog Team{% endblock %}">
<meta name="description" content="{% block meta_description %}{{ _('base.meta_description', current_locale) }}{% endblock %}">
<meta name="keywords" content="{% block meta_keywords %}{{ _('base.meta_keywords', current_locale) }}{% endblock %}">
<meta name="author" content="{% block meta_author %}{{ _('base.meta_author', current_locale) }}{% endblock %}">
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}">
<!-- Canonical URL -->
@@ -30,7 +30,7 @@
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
<link rel="alternate icon" href="/static/images/favicon.ico">
<title data-testid="page-title">{% block title %}Blog{% endblock %}</title>
<title data-testid="page-title">{% block title %}{{ _('base.default_title', current_locale) }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/themes/theme-light.css" data-testid="theme-light-stylesheet">
<link rel="stylesheet" href="/static/css/themes/theme-dark.css" data-testid="theme-dark-stylesheet">
@@ -51,7 +51,7 @@
{% for msg in flash_messages %}
<div class="flash-message flash-{{ msg.category }}" data-testid="flash-message-{{ msg.category }}" role="alert">
<span class="flash-text" data-testid="flash-text">{{ msg.message }}</span>
<button type="button" class="flash-close" data-testid="flash-close" aria-label="Close message">&times;</button>
<button type="button" class="flash-close" data-testid="flash-close" aria-label="{{ _('base.close_message', current_locale) }}">&times;</button>
</div>
{% endfor %}
</div>

View File

@@ -1,26 +1,26 @@
{% extends "base.html" %}
{% block title %}About - Blog{% endblock %}
{% block meta_description %}A modern blog built with FastAPI and DDD architecture.{% endblock %}
{% block title %}{{ _('about.title', current_locale) }} - {{ _('base.default_title', current_locale) }}{% endblock %}
{% block meta_description %}{{ _('about.description', current_locale) }}{% endblock %}
{% block content %}
<div class="page-header" data-testid="page-header-about">
<h1 class="page-title" data-testid="page-title-about">About</h1>
<h1 class="page-title" data-testid="page-title-about">{{ _('about.page_title', current_locale) }}</h1>
</div>
<div class="card" data-testid="about-card">
<div class="card-body" data-testid="about-card-body">
<p data-testid="about-description">
A modern blog built with FastAPI and Domain-Driven Design architecture.
{{ _('about.description', current_locale) }}
</p>
<div class="divider" data-testid="about-divider"></div>
<p data-testid="about-user">
{% if user %}
Signed in as <strong>{{ user.username }}</strong>.
{{ _('about.signed_in', current_locale).format(username=user.username) }}
{% else %}
You are browsing as a guest.
{{ _('about.browsing_guest', current_locale) }}
{% endif %}
</p>
</div>
@@ -30,7 +30,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to Home
{{ _('about.back_home', current_locale) }}
</a>
</div>
</div>

View File

@@ -1,29 +1,29 @@
{% extends "base.html" %}
{% block title %}Blog - Home{% endblock %}
{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %}
{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %}
{% block title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block meta_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block meta_keywords %}{{ _('home.meta_keywords', current_locale) }}{% endblock %}
{% block og_type %}website{% endblock %}
{% block og_title %}Blog - Home{% endblock %}
{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
{% block og_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block og_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block twitter_title %}Blog - Home{% endblock %}
{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %}
{% block twitter_title %}{{ _('home.title', current_locale) }}{% endblock %}
{% block twitter_description %}{{ _('home.meta_description', current_locale) }}{% endblock %}
{% block content %}
<section class="page-header" data-testid="page-header-home">
<div class="page-header-flex">
<div data-testid="page-header-content">
<h1 class="page-title" data-testid="page-title-home">Latest Posts</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">Discover stories, thinking, and expertise from writers on any topic.</p>
<h1 class="page-title" data-testid="page-title-home">{{ _('home.page_title', current_locale) }}</h1>
<p class="page-subtitle" data-testid="page-subtitle-home">{{ _('home.page_subtitle', current_locale) }}</p>
</div>
{% if can_create %}
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-post-header">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Write a Post
{{ _('home.write_post', current_locale) }}
</a>
{% endif %}
</div>
@@ -38,9 +38,9 @@
<a href="/web/posts/{{ post.slug }}" data-testid="post-title-link-{{ post.id }}">{{ post.title }}</a>
</h2>
{% if post.published %}
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">Published</span>
<span class="badge badge-success" data-testid="post-status-{{ post.id }}">{{ _('home.status_published', current_locale) }}</span>
{% else %}
<span class="badge" data-testid="post-status-{{ post.id }}">Draft</span>
<span class="badge" data-testid="post-status-{{ post.id }}">{{ _('home.status_draft', current_locale) }}</span>
{% endif %}
</div>
@@ -52,6 +52,12 @@
<span class="post-card-meta-item" data-testid="post-date-{{ post.id }}">
{{ post.created_at.strftime('%B %d, %Y') }}
</span>
<span class="post-card-meta-item" data-testid="like-count-{{ post.id }}">
👍 {{ post.like_count }}
</span>
<span class="post-card-meta-item" data-testid="comment-count-{{ post.id }}">
💬 {{ post.comment_count }}
</span>
</div>
<div class="post-card-content" data-testid="post-content-preview-{{ post.id }}">
@@ -65,7 +71,7 @@
{% endfor %}
</div>
<a href="/web/posts/{{ post.slug }}" class="btn btn-sm" data-testid="btn-read-more-{{ post.id }}">
Read more
{{ _('home.read_more', current_locale) }}
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-left: 0.25rem;">
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -77,26 +83,26 @@
<nav class="pagination" data-testid="pagination" aria-label="Pagination">
{% if has_prev %}
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">Previous</a>
<a href="{{ request.url.path }}?page={{ current_page - 1 }}" class="pagination-item" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-prev">Previous</span>
<span class="pagination-item disabled" data-testid="pagination-prev">{{ _('home.pagination_previous', current_locale) }}</span>
{% endif %}
<span class="pagination-item active" data-testid="pagination-current">{{ current_page }}</span>
{% if has_next %}
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">Next</a>
<a href="{{ request.url.path }}?page={{ current_page + 1 }}" class="pagination-item" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</a>
{% else %}
<span class="pagination-item disabled" data-testid="pagination-next">Next</span>
<span class="pagination-item disabled" data-testid="pagination-next">{{ _('home.pagination_next', current_locale) }}</span>
{% endif %}
</nav>
{% else %}
<div class="empty-state" data-testid="empty-state">
<div class="empty-state-icon" data-testid="empty-state-icon">📝</div>
<h3 data-testid="empty-state-title">No posts yet</h3>
<p data-testid="empty-state-description">Be the first to write a post!</p>
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">Create your first post</a>
<h3 data-testid="empty-state-title">{{ _('home.empty_title', current_locale) }}</h3>
<p data-testid="empty-state-description">{{ _('home.empty_description', current_locale) }}</p>
<a href="/web/posts/new" class="btn btn-primary" data-testid="btn-create-first-post">{{ _('home.empty_action', current_locale) }}</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -19,7 +19,7 @@
<article class="post-detail" data-testid="post-detail">
<header class="post-detail-header" data-testid="post-detail-header">
<h1 class="post-detail-title" data-testid="post-detail-title">{{ post.title }}</h1>
<div class="post-detail-meta" data-testid="post-detail-meta">
<span class="post-card-meta-item" data-testid="post-detail-author">
<span class="avatar avatar-sm" data-testid="post-detail-author-avatar">{{ post.author_id[0]|upper }}</span>
@@ -29,51 +29,61 @@
{{ post.created_at.strftime('%B %d, %Y') }}
</span>
{% if post.published %}
<span class="badge badge-success" data-testid="post-detail-status">Published</span>
<span class="badge badge-success" data-testid="post-detail-status">{{ _('post.status_published', current_locale) }}</span>
{% else %}
<span class="badge" data-testid="post-detail-status">Draft</span>
<span class="badge" data-testid="post-detail-status">{{ _('post.status_draft', current_locale) }}</span>
{% 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>
<span class="post-card-meta-item" data-testid="post-detail-comment-count">
💬 {{ post.comment_count }}
</span>
</div>
</header>
<div class="post-detail-content markdown-body" data-testid="post-detail-content">
{{ post.content|markdown|safe }}
</div>
<footer class="post-detail-footer" data-testid="post-detail-footer">
<div class="post-detail-tags" data-testid="post-detail-tags">
{% for tag in post.tags %}
<span class="tag" data-testid="post-detail-tag-{{ loop.index }}">{{ tag }}</span>
{% endfor %}
</div>
<div class="divider" data-testid="post-detail-divider"></div>
<div class="flex justify-between items-center" data-testid="post-detail-actions">
<a href="/" class="btn" data-testid="btn-back-to-posts">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to posts
{{ _('post.back_to_posts', current_locale) }}
</a>
{% if can_edit or can_delete %}
<div class="flex gap-2" data-testid="post-detail-edit-actions">
{% if can_edit %}
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Edit
</a>
<a href="/web/posts/{{ post.slug }}/edit" class="btn" data-testid="btn-edit-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M11 2L14 5M2 14L3 10L12 1L15 4L6 13L2 14Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ _('post.edit', current_locale) }}
</a>
{% endif %}
{% if can_delete %}
<form action="/web/posts/{{ post.slug }}/delete" method="POST" style="display: inline;" data-testid="form-delete-post">
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('Are you sure you want to delete this post?');">
<button type="submit" class="btn btn-danger" data-testid="btn-delete-post" onclick="return confirm('{{ _('post.delete_confirm', current_locale) }}');">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M2 4h12M6 4V2a2 2 0 012-2h0a2 2 0 012 2v2m3 0v10a2 2 0 01-2 2H5a2 2 0 01-2-2V4h9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Delete
{{ _('post.delete', current_locale) }}
</button>
</form>
{% endif %}
@@ -82,4 +92,269 @@
</div>
</footer>
</article>
<section class="comments-section" data-testid="comments-section">
<div class="comments-header" data-testid="comments-header">
<h2 class="comments-title" data-testid="comments-title">
💬 {{ _('post.comments', current_locale) }}
<span class="comments-count" data-testid="comments-count">({{ post.comment_count }})</span>
</h2>
{% if user %}
<button id="btn-show-comment-form" class="btn btn-primary" data-testid="btn-show-comment-form">
{{ _('post.write_comment', current_locale) }}
</button>
{% endif %}
</div>
{% if user %}
<div id="comment-form-wrapper" class="comment-form-wrapper" data-testid="comment-form-wrapper" style="display: none;">
<form id="comment-form" class="comment-form" data-testid="form-create-comment" data-post-slug="{{ post.slug }}">
<div class="form-group">
<textarea id="comment-content" class="form-textarea" data-testid="input-comment-content"
rows="4" placeholder="{{ _('post.comment_placeholder', current_locale) }}"
required minlength="1" maxlength="5000"></textarea>
<input type="hidden" id="comment-parent-id" name="parent_id" value="">
<p class="form-help" data-testid="comment-form-help" id="reply-info" style="display: none;">
{{ _('post.replying_to', current_locale) }}
<button type="button" class="btn-cancel-reply" data-testid="btn-cancel-reply">{{ _('post.cancel_reply', current_locale) }}</button>
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" data-testid="submit-comment">
{{ _('post.submit_comment', current_locale) }}
</button>
<button type="button" class="btn btn-cancel-comment" data-testid="btn-cancel-comment" style="display: none;">
{{ _('post.cancel', current_locale) }}
</button>
</div>
</form>
<div id="comment-error" class="comment-error" data-testid="comment-error" style="display: none;"></div>
</div>
{% endif %}
{% macro render_comment(comment, depth) %}
<div class="comment{% if depth > 0 %} comment-reply{% endif %}" data-testid="comment-{{ comment.id }}" data-comment-id="{{ comment.id }}">
<div class="comment-avatar" data-testid="comment-avatar-{{ comment.id }}">
<span class="avatar avatar-sm">{{ comment.author_id[0]|upper }}</span>
</div>
<div class="comment-body" data-testid="comment-body-{{ comment.id }}">
<div class="comment-meta" data-testid="comment-meta-{{ comment.id }}">
<span class="comment-author" data-testid="comment-author-{{ comment.id }}">{{ comment.author_id }}</span>
<span class="comment-date" data-testid="comment-date-{{ comment.id }}">
{% if comment.created_at %}{{ comment.created_at.strftime('%B %d, %Y') }}{% endif %}
</span>
</div>
<div class="comment-content" data-testid="comment-content-{{ comment.id }}">{{ comment.content }}</div>
<div class="comment-actions" data-testid="comment-actions-{{ comment.id }}">
{% if user %}
<button class="btn-comment-reply btn btn-sm" data-testid="btn-comment-reply-{{ comment.id }}"
data-comment-id="{{ comment.id }}" data-comment-author="{{ comment.author_id }}">
{{ _('post.reply', current_locale) }}
</button>
{% endif %}
<button class="btn-comment-like btn btn-sm" data-testid="btn-comment-like-{{ comment.id }}"
data-comment-id="{{ comment.id }}">
👍 <span class="comment-like-count" data-testid="comment-like-count-{{ comment.id }}">{{ comment.like_count }}</span>
</button>
</div>
{% set key = comment.id|string %}
{% if key in reply_comments %}
<div class="comment-replies" data-testid="comment-replies-{{ comment.id }}">
{% for child in reply_comments[key] %}
{{ render_comment(child, depth + 1) }}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endmacro %}
<div class="comments-list" data-testid="comments-list">
{% if top_level_comments %}
{% for comment in top_level_comments %}
{{ render_comment(comment, 0) }}
{% endfor %}
{% else %}
<div class="comments-empty" data-testid="comments-empty">
<p>{{ _('post.no_comments', current_locale) }}</p>
</div>
{% endif %}
</div>
</section>
{% endblock %}
{% block extra_js %}
<script data-testid="comment-script">
document.addEventListener('DOMContentLoaded', function() {
var likeButton = document.getElementById('like-button');
if (likeButton) {
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);
});
});
}
var showFormBtn = document.getElementById('btn-show-comment-form');
var formWrapper = document.getElementById('comment-form-wrapper');
var cancelBtn = document.querySelector('.btn-cancel-comment');
var commentForm = document.getElementById('comment-form');
var commentContent = document.getElementById('comment-content');
var commentParentId = document.getElementById('comment-parent-id');
var replyInfo = document.getElementById('reply-info');
var commentError = document.getElementById('comment-error');
function showCommentForm(parentId, authorName) {
commentParentId.value = parentId || '';
if (parentId && authorName) {
replyInfo.style.display = 'block';
replyInfo.innerHTML = '{{ _("post.replying_to", current_locale) }} <strong>' + authorName + '</strong> &mdash; <button type="button" class="btn-cancel-reply" id="btn-cancel-reply">{{ _("post.cancel_reply", current_locale) }}</button>';
document.getElementById('btn-cancel-reply').addEventListener('click', function() {
commentParentId.value = '';
replyInfo.style.display = 'none';
});
} else {
replyInfo.style.display = 'none';
}
formWrapper.style.display = 'block';
if (showFormBtn) showFormBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-flex';
commentContent.focus();
commentError.style.display = 'none';
}
function hideCommentForm() {
formWrapper.style.display = 'none';
if (showFormBtn) showFormBtn.style.display = 'inline-flex';
if (cancelBtn) cancelBtn.style.display = 'none';
commentContent.value = '';
commentParentId.value = '';
replyInfo.style.display = 'none';
commentError.style.display = 'none';
}
if (showFormBtn) {
showFormBtn.addEventListener('click', function() {
showCommentForm(null, null);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', hideCommentForm);
}
if (commentForm) {
commentForm.addEventListener('submit', function(e) {
e.preventDefault();
var content = commentContent.value.trim();
if (!content) return;
var slug = this.getAttribute('data-post-slug');
var parentId = commentParentId.value || null;
var payload = {content: content};
if (parentId) {
payload.parent_id = parentId;
}
fetch('/web/posts/' + slug + '/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(payload)
})
.then(function(response) {
if (response.status === 401) {
window.location.href = '/auth/dev-login';
return;
}
if (!response.ok) {
throw new Error('Comment creation failed');
}
return response.json();
})
.then(function(data) {
if (data) {
location.reload();
}
})
.catch(function(error) {
commentError.textContent = '{{ _("post.comment_error", current_locale) }}';
commentError.style.display = 'block';
console.error('Comment error:', error);
});
});
}
var replyButtons = document.querySelectorAll('.btn-comment-reply');
replyButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var commentId = this.getAttribute('data-comment-id');
var author = this.getAttribute('data-comment-author');
showCommentForm(commentId, author);
});
});
var commentLikeButtons = document.querySelectorAll('.btn-comment-like');
commentLikeButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var commentId = this.getAttribute('data-comment-id');
var countSpan = this.querySelector('.comment-like-count');
fetch('/web/comments/' + commentId + '/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('Comment like failed');
}
return response.json();
})
.then(function(data) {
if (data && data.like_count !== undefined) {
countSpan.textContent = data.like_count;
}
})
.catch(function(error) {
console.error('Comment like error:', error);
});
});
});
});
</script>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{% if is_edit %}Edit Post{% else %}New Post{% endif %} - Blog{% endblock %}
{% block title %}{% if is_edit %}{{ _('post_form.title_edit', current_locale) }}{% else %}{{ _('post_form.title_new', current_locale) }}{% endif %} - {{ _('base.default_title', current_locale) }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="/static/css/easymde.min.css" data-testid="easymde-stylesheet">
@@ -9,7 +9,7 @@
{% block content %}
<section class="page-header" data-testid="page-header-form">
<h1 class="page-title" data-testid="page-title-form">
{% if is_edit %}Edit Post{% else %}Create New Post{% endif %}
{% if is_edit %}{{ _('post_form.page_title_edit', current_locale) }}{% else %}{{ _('post_form.page_title_new', current_locale) }}{% endif %}
</h1>
</section>
@@ -22,7 +22,7 @@
<div class="card-body" data-testid="form-post-body">
<div class="form-group" data-testid="form-group-title">
<label for="title" class="form-label form-label-required" data-testid="label-title">
Title
{{ _('post_form.label_title', current_locale) }}
</label>
<input
type="text"
@@ -30,31 +30,31 @@
name="title"
class="input input-lg"
value="{% if post %}{{ post.title }}{% endif %}"
placeholder="Enter post title"
placeholder="{{ _('post_form.placeholder_title', current_locale) }}"
required
data-testid="input-title"
>
<span class="form-hint" data-testid="hint-title">A catchy title for your post</span>
<span class="form-hint" data-testid="hint-title">{{ _('post_form.hint_title', current_locale) }}</span>
</div>
<div class="form-group" data-testid="form-group-content">
<label for="content" class="form-label form-label-required" data-testid="label-content">
Content
{{ _('post_form.label_content', current_locale) }}
</label>
<textarea
id="content"
name="content"
rows="12"
placeholder="Write your post content here..."
placeholder="{{ _('post_form.placeholder_content', current_locale) }}"
required
data-testid="textarea-content"
>{% if post %}{{ post.content }}{% endif %}</textarea>
<span class="form-hint" data-testid="hint-content">The main content of your post. Markdown is supported.</span>
<span class="form-hint" data-testid="hint-content">{{ _('post_form.hint_content', current_locale) }}</span>
</div>
<div class="form-group" data-testid="form-group-tags">
<label for="tags" class="form-label" data-testid="label-tags">
Tags
{{ _('post_form.label_tags', current_locale) }}
</label>
<input
type="text"
@@ -62,10 +62,10 @@
name="tags"
class="input"
value="{% if post %}{{ post.tags|join(', ') }}{% endif %}"
placeholder="python, fastapi, tutorial"
placeholder="{{ _('post_form.placeholder_tags', current_locale) }}"
data-testid="input-tags"
>
<span class="form-hint" data-testid="hint-tags">Comma-separated list of tags</span>
<span class="form-hint" data-testid="hint-tags">{{ _('post_form.hint_tags', current_locale) }}</span>
</div>
</div>
@@ -73,15 +73,15 @@
<div class="card-footer" data-testid="form-post-footer">
<div class="flex justify-between items-center" data-testid="form-actions">
<a href="{% if is_edit %}/web/posts/{{ post.slug }}{% else %}/web/{% endif %}" class="btn" data-testid="btn-cancel">
Cancel
{{ _('post_form.cancel', current_locale) }}
</a>
<div class="flex gap-2" data-testid="form-submit-actions">
<button type="submit" name="action" value="draft" class="btn" data-testid="btn-save-draft">
Save as Draft
{{ _('post_form.save_draft', current_locale) }}
</button>
<button type="submit" name="action" value="publish" class="btn btn-primary" data-testid="btn-publish-post">
{% if is_edit %}Update Post{% else %}Publish Post{% endif %}
{% if is_edit %}{{ _('post_form.update_post', current_locale) }}{% else %}{{ _('post_form.publish_post', current_locale) }}{% endif %}
</button>
</div>
</div>
@@ -99,7 +99,7 @@
spellChecker: false,
status: false,
minHeight: '300px',
placeholder: 'Write your post content here...',
placeholder: '{{ _('post_form.placeholder_content', current_locale) }}',
toolbar: [
'bold', 'italic', 'heading', '|',
'code', 'quote', 'unordered-list', 'ordered-list', '|',

View File

@@ -4,7 +4,7 @@
{% block content %}
<div class="page-header" data-testid="page-header-profile">
<h1 class="page-title" data-testid="page-title-profile">User Profile</h1>
<h1 class="page-title" data-testid="page-title-profile">{{ _('profile.title', current_locale) }}</h1>
</div>
<div class="card" data-testid="profile-card">
@@ -25,18 +25,18 @@
<div class="profile-details" data-testid="profile-details">
<div class="profile-field" data-testid="profile-field-email">
<span class="profile-label" data-testid="profile-label-email">Email:</span>
<span class="profile-value" data-testid="profile-value-email">{{ user.email or 'Not provided' }}</span>
<span class="profile-label" data-testid="profile-label-email">{{ _('profile.email', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-email">{{ user.email or _('profile.not_provided', current_locale) }}</span>
</div>
<div class="profile-field" data-testid="profile-field-userid">
<span class="profile-label" data-testid="profile-label-userid">User ID:</span>
<span class="profile-label" data-testid="profile-label-userid">{{ _('profile.user_id', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-userid">{{ user.user_id }}</span>
</div>
{% if user.first_name or user.last_name %}
<div class="profile-field" data-testid="profile-field-name">
<span class="profile-label" data-testid="profile-label-name">Name:</span>
<span class="profile-label" data-testid="profile-label-name">{{ _('profile.name', current_locale) }}</span>
<span class="profile-value" data-testid="profile-value-name">
{{ user.first_name or '' }} {{ user.last_name or '' }}
</span>
@@ -51,7 +51,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back to Home
{{ _('profile.back_home', current_locale) }}
</a>
{% if can_create %}
@@ -59,7 +59,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
New Post
{{ _('profile.new_post', current_locale) }}
</a>
{% endif %}
</div>

View File

@@ -1,14 +1,14 @@
<footer class="site-footer" data-testid="site-footer">
<div class="container" data-testid="footer-container">
<div class="footer-copyright" data-testid="footer-copyright">
<span data-testid="copyright-text">© 2026 Blog. All rights reserved.</span>
<span data-testid="copyright-text">{{ _('footer.copyright', current_locale) }}</span>
</div>
<nav class="footer-links" data-testid="footer-nav" aria-label="Footer navigation">
<a href="/about" class="footer-link" data-testid="footer-link-about">About</a>
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">Privacy</a>
<a href="/terms" class="footer-link" data-testid="footer-link-terms">Terms</a>
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">API</a>
<a href="/about" class="footer-link" data-testid="footer-link-about">{{ _('footer.about', current_locale) }}</a>
<a href="/privacy" class="footer-link" data-testid="footer-link-privacy">{{ _('footer.privacy', current_locale) }}</a>
<a href="/terms" class="footer-link" data-testid="footer-link-terms">{{ _('footer.terms', current_locale) }}</a>
<a href="/api/docs" class="footer-link" data-testid="footer-link-api">{{ _('footer.api', current_locale) }}</a>
</nav>
</div>
</footer>

View File

@@ -5,7 +5,7 @@
<rect width="32" height="32" rx="6" fill="var(--color-primary)"/>
<path d="M8 12h16M8 16h12M8 20h8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>
<span data-testid="logo-text">Blog</span>
<span data-testid="logo-text">{{ _('header.logo', current_locale) }}</span>
</a>
{% include "partials/nav.html" %}
@@ -15,7 +15,7 @@
type="button"
class="mobile-menu-btn"
data-testid="mobile-menu-toggle"
aria-label="Toggle menu"
aria-label="{{ _('header.toggle_menu', current_locale) }}"
aria-expanded="false"
aria-controls="mobile-nav"
>
@@ -30,8 +30,8 @@
type="button"
class="theme-toggle"
data-testid="theme-toggle"
aria-label="Toggle dark mode"
title="Toggle dark mode"
aria-label="{{ _('header.toggle_theme', current_locale) }}"
title="{{ _('header.toggle_theme', current_locale) }}"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="theme-light-icon" style="display: none;">
<path d="M10 2v2M10 16v2M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42M2 10h2M16 10h2M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -41,7 +41,27 @@
<path d="M17.293 13.293A8 8 0 116.707 2.707a8.003 8.003 0 0010.586 10.586z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="lang-switcher" data-testid="lang-switcher">
<button
type="button"
class="lang-switcher-toggle"
data-testid="lang-switcher-toggle"
aria-haspopup="true"
aria-expanded="false"
title="{{ _('header.lang_switcher', current_locale) }}"
>
<span data-testid="current-lang-code">{{ current_locale|upper }}</span>
</button>
<div class="lang-switcher-dropdown" data-testid="lang-switcher-dropdown">
{% for code in ('en', 'ru', 'fr', 'de') %}
<a href="/web/lang/{{ code }}" class="lang-switcher-item {% if code == current_locale %}active{% endif %}" data-testid="lang-option-{{ code }}">
{{ _('lang.' + code, current_locale) }}
</a>
{% endfor %}
</div>
</div>
{% if user %}
<div class="user-menu" data-testid="user-menu">
<button
@@ -63,14 +83,14 @@
<circle cx="8" cy="6" r="3" stroke="currentColor" stroke-width="2"/>
<path d="M2 14c0-3 3-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Profile
{{ _('header.profile', current_locale) }}
</a>
{% if can_create %}
<a href="/web/posts/new" class="user-menu-item" data-testid="user-menu-new-post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
New Post
{{ _('header.new_post', current_locale) }}
</a>
{% endif %}
<div class="user-menu-divider" data-testid="user-menu-divider"></div>
@@ -78,13 +98,13 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 0.5rem;">
<path d="M10 12h2a2 2 0 002-2V6a2 2 0 00-2-2h-2M6 12l-3-3m0 0l3-3m-3 3h8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Sign Out
{{ _('header.sign_out', current_locale) }}
</a>
</div>
</div>
{% else %}
<a href="/auth/login" class="btn btn-primary btn-sm" data-testid="btn-login">
Sign In
{{ _('header.sign_in', current_locale) }}
</a>
{% endif %}
</div>
@@ -241,6 +261,84 @@
}
}
.lang-switcher {
position: relative;
}
.lang-switcher-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.lang-switcher-toggle:hover {
background-color: var(--color-hover);
border-color: var(--color-secondary-dark-2);
}
.lang-switcher-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
min-width: 140px;
background-color: var(--color-box-body);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 12px var(--color-shadow);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
z-index: 1000;
}
.lang-switcher:hover .lang-switcher-dropdown,
.lang-switcher-toggle:focus + .lang-switcher-dropdown,
.lang-switcher-dropdown:hover {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.lang-switcher-item {
display: block;
padding: 0.6rem 1rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.875rem;
transition: background-color 0.2s ease;
}
.lang-switcher-item:first-child {
border-radius: 8px 8px 0 0;
}
.lang-switcher-item:last-child {
border-radius: 0 0 8px 8px;
}
.lang-switcher-item:hover {
background-color: var(--color-hover);
text-decoration: none;
}
.lang-switcher-item.active {
font-weight: 600;
color: var(--color-primary);
}
@media (min-width: 769px) {
.mobile-menu-btn {
display: none;
@@ -255,13 +353,13 @@
<!-- Mobile Navigation Menu -->
<nav class="mobile-nav" id="mobile-nav" data-testid="mobile-nav" aria-label="Mobile navigation">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="mobile-nav-link-home">
Home
{{ _('nav.home', current_locale) }}
</a>
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="mobile-nav-link-posts">
Posts
{{ _('nav.posts', current_locale) }}
</a>
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="mobile-nav-link-about">
About
{{ _('nav.about', current_locale) }}
</a>
</nav>

View File

@@ -1,11 +1,11 @@
<nav class="main-nav" data-testid="main-nav" aria-label="Main navigation">
<a href="/web/" class="nav-link {% if active_page == 'home' %}active{% endif %}" data-testid="nav-link-home">
Home
{{ _('nav.home', current_locale) }}
</a>
<a href="/web/posts" class="nav-link {% if active_page == 'posts' %}active{% endif %}" data-testid="nav-link-posts">
Posts
{{ _('nav.posts', current_locale) }}
</a>
<a href="/web/about" class="nav-link {% if active_page == 'about' %}active{% endif %}" data-testid="nav-link-about">
About
{{ _('nav.about', current_locale) }}
</a>
</nav>

View File

@@ -10,9 +10,11 @@ from fastapi import HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, _
from app.presentation.web.flash import FlashManager, get_flash_messages
templates = Jinja2Templates(directory="app/presentation/templates")
templates.env.globals["_"] = _
async def setup_flash_manager(request: Request) -> None:
@@ -55,6 +57,7 @@ def get_template_context(request: Request) -> dict[str, Any]:
"user_role": user_role.value if user_role else None,
"can_create": can_create_post(user),
"flash_messages": get_flash_messages(request),
"current_locale": getattr(request.state, "locale", DEFAULT_LOCALE),
}

View File

@@ -0,0 +1,72 @@
"""Locale detection and management for i18n support.
This module provides locale detection from Accept-Language headers and cookies,
following the same middleware pattern as the flash message system.
"""
from fastapi import Request
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, SUPPORTED_LOCALES
LOCALE_COOKIE_NAME = "locale"
SUPPORTED_LOCALES_SET: frozenset[str] = SUPPORTED_LOCALES
def _parse_accept_language(header: str) -> list[str]:
"""Parse Accept-Language header into ordered list of locale codes.
Args:
header: Raw Accept-Language header value.
Returns:
List of locale codes in preference order, with region subtags removed.
"""
if not header:
return []
locales: list[str] = []
for part in header.split(","):
part = part.strip()
if not part:
continue
locale = part.split(";")[0].strip().split("-")[0]
if locale:
locales.append(locale)
return locales
def _get_best_locale(request: Request) -> str:
"""Detect the best locale for the current request.
Priority order: cookie → Accept-Language header → default.
Args:
request: FastAPI request object.
Returns:
Best matching locale code, defaulting to ``en``.
"""
cookie_locale = request.cookies.get(LOCALE_COOKIE_NAME)
if cookie_locale and cookie_locale in SUPPORTED_LOCALES_SET:
return cookie_locale
accept_language = request.headers.get("accept-language", "")
for lang in _parse_accept_language(accept_language):
if lang in SUPPORTED_LOCALES_SET:
return lang
return DEFAULT_LOCALE
async def setup_locale_manager(request: Request) -> None:
"""Set the detected locale on request state.
Called early in the request lifecycle so that route handlers and
template rendering can access the current locale via
``request.state.locale``.
Args:
request: FastAPI request object.
"""
if not hasattr(request.state, "locale"):
request.state.locale = _get_best_locale(request)

View File

@@ -6,6 +6,7 @@ integration with the application's use cases and domain layer.
"""
from typing import Any
from uuid import UUID
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, HTTPException, Request
@@ -19,11 +20,15 @@ from pygments.util import ClassNotFound
from app.application.dtos import CreatePostDTO, UpdatePostDTO
from app.application.use_cases import (
CreateCommentUseCase,
CreatePostUseCase,
DeletePostUseCase,
GetPostUseCase,
ListCommentsUseCase,
ListPostsUseCase,
PublishPostUseCase,
ToggleCommentLikeUseCase,
TogglePostLikeUseCase,
UpdatePostUseCase,
)
from app.domain.exceptions import (
@@ -31,8 +36,11 @@ from app.domain.exceptions import (
NotFoundException,
ValidationException,
)
from app.domain.repositories import CommentRepository
from app.domain.roles import Role, get_effective_role
from app.infrastructure.auth import TokenInfo
from app.infrastructure.config.settings import settings
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, SUPPORTED_LOCALES, _
from app.presentation.web.deps import (
OptionalUserDep,
RequireUserDep,
@@ -47,6 +55,22 @@ router = APIRouter(prefix="/web", tags=["web"], route_class=DishkaRoute)
templates = Jinja2Templates(directory="app/presentation/templates")
def _jinja_translate(key: str, locale: str = DEFAULT_LOCALE) -> str:
"""Jinja2 global function for template translation.
Args:
key: Translation key to look up.
locale: Target locale code.
Returns:
Translated string or the key itself if no translation found.
"""
return _(key, locale)
templates.env.globals["_"] = _jinja_translate
_md = MarkdownIt("commonmark", {"html": False}).enable("table")
@@ -85,14 +109,17 @@ def _get_user_role(user: TokenInfo | None) -> Role:
return get_effective_role(user.roles)
def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
def _get_base_context(
user: TokenInfo | None, current_locale: str = DEFAULT_LOCALE
) -> dict[str, Any]:
"""Get base template context with user info and permissions.
Args:
user: Current user or None for guest.
current_locale: Active locale code for i18n.
Returns:
Dictionary with user, user_role, and can_create flags.
Dictionary with user, user_role, can_create, and current_locale.
"""
user_role = _get_user_role(user)
@@ -100,6 +127,7 @@ def _get_base_context(user: TokenInfo | None) -> dict[str, Any]:
"user": user,
"user_role": user_role.value if user_role else None,
"can_create": can_create_post(user),
"current_locale": current_locale,
}
@@ -154,6 +182,7 @@ async def home(
request: Request,
user: OptionalUserDep,
list_use_case: FromDishka[ListPostsUseCase],
comment_repo: FromDishka[CommentRepository],
) -> HTMLResponse:
"""Render the home page with list of posts.
@@ -161,10 +190,13 @@ async def home(
request: The HTTP request object for template context.
user: Current user from dependency.
list_use_case: Use case for listing posts.
comment_repo: Repository for fetching comment counts.
Returns:
HTMLResponse with rendered posts list template.
"""
from dataclasses import replace
page_str = request.query_params.get("page", "1")
page = max(1, int(page_str) if page_str.isdigit() else 1)
offset = (page - 1) * _DEFAULT_PAGE_SIZE
@@ -173,7 +205,12 @@ async def home(
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
)
context = _get_base_context(user)
for i, post in enumerate(visible_posts):
count = await comment_repo.count_by_post(post.id)
visible_posts[i] = replace(post, comment_count=count)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
"pages/index.html",
@@ -193,6 +230,7 @@ async def list_posts(
request: Request,
user: OptionalUserDep,
list_use_case: FromDishka[ListPostsUseCase],
comment_repo: FromDishka[CommentRepository],
) -> HTMLResponse:
"""Render the posts listing page.
@@ -200,10 +238,13 @@ async def list_posts(
request: The HTTP request object for template context.
user: Current user from dependency.
list_use_case: Use case for listing posts.
comment_repo: Repository for fetching comment counts.
Returns:
HTMLResponse with rendered posts list template.
"""
from dataclasses import replace
page_str = request.query_params.get("page", "1")
page = max(1, int(page_str) if page_str.isdigit() else 1)
offset = (page - 1) * _DEFAULT_PAGE_SIZE
@@ -212,7 +253,12 @@ async def list_posts(
list_use_case, user, _DEFAULT_PAGE_SIZE, offset
)
context = _get_base_context(user)
for i, post in enumerate(visible_posts):
count = await comment_repo.count_by_post(post.id)
visible_posts[i] = replace(post, comment_count=count)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
"pages/index.html",
@@ -241,7 +287,8 @@ async def new_post_form(
Returns:
HTMLResponse with rendered post form template.
"""
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -291,11 +338,12 @@ async def create_post(
result = await create_use_case.execute(dto)
user_role = _get_user_role(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
if action == "publish":
await publish_use_case.publish(result.id, user.user_id, user_role)
flash(request, "Post published successfully!", "success")
flash(request, _("flash.post_published", locale), "success")
else:
flash(request, "Post saved as draft!", "success")
flash(request, _("flash.post_saved_draft", locale), "success")
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except AlreadyExistsException as exc:
@@ -312,14 +360,18 @@ async def post_detail(
post_slug: str,
user: OptionalUserDep,
get_use_case: FromDishka[GetPostUseCase],
list_comments_use_case: FromDishka[ListCommentsUseCase],
comment_repo: FromDishka[CommentRepository],
) -> HTMLResponse:
"""Render a single post detail page.
"""Render a single post detail page with comments.
Args:
request: The HTTP request object for template context.
post_slug: The URL-friendly slug of the post to display.
user: Current user from dependency.
get_use_case: Use case for retrieving posts.
list_comments_use_case: Use case for listing comments.
comment_repo: Repository for fetching comment count.
Returns:
HTMLResponse with rendered post detail template.
@@ -327,6 +379,8 @@ async def post_detail(
Raises:
HTTPException: If post not found or not visible to user.
"""
from dataclasses import replace
try:
post = await get_use_case.by_slug(post_slug)
except NotFoundException:
@@ -335,7 +389,21 @@ async def post_detail(
if not post.published and not can_see_draft(user, post.author_id):
raise HTTPException(status_code=404, detail="Post not found")
context = _get_base_context(user)
comments = await list_comments_use_case.execute(post.id)
comment_count = await comment_repo.count_by_post(post.id)
post = replace(post, comment_count=comment_count)
children: dict[str, list[Any]] = {}
for c in comments:
pid = str(c.parent_id) if c.parent_id else ""
if pid not in children:
children[pid] = []
children[pid].append(c)
top_level = children.pop("", [])
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -343,6 +411,8 @@ async def post_detail(
{
**context,
"post": post,
"top_level_comments": top_level,
"reply_comments": children,
"active_page": "posts",
"can_edit": can_edit_post(user, post.author_id),
"can_delete": can_delete_post(user, post.author_id),
@@ -350,6 +420,89 @@ async def post_detail(
)
@router.post("/posts/{post_slug}/comments")
async def create_comment_web(
request: Request,
post_slug: str,
user: OptionalUserDep,
get_use_case: FromDishka[GetPostUseCase],
create_use_case: FromDishka[CreateCommentUseCase],
) -> dict[str, object]:
"""Create a comment on a post via web UI.
Args:
request: The HTTP request object with JSON body.
post_slug: The URL-friendly slug of the post.
user: Current user from cookie or None.
get_use_case: Use case for retrieving post.
create_use_case: Use case for creating comments.
Returns:
JSON dict with created comment data.
Raises:
HTTPException: If user not authenticated or post not found.
"""
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
body = await request.json()
content = str(body.get("content", "")).strip()
parent_id_str = body.get("parent_id")
parent_id: UUID | None = None
if parent_id_str:
parent_id = UUID(parent_id_str)
result = await create_use_case.execute(
post_id=post.id,
author_id=user.user_id,
content=content,
parent_id=parent_id,
)
return {
"id": str(result.id),
"post_id": str(result.post_id),
"author_id": result.author_id,
"content": result.content,
"parent_id": str(result.parent_id) if result.parent_id else None,
"like_count": result.like_count,
"created_at": result.created_at.isoformat() if result.created_at else None,
}
@router.post("/comments/{comment_id}/like")
async def toggle_comment_like_web(
comment_id: UUID,
user: OptionalUserDep,
toggle_use_case: FromDishka[ToggleCommentLikeUseCase],
) -> dict[str, object]:
"""Toggle like on a comment via web UI.
Args:
comment_id: UUID of the comment.
user: Current user from cookie or None.
toggle_use_case: Use case for toggling comment likes.
Returns:
JSON dict with updated like_count.
Raises:
HTTPException: If user not authenticated.
"""
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
result = await toggle_use_case.execute(comment_id, user.user_id)
return {"like_count": result.like_count, "id": str(result.id)}
@router.get("/posts/{post_slug}/edit", response_class=HTMLResponse)
async def edit_post_form(
request: Request,
@@ -379,7 +532,8 @@ async def edit_post_form(
if not can_edit_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -447,7 +601,8 @@ async def update_post(
if result.published:
await publish_use_case.unpublish(result.id, user.user_id, user_role)
flash(request, "Post updated successfully!", "success")
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
flash(request, _("flash.post_updated", locale), "success")
return RedirectResponse(url=f"/web/posts/{result.slug}", status_code=303)
except (AlreadyExistsException, ValidationException) as exc:
flash(request, str(exc), "error")
@@ -485,13 +640,48 @@ async def delete_post(
try:
user_role = _get_user_role(user)
await delete_use_case.execute(post.id, user.user_id, user_role)
flash(request, "Post deleted successfully!", "success")
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
flash(request, _("flash.post_deleted", locale), "success")
except NotFoundException:
flash(request, "Post not found.", "error")
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
flash(request, _("flash.post_not_found", locale), "error")
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)
async def profile(
request: Request,
@@ -506,7 +696,8 @@ async def profile(
Returns:
HTMLResponse with rendered profile template.
"""
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -532,7 +723,8 @@ async def about(
Returns:
HTMLResponse with rendered about page template.
"""
context = _get_base_context(user)
locale = getattr(request.state, "locale", DEFAULT_LOCALE)
context = _get_base_context(user, locale)
return templates.TemplateResponse(
request,
@@ -542,3 +734,37 @@ async def about(
"active_page": "about",
},
)
@router.get("/lang/{locale}")
async def set_language(
request: Request,
locale: str,
) -> RedirectResponse:
"""Set the active language and redirect back to the previous page.
Stores the locale choice in a persistent cookie so that subsequent
requests use the selected language. Falls back to browser preference
or English default.
Args:
request: HTTP request object.
locale: Target locale code (en, ru, fr, de).
Returns:
RedirectResponse back to the referrer or home page.
"""
if locale not in SUPPORTED_LOCALES:
locale = DEFAULT_LOCALE
referer = request.headers.get("referer", "/web/")
response = RedirectResponse(url=referer, status_code=303)
response.set_cookie(
key="locale",
value=locale,
httponly=True,
secure=not settings.is_dev,
samesite="lax",
max_age=365 * 24 * 3600,
)
return response

View File

@@ -61,7 +61,7 @@ types = [
blog = "app.main:main"
[tool.uv.sources]
pytfm = { workspace = true }
pytfm = { git = "https://git.pyaqa.ru/pi3c/pytfm.git" }
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

@@ -362,3 +362,179 @@ input[type="checkbox"] {
margin-bottom: 1rem;
opacity: 0.5;
}
/* Comment section */
.comments-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.comments-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.comments-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.comments-count {
font-weight: 400;
color: var(--color-text-light-3);
font-size: 0.9375rem;
}
.comment-form-wrapper {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-secondary-bg);
}
.comment-form .form-group {
margin-bottom: 0.75rem;
}
.form-textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background-color: var(--color-input-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
line-height: 1.5;
resize: vertical;
min-height: 5rem;
box-sizing: border-box;
}
.form-textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha-30);
}
.form-help {
font-size: 0.8125rem;
color: var(--color-text-light-3);
margin-top: 0.375rem;
margin-bottom: 0;
}
.form-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.comment-error {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background-color: var(--color-error-bg);
border: 1px solid var(--color-error-border);
border-radius: 4px;
color: var(--color-error-text);
font-size: 0.8125rem;
}
/* Individual comment */
.comment {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0;
}
.comment + .comment {
border-top: 1px solid var(--color-border);
}
.comment-avatar {
flex-shrink: 0;
padding-top: 0.125rem;
}
.comment-body {
flex: 1;
min-width: 0;
}
.comment-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.comment-author {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text);
}
.comment-date {
font-size: 0.75rem;
color: var(--color-text-light-3);
}
.comment-content {
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text);
margin-bottom: 0.375rem;
word-wrap: break-word;
}
.comment-actions {
display: flex;
gap: 0.5rem;
}
.btn-comment-reply,
.btn-comment-like {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
}
.comment-replies {
margin-top: 0.5rem;
padding-left: 1rem;
border-left: 2px solid var(--color-border);
}
.comment-reply {
padding: 0.5rem 0;
}
.comment-reply + .comment-reply {
border-top: 1px solid var(--color-border);
}
.comments-empty {
text-align: center;
padding: 2rem 1rem;
color: var(--color-text-light-3);
font-size: 0.9375rem;
}
.btn-cancel-reply {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: inherit;
padding: 0;
text-decoration: underline;
}
.btn-cancel-reply:hover {
color: var(--color-primary-hover);
}

285
tests/FEATURE_COMMENTS.md Normal file
View File

@@ -0,0 +1,285 @@
# Test Model: Comments
Feature: Add comments to blog posts with Markdown editor, nested replies
(parent_id), and per-comment like/unlike toggle.
## Unit Test Cases
### Domain Entities
#### TC-UNIT-829: Comment entity — valid creation
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/domain/test_comment_entity.py::TestCommentEntity::test_comment_creation`
- **Preconditions:** Valid post_id, author_id, and content
- **Steps:** Create Comment instance
- **Expected:**
- `post_id` matches input
- `author_id` matches input
- `content` is CommentContent value object
- `id` is a valid UUID
- `parent_id` is None
- `like_count` is 0
- `created_at` is set
- **Last Verified:** —
#### TC-UNIT-830: Comment entity — with parent_id (reply)
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/domain/test_comment_entity.py::TestCommentEntity::test_comment_with_parent`
- **Preconditions:** Valid post_id, author_id, content, and parent_id
- **Steps:** Create Comment instance with parent_id
- **Expected:**
- `parent_id` matches input
- All other attributes set correctly
- **Last Verified:** —
#### TC-UNIT-831: CommentLike entity — valid creation
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/domain/test_comment_like_entity.py::TestCommentLikeEntity::test_comment_like_creation`
- **Preconditions:** Valid comment_id and liked_by
- **Steps:** Create CommentLike instance
- **Expected:**
- `comment_id` matches input
- `liked_by` matches input
- `id` is a valid UUID
- `created_at` is set
- **Last Verified:** —
### CreateCommentUseCase
#### TC-UNIT-832: CreateCommentUseCase — on post (top-level)
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_create_comment.py::TestCreateCommentUseCase::test_create_comment_on_post`
- **Preconditions:** Post exists
- **Steps:** Execute with post_id, author_id, content, parent_id=None
- **Expected:**
- Comment created with correct post_id and author_id
- `parent_id` is None
- `comment_repo.add` called once
- Transaction committed
- **Last Verified:** —
#### TC-UNIT-833: CreateCommentUseCase — reply to comment
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_create_comment.py::TestCreateCommentUseCase::test_create_comment_reply`
- **Preconditions:** Post and parent comment exist
- **Steps:** Execute with post_id, author_id, content, parent_id set
- **Expected:**
- Comment created with correct parent_id
- `comment_repo.add` called once
- **Last Verified:** —
#### TC-UNIT-834: CreateCommentUseCase — post not found
- **Type:** Negative
- **Layer:** Unit
- **File:** `unit/application/test_create_comment.py::TestCreateCommentUseCase::test_create_comment_post_not_found`
- **Preconditions:** Post does not exist
- **Steps:** Execute with non-existent post_id
- **Expected:** `NotFoundException` raised
- **Last Verified:** —
### ListCommentsUseCase
#### TC-UNIT-835: ListCommentsUseCase — returns comments for post
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_list_comments.py::TestListCommentsUseCase::test_list_comments_by_post`
- **Preconditions:** Post has multiple comments and replies
- **Steps:** Execute with post_id
- **Expected:** Returns list of CommentResponseDTO with correct post_id
- **Last Verified:** —
### DeleteCommentUseCase
#### TC-UNIT-836: DeleteCommentUseCase — own comment
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_delete_comment.py::TestDeleteCommentUseCase::test_delete_own_comment`
- **Preconditions:** Comment exists owned by user
- **Steps:** Execute with comment_id, user_id matching author_id
- **Expected:** `comment_repo.delete` called
- **Last Verified:** —
#### TC-UNIT-837: DeleteCommentUseCase — not found
- **Type:** Negative
- **Layer:** Unit
- **File:** `unit/application/test_delete_comment.py::TestDeleteCommentUseCase::test_delete_comment_not_found`
- **Preconditions:** Comment does not exist
- **Steps:** Execute with non-existent comment_id
- **Expected:** `NotFoundException` raised
- **Last Verified:** —
### ToggleCommentLikeUseCase
#### TC-UNIT-838: ToggleCommentLikeUseCase — like first time
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_toggle_comment_like.py::TestToggleCommentLikeUseCase::test_like_comment_first_time`
- **Preconditions:** Comment exists, no existing like for this user
- **Steps:** Execute toggle with comment_id and liked_by
- **Expected:**
- `add_like` called once
- `remove_like` not called
- Response DTO has `like_count=1`
- **Last Verified:** —
#### TC-UNIT-839: ToggleCommentLikeUseCase — unlike (already liked)
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_toggle_comment_like.py::TestToggleCommentLikeUseCase::test_unlike_comment_already_liked`
- **Preconditions:** Comment exists, existing like found for this user
- **Steps:** Execute toggle with same comment_id and liked_by
- **Expected:**
- `remove_like` called once
- `add_like` not called
- Response DTO has `like_count=0`
- **Last Verified:** —
#### TC-UNIT-840: ToggleCommentLikeUseCase — comment not found
- **Type:** Negative
- **Layer:** Unit
- **File:** `unit/application/test_toggle_comment_like.py::TestToggleCommentLikeUseCase::test_like_comment_not_found`
- **Preconditions:** Comment does not exist
- **Steps:** Execute toggle with non-existent comment_id
- **Expected:** `NotFoundException` raised
- **Last Verified:** —
## API Test Cases
#### TC-API-118: Create comment — authenticated
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_comments.py::TestCreateComment::test_create_comment_authenticated`
- **Preconditions:** Post exists, user authenticated
- **Steps:** POST `/api/v1/posts/{post_id}/comments` with auth header
- **Expected:**
- Status 201
- Response has comment_id, post_id, content, author_id
- **Last Verified:** —
#### TC-API-119: Create comment — reply to comment
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_comments.py::TestCreateComment::test_create_comment_reply`
- **Preconditions:** Post and comment exist, user authenticated
- **Steps:** POST with `parent_id` set to existing comment
- **Expected:**
- Status 201
- Response has correct `parent_id`
- **Last Verified:** —
#### TC-API-120: Create comment — guest
- **Type:** Negative
- **Layer:** API
- **File:** `api/test_comments.py::TestCreateComment::test_create_comment_as_guest`
- **Preconditions:** Post exists, guest token used
- **Steps:** POST without auth header
- **Expected:** Status 401
- **Last Verified:** —
#### TC-API-121: List comments — by post
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_comments.py::TestListComments::test_list_comments_by_post`
- **Preconditions:** Post exists with comments
- **Steps:** GET `/api/v1/posts/{post_id}/comments`
- **Expected:**
- Status 200
- Response contains list of comments
- **Last Verified:** —
#### TC-API-122: Delete comment — own comment
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_comments.py::TestDeleteComment::test_delete_own_comment`
- **Preconditions:** Comment exists owned by authenticated user
- **Steps:** DELETE `/api/v1/comments/{comment_id}` with auth header
- **Expected:** Status 204
- **Last Verified:** —
#### TC-API-123: Delete comment — not owner
- **Type:** Negative
- **Layer:** API
- **File:** `api/test_comments.py::TestDeleteComment::test_delete_comment_not_owner`
- **Preconditions:** Comment exists owned by different user
- **Steps:** DELETE with another user's auth header
- **Expected:** Status 403
- **Last Verified:** —
#### TC-API-124: Toggle comment like — authenticated
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_comments.py::TestLikeComment::test_like_comment_authenticated`
- **Preconditions:** Comment exists, user authenticated
- **Steps:** POST `/api/v1/comments/{comment_id}/like` with auth header
- **Expected:**
- Status 200
- `like_count == 1`
- **Last Verified:** —
#### TC-API-125: Toggle comment like — guest
- **Type:** Negative
- **Layer:** API
- **File:** `api/test_comments.py::TestLikeComment::test_like_comment_as_guest`
- **Preconditions:** Comment exists, guest token used
- **Steps:** POST without auth header
- **Expected:** Status 401
- **Last Verified:** —
## E2E Test Cases
#### TC-E2E-109: Create comment via web UI
- **Type:** Positive
- **Layer:** E2E
- **File:** `tests/e2e/test_comments.py::test_create_comment`
- **Scenario:** Login → open post → write comment with Markdown → verify display
- **Expected:** Comment displayed on post detail page with rendered Markdown
- **Last Verified:** —
#### TC-E2E-110: Reply to comment
- **Type:** Positive
- **Layer:** E2E
- **File:** `tests/e2e/test_comments.py::test_reply_to_comment`
- **Scenario:** Create top-level comment → click Reply → write reply → verify nesting
- **Expected:** Reply appears below parent comment with indentation
- **Last Verified:** —
#### TC-E2E-111: Like/unlike comment
- **Type:** Positive
- **Layer:** E2E
- **File:** `tests/e2e/test_comments.py::test_like_unlike_comment`
- **Scenario:** Create comment → like → verify count → unlike → verify count
- **Expected:** Count toggles correctly (0→1→0)
- **Last Verified:** —
#### TC-E2E-112: Guest cannot comment
- **Type:** Negative
- **Layer:** E2E
- **File:** `tests/e2e/test_comments.py::test_guest_cannot_comment`
- **Scenario:** Guest opens published post → comment form not visible → cannot post
- **Expected:** Comment form hidden for guests
- **Last Verified:** —
## Coverage Summary
| Component | Cases | Status |
|-----------|-------|--------|
| Domain Entities (Comment, CommentLike) | 3 | ⬜ Planned |
| CreateCommentUseCase | 3 | ⬜ Planned |
| ListCommentsUseCase | 1 | ⬜ Planned |
| DeleteCommentUseCase | 2 | ⬜ Planned |
| ToggleCommentLikeUseCase | 3 | ⬜ Planned |
| API Endpoints | 8 | ⬜ Planned |
| E2E Flows | 4 | ⬜ Planned |
## Gaps (Not Yet Covered)
- [ ] Integration tests for SQLAlchemyCommentRepository
- [ ] Web-only tests (TC-WEB-004+)
- [ ] Admin delete any comment
- [ ] Edit comment
- [ ] Comment pagination with large number of comments

View File

@@ -314,6 +314,113 @@ supports the domain and application layers.
- **Expected:** Calls `session.rollback` once
- **Last Verified:** 2026-05-07
### i18n Localization
#### Translation Service
##### TC-UNIT-811: TranslationService — Existing key returns translation
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_translation_for_existing_key`
- **Expected:** Returns correct localized string when key exists in requested locale
- **Last Verified:** 2026-05-10
##### TC-UNIT-812: TranslationService — Fallback chain
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_get_text_returns_english_fallback_for_missing_key, test_get_text_returns_key_when_neither_locale_nor_en_has_it, test_get_text_returns_english_fallback_for_unknown_locale}`
- **Preconditions:** Key missing in requested locale
- **Steps:** Call get_text with partially-available keys
- **Expected:**
- Falls back to English when key exists in `en` but not in requested locale
- Falls back to raw key when neither requested locale nor `en` has it
- Falls back to English when locale is completely unknown
- **Last Verified:** 2026-05-10
##### TC-UNIT-813: TranslationService — English locale
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::test_get_text_returns_en_when_requested_locale_is_en`
- **Expected:** Returns English string when locale is explicitly `en`
- **Last Verified:** 2026-05-10
##### TC-UNIT-814: TranslationService — Singleton pattern
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestTranslationService::{test_singleton_returns_same_instance, test_singleton_shares_translations}`
- **Expected:** Multiple calls to `get_instance()` return the same object with shared data
- **Last Verified:** 2026-05-10
#### Convenience Function
##### TC-UNIT-815: Convenience _() function
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestConvenienceFunction::{test_convenience_function_returns_translation, test_convenience_function_defaults_to_english, test_convenience_function_returns_key_on_missing}`
- **Expected:**
- Returns translation when key and locale are given
- Defaults to `DEFAULT_LOCALE` ("en") when locale omitted
- Returns raw key when no translation exists
- **Last Verified:** 2026-05-10
#### Locale Detection
##### TC-UNIT-816: Parse Accept-Language — Empty and single locale
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_empty_header_returns_empty_list, test_single_locale}`
- **Expected:**
- Empty header returns empty list
- Single locale returns single-element list
- **Last Verified:** 2026-05-10
##### TC-UNIT-817: Parse Accept-Language — Multi-locale with quality
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::test_multiple_locales_with_quality`
- **Expected:** Locales sorted by descending q-value, quality values stripped
- **Last Verified:** 2026-05-10
##### TC-UNIT-818: Parse Accept-Language — Region codes and complex input
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestParseAcceptLanguage::{test_region_code_strips_to_base_language, test_complex_header, test_whitespace_around_locales}`
- **Expected:**
- `fr-FR` normalised to `fr`
- Realistic headers with region codes parsed correctly
- Whitespace around locale codes handled gracefully
- **Last Verified:** 2026-05-10
##### TC-UNIT-819: Get best locale — Cookie priority
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_cookie_takes_priority, test_invalid_cookie_falls_back_to_header}`
- **Expected:**
- Cookie value used when it is a supported locale
- Unsupported locale in cookie falls back to Accept-Language header
- **Last Verified:** 2026-05-10
##### TC-UNIT-820: Get best locale — Header fallback
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestGetBestLocale::{test_no_cookie_uses_accept_language, test_no_cookie_no_header_returns_default, test_accept_language_unsupported_returns_default, test_missing_cookie_key_uses_header}`
- **Expected:**
- Accept-Language used when no cookie present
- Default locale returned when neither cookie nor header matches
- Unsupported language in header falls back to default
- Absent `locale` cookie key treated as no preference
- **Last Verified:** 2026-05-10
##### TC-UNIT-821: Setup locale manager — Middleware helper
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/infrastructure/test_i18n.py::TestSetupLocaleManager::{test_sets_locale_on_request_state, test_does_not_override_existing_locale, test_default_locale_when_no_match}`
- **Expected:**
- Sets `request.state.locale` from best-match locale
- Does not override an already-set locale
- Falls back to default when nothing matches
- **Last Verified:** 2026-05-10
## Coverage Summary
| Component | Cases | Status |
@@ -322,6 +429,7 @@ supports the domain and application layers.
| Settings & Config | 19 | ✅ Defaults, overrides, validation, env checks |
| Keycloak Auth Client | 16 | ✅ Token introspection, userinfo, caching, errors |
| Transaction Manager | 2 | ⚠️ Only commit/rollback; missing nested tx, error handling |
| i18n Localization | 11 | ✅ Translation service, locale detection, middleware helper |
## Gaps (Not Yet Covered)

208
tests/FEATURE_LIKES.md Normal file
View File

@@ -0,0 +1,208 @@
# Test Model: Post Likes
Feature: Like/unlike toggle on blog posts with per-user tracking, session-based
guest identification, and anti-bot protection via JS-only POST.
## Unit Test Cases
### TogglePostLikeUseCase
#### TC-UNIT-822: TogglePostLikeUseCase — Like first time
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_like_post_first_time`
- **Preconditions:** Post exists, no existing like for this user
- **Steps:** Execute toggle with valid post_id and liked_by
- **Expected:**
- `add_like` called once
- `remove_like` not called
- Response DTO has `like_count=1`
- **Last Verified:** 2026-05-10
#### TC-UNIT-823: TogglePostLikeUseCase — Unlike (already liked)
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_unlike_post_already_liked`
- **Preconditions:** Post exists, existing like found for this user
- **Steps:** Execute toggle with same post_id and liked_by
- **Expected:**
- `remove_like` called once
- `add_like` not called
- Response DTO has `like_count=0`
- **Last Verified:** 2026-05-10
#### TC-UNIT-824: TogglePostLikeUseCase — Post not found
- **Type:** Negative
- **Layer:** Unit
- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_like_post_not_found`
- **Preconditions:** Repository returns None for post lookup
- **Steps:** Execute toggle with non-existent post_id
- **Expected:** `NotFoundException` raised
- **Last Verified:** 2026-05-10
#### TC-UNIT-825: TogglePostLikeUseCase — Guest via device_id
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_like_as_guest_with_device_id`
- **Preconditions:** Post exists, no existing like, liked_by set to device_id
- **Steps:** Execute toggle with device_id instead of user_id
- **Expected:**
- Like created with `liked_by == device_id`
- Response DTO has `like_count=1`
- **Last Verified:** 2026-05-10
#### TC-UNIT-828: TogglePostLikeUseCase — Identity isolation
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/application/test_toggle_like.py::TestTogglePostLikeUseCase::test_two_users_can_both_like`
- **Preconditions:** Post exists, user1 likes first
- **Steps:** User2 toggles like on same post
- **Expected:**
- User2's like added (separate identity)
- `like_count=2`
- **Last Verified:** 2026-05-10
### Domain Entities
#### TC-UNIT-826: PostLike entity — valid creation
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/domain/test_like_entity.py::TestPostLikeEntity::test_post_like_creation`
- **Preconditions:** Valid post_id and liked_by values
- **Steps:** Create PostLike instance
- **Expected:**
- `post_id` matches input
- `liked_by` matches input
- `id` is a valid UUID
- `created_at` is set
- **Last Verified:** 2026-05-10
#### TC-UNIT-827: Post entity — like_count default 0
- **Type:** Positive
- **Layer:** Unit
- **File:** `unit/domain/test_post_entity.py::TestPostEntity::test_like_count_defaults_to_zero`
- **Preconditions:** —
- **Steps:** Create Post via `Post.create()`
- **Expected:** `post.like_count == 0`
- **Last Verified:** 2026-05-10
## API Test Cases
#### TC-API-114: Like Post — authenticated toggle on
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_likes.py::TestLikePost::test_like_post_authenticated`
- **Preconditions:** Post exists, user authenticated
- **Steps:** POST `/api/v1/posts/{id}/like` with auth header
- **Expected:**
- Status 200
- `like_count == 1`
- **Last Verified:** 2026-05-10
#### TC-API-115: Like Post — authenticated toggle off
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_likes.py::TestLikePost::test_unlike_post_authenticated`
- **Preconditions:** Post exists, user already liked it
- **Steps:** POST `/api/v1/posts/{id}/like` second time
- **Expected:**
- Status 200
- `like_count == 0`
- **Last Verified:** 2026-05-10
#### TC-API-116: Like Post — guest via device_id
- **Type:** Positive
- **Layer:** API
- **File:** `api/test_likes.py::TestLikePost::test_like_post_as_guest`
- **Preconditions:** Post exists, guest token used
- **Steps:** POST `/api/v1/posts/{id}/like` with guest token
- **Expected:**
- Status 200
- `like_count == 1`
- **Last Verified:** 2026-05-10
#### TC-API-117: Like Post — not found
- **Type:** Negative
- **Layer:** API
- **File:** `api/test_likes.py::TestLikePost::test_like_post_not_found`
- **Preconditions:** Post does not exist
- **Steps:** POST `/api/v1/posts/{id}/like` with auth header
- **Expected:**
- Status 404
- **Last Verified:** 2026-05-10
## Web Test Cases
#### TC-WEB-001: Like count on post list
- **Type:** Positive
- **Layer:** Web
- **File:** `tests/web/test_likes.py::TestLikeDisplay::test_like_count_on_homepage`
- **Preconditions:** Posts exist with known like counts
- **Steps:** GET `/web/`
- **Expected:**
- Each post card shows like count
- `data-testid="like-count-{post.id}"` present
- **Last Verified:** 2026-05-10
#### TC-WEB-002: Like button on post detail
- **Type:** Positive
- **Layer:** Web
- **File:** `tests/web/test_likes.py::TestLikeDisplay::test_like_button_on_detail`
- **Preconditions:** Post exists
- **Steps:** GET `/web/posts/{slug}`
- **Expected:**
- Like count displayed
- `data-testid="like-button"` present
- **Last Verified:** 2026-05-10
#### TC-WEB-003: Like toggle via POST
- **Type:** Positive
- **Layer:** Web
- **File:** `tests/web/test_likes.py::TestLikeToggle::test_like_toggle_via_web`
- **Preconditions:** Post exists
- **Steps:** POST `/web/posts/{slug}/like` redirects back
- **Expected:**
- 303 redirect to post detail
- Like count incremented
- **Last Verified:** 2026-05-10
## E2E Test Cases
#### TC-E2E-106: Like/Unlike flow via web UI
- **Type:** Positive
- **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 (0→1→0)
- **Last Verified:** 2026-05-10
#### TC-E2E-107: Separate users can both like
- **Type:** Positive
- **Layer:** E2E
- **File:** `tests/e2e/test_likes.py::test_multiple_users_can_like`
- **Scenario:** User1 likes → count=1 → User2 likes → count=2
- **Expected:** Count increments per user
- **Last Verified:** 2026-05-10
#### TC-E2E-108: Guest redirect on like
- **Type:** Positive
- **Layer:** E2E
- **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
| Component | Cases | Status |
|-----------|-------|--------|
| TogglePostLikeUseCase | 5 | ✅ Verified |
| Domain Entities (PostLike, Post) | 2 | ✅ Verified |
| API Endpoints | 4 | ✅ Verified |
| Web Display | 3 | ⬜ Planned |
| E2E Flows | 3 | ✅ Verified |
## Gaps (Not Yet Covered)
- [ ] Web tests (TC-WEB-001003) — test infrastructure pending
- [ ] Full device_id middleware for guest like support

View File

@@ -21,6 +21,9 @@ adding new tests.
| Pagination | 40% | — | — | 60% | P1 | ⚠️ Partial |
| Post Edit via Web | — | — | — | 40% | P1 | ⚠️ Partial |
| Post Delete via Web | — | — | — | 40% | P1 | ⚠️ Partial |
| i18n Localization | 100% | — | — | — | P1 | ✅ Active |
| Post Likes | 100% | — | 100% | — | P1 | ✅ Active |
| Comments (CRUD, Like, Nested) | — | — | — | — | P1 | 🔴 In Progress |
Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
@@ -32,6 +35,9 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
| RBAC & Access Control | [FEATURE_RBAC.md](FEATURE_RBAC.md) |
| Domain Foundation | [FEATURE_DOMAIN_FOUNDATION.md](FEATURE_DOMAIN_FOUNDATION.md) |
| Infrastructure & Bootstrap | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
| i18n Localization | [FEATURE_INFRASTRUCTURE.md](FEATURE_INFRASTRUCTURE.md) |
| Post Likes | [FEATURE_LIKES.md](FEATURE_LIKES.md) |
| Comments | [FEATURE_COMMENTS.md](FEATURE_COMMENTS.md) |
## Test Naming Convention

239
tests/api/test_comments.py Normal file
View File

@@ -0,0 +1,239 @@
"""API tests for blog post comments.
This module tests comment CRUD operations, nested replies,
and comment like/unlike toggle via API endpoints.
"""
from typing import Any
from uuid import UUID
from fastapi.testclient import TestClient
from tests.api.conftest import API_PREFIX
COMMENT_CONTENT = "This is a test comment with enough length."
class TestCreateComment:
"""Tests for POST /api/v1/posts/{post_id}/comments — create a comment."""
def test_create_comment_authenticated(
self,
client: TestClient,
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test creating a comment as authenticated user.
TC-API-118: Positive — create top-level comment.
"""
post_id = created_post["id"]
response = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=user_headers,
)
assert response.status_code == 201
data = response.json()
assert data["post_id"] == post_id
assert data["author_id"] == "dev-user"
assert data["content"] == COMMENT_CONTENT
assert data["parent_id"] is None
assert data["like_count"] == 0
assert UUID(data["id"])
def test_create_comment_reply(
self,
client: TestClient,
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test creating a reply to an existing comment.
TC-API-119: Positive — reply to comment with parent_id.
"""
post_id = created_post["id"]
parent = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=user_headers,
)
assert parent.status_code == 201
parent_id = parent.json()["id"]
response = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": "Reply to comment.", "parent_id": parent_id},
headers=user_headers,
)
assert response.status_code == 201
data = response.json()
assert data["parent_id"] == parent_id
assert data["post_id"] == post_id
def test_create_comment_as_guest(
self,
client: TestClient,
guest_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test creating a comment as guest returns 401.
TC-API-120: Negative — guest cannot create comment.
"""
post_id = created_post["id"]
response = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=guest_headers,
)
assert response.status_code == 401
class TestListComments:
"""Tests for GET /api/v1/posts/{post_id}/comments — list comments."""
def test_list_comments_by_post(
self,
client: TestClient,
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test listing comments for a post.
TC-API-121: Positive — returns list of comments.
"""
post_id = created_post["id"]
client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=user_headers,
)
client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": "Second comment."},
headers=user_headers,
)
response = client.get(f"{API_PREFIX}/{post_id}/comments")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["content"] == COMMENT_CONTENT
assert data[1]["content"] == "Second comment."
class TestDeleteComment:
"""Tests for DELETE /api/v1/comments/{comment_id} — delete a comment."""
def test_delete_own_comment(
self,
client: TestClient,
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test deleting own comment returns 204.
TC-API-122: Positive — delete own comment.
"""
post_id = created_post["id"]
comment_resp = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=user_headers,
)
comment_id = comment_resp.json()["id"]
response = client.delete(
f"/api/v1/comments/{comment_id}",
headers=user_headers,
)
assert response.status_code == 204
def test_delete_comment_not_owner(
self,
client: TestClient,
user_headers: dict[str, str],
user2_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test deleting another user's comment returns 403.
TC-API-123: Negative — not owner.
"""
post_id = created_post["id"]
comment_resp = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=user_headers,
)
comment_id = comment_resp.json()["id"]
response = client.delete(
f"/api/v1/comments/{comment_id}",
headers=user2_headers,
)
assert response.status_code == 403
class TestLikeComment:
"""Tests for POST /api/v1/comments/{comment_id}/like — like a comment."""
def test_like_comment_authenticated(
self,
client: TestClient,
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test liking a comment as authenticated user.
TC-API-124: Positive — authenticated like on.
"""
post_id = created_post["id"]
comment_resp = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=user_headers,
)
comment_id = comment_resp.json()["id"]
response = client.post(
f"/api/v1/comments/{comment_id}/like",
headers=user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["like_count"] == 1
assert data["id"] == comment_id
def test_like_comment_as_guest(
self,
client: TestClient,
guest_headers: dict[str, str],
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test liking a comment as guest returns 401.
TC-API-125: Negative — guest cannot like.
"""
post_id = created_post["id"]
comment_resp = client.post(
f"{API_PREFIX}/{post_id}/comments",
json={"content": COMMENT_CONTENT},
headers=user_headers,
)
comment_id = comment_resp.json()["id"]
response = client.post(
f"/api/v1/comments/{comment_id}/like",
headers=guest_headers,
)
assert response.status_code == 401

94
tests/api/test_likes.py Normal file
View File

@@ -0,0 +1,94 @@
"""API tests for post like/unlike toggle.
This module tests the POST /api/v1/posts/{post_id}/like endpoint covering
authenticated toggle on, toggle off, guest access, and not-found scenarios.
"""
from typing import Any
from fastapi.testclient import TestClient
from tests.api.conftest import API_PREFIX
class TestLikePost:
"""Tests for POST /api/v1/posts/{post_id}/like — toggle like on a post."""
def test_like_post_authenticated(
self,
client: TestClient,
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test liking a post as authenticated user returns like_count=1.
TC-API-114: Positive — authenticated like toggle on.
"""
post_id = created_post["id"]
response = client.post(
f"{API_PREFIX}/{post_id}/like",
headers=user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["like_count"] == 1
assert data["id"] == post_id
def test_unlike_post_authenticated(
self,
client: TestClient,
user_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test unliking a post that was already liked returns like_count=0.
TC-API-115: Positive — authenticated like toggle off.
"""
post_id = created_post["id"]
client.post(f"{API_PREFIX}/{post_id}/like", headers=user_headers)
response = client.post(
f"{API_PREFIX}/{post_id}/like",
headers=user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["like_count"] == 0
assert data["id"] == post_id
def test_like_post_as_guest(
self,
client: TestClient,
guest_headers: dict[str, str],
created_post: dict[str, Any],
) -> None:
"""Test liking a post as guest (inactive token) returns 401.
TC-API-116: Negative — guest/inactive token cannot like.
"""
post_id = created_post["id"]
response = client.post(
f"{API_PREFIX}/{post_id}/like",
headers=guest_headers,
)
assert response.status_code == 401
def test_like_post_not_found(
self,
client: TestClient,
user_headers: dict[str, str],
) -> None:
"""Test liking a non-existent post returns 404.
TC-API-117: Negative — post not found.
"""
fake_id = "00000000-0000-0000-0000-000000000000"
response = client.post(
f"{API_PREFIX}/{fake_id}/like",
headers=user_headers,
)
assert response.status_code == 404
error = response.json()
assert error["error"] == "NotFoundException"

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pytfm.web import BasePage, SmartLocator
from pytfm.web import BasePage
if TYPE_CHECKING:
from playwright.sync_api import Page
@@ -22,21 +22,9 @@ class HomePage(BasePage):
path = "/web/"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the home page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._create_post_btn = SmartLocator.by_testid("btn-create-post-header")
self._post_list = SmartLocator.by_testid("post-list")
self._empty_state = SmartLocator.by_testid("empty-state")
def create_post(self) -> None:
"""Click the 'Write a Post' button to navigate to the form."""
self._create_post_btn.click(self.page)
self.loc("btn-create-post-header").click()
def has_post_with_title(self, title: str) -> bool:
"""Check if a post with the given title is present in the list.
@@ -79,7 +67,7 @@ class HomePage(BasePage):
Returns:
True if the empty state is visible.
"""
return self._empty_state.is_visible(self.page)
return self.loc("empty-state")._get_locator().is_visible()
def count_posts(self) -> int:
"""Count the number of post cards on the page.
@@ -107,7 +95,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 +106,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."""
@@ -138,20 +126,6 @@ class PostFormPage(BasePage):
path = "/web/posts/new"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the post form page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._title_input = SmartLocator.by_testid("input-title")
self._content_input = SmartLocator.by_testid("textarea-content")
self._tags_input = SmartLocator.by_testid("input-tags")
self._publish_btn = SmartLocator.by_testid("btn-publish-post")
self._save_draft_btn = SmartLocator.by_testid("btn-save-draft")
def fill_form(self, title: str, content: str, tags: str) -> None:
"""Fill the post creation form.
@@ -160,8 +134,8 @@ class PostFormPage(BasePage):
content: Post content (markdown).
tags: Comma-separated tags string.
"""
self._title_input.fill(self.page, title)
self._tags_input.fill(self.page, tags)
self.loc("input-title").fill(title)
self.loc("input-tags").fill(tags)
self.page.evaluate(
"(content) => {"
@@ -177,11 +151,11 @@ class PostFormPage(BasePage):
def publish(self) -> None:
"""Click the publish button to submit the form."""
self._publish_btn.click(self.page)
self.loc("btn-publish-post").click()
def save_draft(self) -> None:
"""Click the 'Save as Draft' button."""
self._save_draft_btn.click(self.page)
self.loc("btn-save-draft").click()
class PostDetailPage(BasePage):
@@ -203,11 +177,6 @@ class PostDetailPage(BasePage):
"""
super().__init__(page, base_url)
self.slug = slug
self._title = SmartLocator.by_testid("post-detail-title")
self._status = SmartLocator.by_testid("post-detail-status")
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")
@property
def url(self) -> str:
@@ -233,7 +202,7 @@ class PostDetailPage(BasePage):
Returns:
Post title string.
"""
return self._title.get_text(self.page)
return self.loc("post-detail-title").text_content() or ""
def get_status(self) -> str:
"""Get the post status badge text.
@@ -241,7 +210,7 @@ class PostDetailPage(BasePage):
Returns:
Status text ('Published' or 'Draft').
"""
return self._status.get_text(self.page)
return self.loc("post-detail-status").text_content() or ""
def is_published(self) -> bool:
"""Check if the post status is 'Published'.
@@ -253,7 +222,7 @@ class PostDetailPage(BasePage):
def edit(self) -> None:
"""Click the edit button to navigate to the edit form."""
self._edit_btn.click(self.page)
self.loc("btn-edit-post").click()
def can_edit(self) -> bool:
"""Check if the edit button is visible.
@@ -261,7 +230,7 @@ class PostDetailPage(BasePage):
Returns:
True if edit button is present.
"""
return self._edit_btn.is_visible(self.page)
return self.loc("btn-edit-post")._get_locator().is_visible()
def can_delete(self) -> bool:
"""Check if the delete button is visible.
@@ -269,9 +238,22 @@ class PostDetailPage(BasePage):
Returns:
True if delete button is present.
"""
return self._delete_btn.is_visible(self.page)
return self.loc("btn-delete-post")._get_locator().is_visible()
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)
self.loc("btn-delete-post").click()
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.loc("like-button").click()

190
tests/e2e/test_comments.py Normal file
View File

@@ -0,0 +1,190 @@
"""End-to-end tests for blog post comments.
Tests comment creation, nested replies with @username prefix,
and comment visibility for authenticated users.
"""
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_create_and_reply_comment(
user_page: Page,
base_url: str,
) -> None:
"""Test TC-E2E-110: Create a top-level comment, reply, and nested reply.
Steps:
1. User creates and publishes a post.
2. User navigates to the post detail page.
3. User clicks "Write a Comment" button.
4. User types a top-level comment and submits it.
5. Page reloads, top-level comment is visible.
6. User clicks "Reply" on the top-level comment.
7. User types a reply and submits it.
8. Page reloads, reply is visible under the parent comment.
9. User clicks "Reply" on the reply (nested reply).
10. User types a nested reply and submits it.
11. Page reloads, nested reply is visible under the reply.
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
# Click "Write a Comment" button to show the form
user_page.locator('[data-testid="btn-show-comment-form"]').click()
user_page.wait_for_selector('[data-testid="form-create-comment"]', state="visible")
# Write a top-level comment
comment_text = "This is a top-level comment with enough length for testing."
user_page.locator('[data-testid="input-comment-content"]').fill(comment_text)
# Submit the comment
user_page.locator('[data-testid="submit-comment"]').click()
# Page should reload after successful comment creation
user_page.wait_for_selector('[data-testid="comments-section"]', timeout=15000)
# Verify the top-level comment appears
comment_locator = user_page.locator('[data-testid^="comment-content-"]', has_text=comment_text)
expect(comment_locator.first).to_be_visible(timeout=10000)
# Get the comment ID for the reply button
top_comment = user_page.locator('[data-testid^="comment-"][data-comment-id]').first
comment_id = top_comment.get_attribute("data-comment-id")
# Click Reply on the top-level comment
reply_btn = user_page.locator(f'[data-testid="btn-comment-reply-{comment_id}"]')
reply_btn.click()
# The comment form should appear with reply info
user_page.wait_for_selector('[data-testid="comment-form-help"]', state="visible")
# Write a reply
reply_text = "This is a reply to the comment."
user_page.locator('[data-testid="input-comment-content"]').fill(reply_text)
# Submit the reply
user_page.locator('[data-testid="submit-comment"]').click()
# Page should reload
user_page.wait_for_selector('[data-testid="comments-section"]', timeout=15000)
# Verify the reply appears in the comment-replies section under the parent
replies_section = user_page.locator(f'[data-testid="comment-replies-{comment_id}"]')
expect(replies_section).to_be_visible(timeout=10000)
# Verify reply text is visible within the replies section
reply_in_section = replies_section.locator(
'[data-testid^="comment-content-"]', has_text=reply_text
)
expect(reply_in_section).to_be_visible(timeout=5000)
# Get the reply's comment ID for the nested reply
reply_element = replies_section.locator('[data-testid^="comment-"][data-comment-id]').first
reply_id = reply_element.get_attribute("data-comment-id")
# Click Reply on the reply (nested reply)
user_page.locator(f'[data-testid="btn-comment-reply-{reply_id}"]').click()
user_page.wait_for_selector('[data-testid="comment-form-help"]', state="visible")
# Write a nested reply
nested_text = "This is a reply to the reply."
user_page.locator('[data-testid="input-comment-content"]').fill(nested_text)
# Submit the nested reply
user_page.locator('[data-testid="submit-comment"]').click()
# Page should reload
user_page.wait_for_selector('[data-testid="comments-section"]', timeout=15000)
# Verify the nested reply appears in the comment-replies section under the reply
nested_replies = user_page.locator(f'[data-testid="comment-replies-{reply_id}"]')
expect(nested_replies).to_be_visible(timeout=10000)
# Verify nested reply text is visible
nested_in_section = nested_replies.locator(
'[data-testid^="comment-content-"]', has_text=nested_text
)
expect(nested_in_section).to_be_visible(timeout=5000)
@pytest.mark.e2e
def test_guest_cannot_comment(
guest_page: Page,
user_page: Page,
base_url: str,
) -> None:
"""Test TC-E2E-112: Guest cannot see the comment form.
Steps:
1. User creates and publishes a post.
2. Guest opens the post detail page.
3. Verify guest cannot see the "Write a Comment" button.
Args:
guest_page: Unauthenticated Playwright page.
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]
# 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"]')
# Verify guest cannot see comment form or button
comment_btn = guest_page.locator('[data-testid="btn-show-comment-form"]')
expect(comment_btn).to_have_count(0)

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

View File

@@ -0,0 +1,128 @@
"""Tests for CreateCommentUseCase.
This module tests comment creation use case covering top-level comments,
replies to existing comments, and post-not-found scenarios.
"""
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from app.application.use_cases.create_comment import CreateCommentUseCase
from app.domain.entities import Post
from app.domain.exceptions import NotFoundException
@pytest.fixture
def test_post() -> Post:
"""Create a test post for comment tests."""
return Post.create(
title_str="Commentable Post",
content_str="This post will receive comments. Enough length here.",
author_id="user-123",
tags=["test"],
)
class TestCreateCommentUseCase:
"""Tests for CreateCommentUseCase.
Covers TC-UNIT-832 through TC-UNIT-834.
"""
@pytest.mark.asyncio
async def test_create_comment_on_post(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test creating a top-level comment on a post.
TC-UNIT-832: Positive — create top-level comment.
"""
mock_comment_repository = AsyncMock()
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
use_case = CreateCommentUseCase(
post_repo=mock_post_repository,
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
result = await use_case.execute(
post_id=test_post.id,
author_id="user-456",
content="Great post! Thanks for sharing.",
)
assert result.post_id == test_post.id
assert result.author_id == "user-456"
assert result.content == "Great post! Thanks for sharing."
assert result.parent_id is None
assert result.like_count == 0
mock_comment_repository.add.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_create_comment_reply(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test creating a reply to an existing comment.
TC-UNIT-833: Positive — reply to comment with parent_id.
"""
mock_comment_repository = AsyncMock()
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
parent_id = uuid4()
use_case = CreateCommentUseCase(
post_repo=mock_post_repository,
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
result = await use_case.execute(
post_id=test_post.id,
author_id="user-456",
content="This is a reply.",
parent_id=parent_id,
)
assert result.parent_id == parent_id
assert result.post_id == test_post.id
mock_comment_repository.add.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_create_comment_post_not_found(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test creating a comment on a non-existent post.
TC-UNIT-834: Negative — post not found.
"""
mock_comment_repository = AsyncMock()
mock_post_repository.get_by_id = AsyncMock(return_value=None)
use_case = CreateCommentUseCase(
post_repo=mock_post_repository,
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
with pytest.raises(NotFoundException):
await use_case.execute(
post_id=uuid4(),
author_id="user-456",
content="Comment on missing post.",
)
mock_comment_repository.add.assert_not_called()
mock_transaction_manager.commit.assert_not_called()

View File

@@ -0,0 +1,81 @@
"""Tests for DeleteCommentUseCase.
This module tests the comment deletion use case covering own comment
deletion, admin deletion, and not-found scenarios.
"""
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.application.use_cases.delete_comment import DeleteCommentUseCase
from app.domain.entities.comment import Comment
from app.domain.exceptions import NotFoundException
class TestDeleteCommentUseCase:
"""Tests for DeleteCommentUseCase.
Covers TC-UNIT-836 and TC-UNIT-837.
"""
@pytest.mark.asyncio
async def test_delete_own_comment(
self,
mock_transaction_manager: AsyncMock,
) -> None:
"""Test deleting own comment.
TC-UNIT-836: Positive — user deletes own comment.
"""
post_id = uuid4()
author_id = "user-123"
comment = Comment.create(
post_id=post_id,
author_id=author_id,
content_str="Comment to delete.",
)
mock_comment_repository = AsyncMock()
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
mock_comment_repository.delete = AsyncMock()
use_case = DeleteCommentUseCase(
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
await use_case.execute(
comment_id=comment.id,
user_id=author_id,
)
mock_comment_repository.delete.assert_called_once_with(comment.id)
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_delete_comment_not_found(
self,
mock_transaction_manager: AsyncMock,
) -> None:
"""Test deleting a non-existent comment.
TC-UNIT-837: Negative — comment not found.
"""
mock_comment_repository = AsyncMock()
mock_comment_repository.get_by_id = AsyncMock(return_value=None)
use_case = DeleteCommentUseCase(
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
with pytest.raises(NotFoundException):
await use_case.execute(
comment_id=uuid4(),
user_id="user-123",
)
mock_comment_repository.delete.assert_not_called()
mock_transaction_manager.commit.assert_not_called()

View File

@@ -0,0 +1,59 @@
"""Tests for ListCommentsUseCase.
This module tests the comment listing use case covering retrieval
of comments by post ID.
"""
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from app.application.use_cases.list_comments import ListCommentsUseCase
from app.domain.entities.comment import Comment
class TestListCommentsUseCase:
"""Tests for ListCommentsUseCase.
Covers TC-UNIT-835.
"""
@pytest.mark.asyncio
async def test_list_comments_by_post(
self,
mock_transaction_manager: Mock,
) -> None:
"""Test listing comments for a post.
TC-UNIT-835: Positive — returns comments for given post_id.
"""
post_id = uuid4()
author_id = "user-123"
comments = [
Comment.create(
post_id=post_id,
author_id=author_id,
content_str="First comment.",
),
Comment.create(
post_id=post_id,
author_id="user-456",
content_str="Second comment.",
),
]
mock_comment_repository = AsyncMock()
mock_comment_repository.get_by_post = AsyncMock(return_value=comments)
use_case = ListCommentsUseCase(
comment_repo=mock_comment_repository,
)
result = await use_case.execute(post_id=post_id)
assert len(result) == 2
assert result[0].post_id == post_id
assert result[0].author_id == author_id
assert result[1].author_id == "user-456"
mock_comment_repository.get_by_post.assert_called_once_with(post_id)

View File

@@ -0,0 +1,119 @@
"""Tests for ToggleCommentLikeUseCase.
This module tests the comment like/unlike toggle use case covering
first-time like, unlike, and comment-not-found scenarios.
"""
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.application.use_cases.toggle_comment_like import ToggleCommentLikeUseCase
from app.domain.entities.comment import Comment
from app.domain.entities.comment_like import CommentLike
from app.domain.exceptions import NotFoundException
class TestToggleCommentLikeUseCase:
"""Tests for ToggleCommentLikeUseCase.
Covers TC-UNIT-838 through TC-UNIT-840.
"""
@pytest.mark.asyncio
async def test_like_comment_first_time(
self,
mock_transaction_manager: AsyncMock,
) -> None:
"""Test liking a comment for the first time.
TC-UNIT-838: Positive — like first time.
"""
post_id = uuid4()
comment = Comment.create(
post_id=post_id,
author_id="user-123",
content_str="Nice post!",
)
mock_comment_repository = AsyncMock()
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
mock_comment_repository.get_like = AsyncMock(return_value=None)
mock_comment_repository.add_like = AsyncMock()
mock_comment_repository.remove_like = AsyncMock()
mock_comment_repository.update = AsyncMock()
use_case = ToggleCommentLikeUseCase(
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
result = await use_case.execute(comment.id, "user-456")
assert result.like_count == 1
mock_comment_repository.add_like.assert_called_once()
mock_comment_repository.remove_like.assert_not_called()
mock_comment_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_unlike_comment_already_liked(
self,
mock_transaction_manager: AsyncMock,
) -> None:
"""Test unliking a comment that is already liked.
TC-UNIT-839: Positive — unlike (already liked).
"""
post_id = uuid4()
comment = Comment.create(
post_id=post_id,
author_id="user-123",
content_str="Nice post!",
)
existing_like = CommentLike(comment_id=comment.id, liked_by="user-456")
mock_comment_repository = AsyncMock()
mock_comment_repository.get_by_id = AsyncMock(return_value=comment)
mock_comment_repository.get_like = AsyncMock(return_value=existing_like)
mock_comment_repository.add_like = AsyncMock()
mock_comment_repository.remove_like = AsyncMock()
mock_comment_repository.update = AsyncMock()
use_case = ToggleCommentLikeUseCase(
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
result = await use_case.execute(comment.id, "user-456")
assert result.like_count == 0
mock_comment_repository.remove_like.assert_called_once()
mock_comment_repository.add_like.assert_not_called()
mock_comment_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_like_comment_not_found(
self,
mock_transaction_manager: AsyncMock,
) -> None:
"""Test liking a non-existent comment.
TC-UNIT-840: Negative — comment not found.
"""
mock_comment_repository = AsyncMock()
mock_comment_repository.get_by_id = AsyncMock(return_value=None)
use_case = ToggleCommentLikeUseCase(
comment_repo=mock_comment_repository,
tx_manager=mock_transaction_manager,
)
with pytest.raises(NotFoundException):
await use_case.execute(uuid4(), "user-456")
mock_comment_repository.add_like.assert_not_called()
mock_comment_repository.remove_like.assert_not_called()
mock_transaction_manager.commit.assert_not_called()

View File

@@ -0,0 +1,170 @@
"""Tests for TogglePostLikeUseCase.
This module tests the like/unlike toggle use case covering
first-time like, unlike, post-not-found, guest access, and
identity isolation scenarios.
"""
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
from app.application.use_cases.toggle_like import TogglePostLikeUseCase
from app.domain.entities import Post
from app.domain.entities.like import PostLike
from app.domain.exceptions import NotFoundException
@pytest.fixture
def test_post() -> Post:
"""Create a test post for like tests."""
return Post.create(
title_str="Likeable Post",
content_str="This post will be liked and unliked. Enough length here.",
author_id="user-123",
tags=["test"],
)
class TestTogglePostLikeUseCase:
"""Tests for TogglePostLikeUseCase.
Covers TC-UNIT-822 through TC-UNIT-825 and TC-UNIT-828.
"""
@pytest.mark.asyncio
async def test_like_post_first_time(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test toggling like on a post for the first time.
TC-UNIT-822: Positive — like first time.
"""
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.get_like = AsyncMock(return_value=None)
mock_post_repository.add_like = AsyncMock()
mock_post_repository.remove_like = AsyncMock()
mock_post_repository.update = AsyncMock()
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
result = await use_case.execute(test_post.id, "user-123")
assert result.like_count == 1
mock_post_repository.add_like.assert_called_once()
mock_post_repository.remove_like.assert_not_called()
mock_post_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_unlike_post_already_liked(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test toggling like on a post that is already liked.
TC-UNIT-823: Positive — unlike (already liked).
"""
existing_like = PostLike(post_id=test_post.id, liked_by="user-123")
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.get_like = AsyncMock(return_value=existing_like)
mock_post_repository.add_like = AsyncMock()
mock_post_repository.remove_like = AsyncMock()
mock_post_repository.update = AsyncMock()
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
result = await use_case.execute(test_post.id, "user-123")
assert result.like_count == 0
mock_post_repository.remove_like.assert_called_once()
mock_post_repository.add_like.assert_not_called()
mock_post_repository.update.assert_called_once()
mock_transaction_manager.commit.assert_called_once()
@pytest.mark.asyncio
async def test_like_post_not_found(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
) -> None:
"""Test toggling like on a non-existent post.
TC-UNIT-824: Negative — post not found.
"""
mock_post_repository.get_by_id = AsyncMock(return_value=None)
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
with pytest.raises(NotFoundException):
await use_case.execute(uuid4(), "user-123")
mock_post_repository.add_like.assert_not_called()
mock_post_repository.remove_like.assert_not_called()
mock_transaction_manager.commit.assert_not_called()
@pytest.mark.asyncio
async def test_like_as_guest_with_device_id(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test toggling like as a guest using device_id.
TC-UNIT-825: Positive — guest via device_id.
"""
device_id = "device-abc-123"
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.get_like = AsyncMock(return_value=None)
mock_post_repository.add_like = AsyncMock()
mock_post_repository.remove_like = AsyncMock()
mock_post_repository.update = AsyncMock()
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
result = await use_case.execute(test_post.id, device_id)
assert result.like_count == 1
added_like = mock_post_repository.add_like.call_args[0][0]
assert added_like.liked_by == device_id
assert added_like.post_id == test_post.id
@pytest.mark.asyncio
async def test_two_users_can_both_like(
self,
mock_post_repository: Mock,
mock_transaction_manager: Mock,
test_post: Post,
) -> None:
"""Test that two different users can both like the same post.
TC-UNIT-828: Positive — identity isolation.
Both likes are counted independently.
"""
mock_post_repository.get_by_id = AsyncMock(return_value=test_post)
mock_post_repository.get_like = AsyncMock(return_value=None)
mock_post_repository.add_like = AsyncMock()
mock_post_repository.remove_like = AsyncMock()
mock_post_repository.update = AsyncMock()
use_case = TogglePostLikeUseCase(mock_post_repository, mock_transaction_manager)
result1 = await use_case.execute(test_post.id, "user-123")
assert result1.like_count == 1
mock_post_repository.add_like.reset_mock()
mock_post_repository.update.reset_mock()
mock_transaction_manager.commit.reset_mock()
result2 = await use_case.execute(test_post.id, "user-456")
assert result2.like_count == 2
assert mock_post_repository.add_like.call_count == 1

View File

@@ -0,0 +1,98 @@
"""Tests for Comment domain entity.
This module tests the Comment entity creation, parent_id support,
and BaseEntity integration.
"""
from uuid import UUID, uuid4
from app.domain.entities.comment import Comment
from app.domain.value_objects.comment_content import CommentContent
class TestCommentEntity:
"""Tests for the Comment domain entity.
Covers TC-UNIT-829 and TC-UNIT-830.
"""
def test_comment_creation(self) -> None:
"""Test creating a top-level Comment with valid attributes.
TC-UNIT-829: Positive — create Comment instance.
Expected:
- post_id matches input
- author_id matches input
- content is CommentContent with correct value
- id is a valid UUID
- parent_id is None
- like_count is 0
- created_at is set
"""
post_id = UUID("00000000-0000-0000-0000-000000000001")
author_id = "user-123"
content_text = "This is a comment with **Markdown** support."
comment = Comment.create(
post_id=post_id,
author_id=author_id,
content_str=content_text,
)
assert comment.post_id == post_id
assert comment.author_id == author_id
assert isinstance(comment.content, CommentContent)
assert comment.content.value == content_text
assert isinstance(comment.id, UUID)
assert comment.parent_id is None
assert comment.like_count == 0
assert comment.created_at is not None
def test_comment_with_parent(self) -> None:
"""Test creating a reply Comment with parent_id.
TC-UNIT-830: Positive — create Comment with parent_id.
Expected:
- parent_id matches the provided parent comment ID.
- All other attributes set correctly.
"""
post_id = UUID("00000000-0000-0000-0000-000000000001")
parent_id = uuid4()
author_id = "user-456"
content_text = "This is a reply to another comment."
comment = Comment.create(
post_id=post_id,
author_id=author_id,
content_str=content_text,
parent_id=parent_id,
)
assert comment.parent_id == parent_id
assert comment.post_id == post_id
assert comment.author_id == author_id
assert comment.content.value == content_text
assert comment.like_count == 0
def test_comment_to_dict(self) -> None:
"""Test Comment to_dict serialization."""
post_id = UUID("00000000-0000-0000-0000-000000000001")
author_id = "user-123"
content_text = "Comment with serialization test."
comment = Comment.create(
post_id=post_id,
author_id=author_id,
content_str=content_text,
)
data = comment.to_dict()
assert data["post_id"] == str(post_id)
assert data["author_id"] == author_id
assert data["content"] == content_text
assert "id" in data
assert "created_at" in data
assert data["parent_id"] is None
assert data["like_count"] == 0

View File

@@ -0,0 +1,50 @@
"""Tests for CommentLike domain entity.
This module tests the CommentLike entity creation, attributes,
and BaseEntity integration.
"""
from uuid import UUID
from app.domain.entities.comment_like import CommentLike
class TestCommentLikeEntity:
"""Tests for the CommentLike domain entity.
Covers TC-UNIT-831.
"""
def test_comment_like_creation(self) -> None:
"""Test creating a CommentLike with valid attributes.
TC-UNIT-831: Positive — create CommentLike instance.
Expected:
- comment_id matches input
- liked_by matches input
- id is a valid UUID
- created_at is set
"""
comment_id = UUID("00000000-0000-0000-0000-000000000001")
liked_by = "user-123"
like = CommentLike(comment_id=comment_id, liked_by=liked_by)
assert like.comment_id == comment_id
assert like.liked_by == liked_by
assert isinstance(like.id, UUID)
assert like.created_at is not None
def test_comment_like_to_dict(self) -> None:
"""Test CommentLike to_dict serialization."""
comment_id = UUID("00000000-0000-0000-0000-000000000001")
liked_by = "device-abc-123"
like = CommentLike(comment_id=comment_id, liked_by=liked_by)
data = like.to_dict()
assert data["comment_id"] == str(comment_id)
assert data["liked_by"] == liked_by
assert "id" in data
assert "created_at" in data

View File

@@ -128,6 +128,19 @@ class TestPost:
assert "created_at" in data
assert "updated_at" in data
def test_like_count_defaults_to_zero(self) -> None:
"""Test that a new post has like_count defaulting to 0.
TC-UNIT-827: Positive — like_count defaults to zero on creation.
"""
post = Post.create(
title_str="Test Post",
content_str="This is test content that is long enough",
author_id="user-123",
)
assert post.like_count == 0
def test_base_entity_eq_and_hash(self) -> None:
"""Test BaseEntity equality and hash directly."""
from app.domain.entities.base import BaseEntity

View File

@@ -0,0 +1,50 @@
"""Tests for PostLike domain entity.
This module tests the PostLike entity creation, attributes,
and BaseEntity integration.
"""
from uuid import UUID
from app.domain.entities.like import PostLike
class TestPostLikeEntity:
"""Tests for the PostLike domain entity.
Covers TC-UNIT-826: PostLike entity valid creation.
"""
def test_post_like_creation(self) -> None:
"""Test creating a PostLike with valid attributes.
TC-UNIT-826: Positive — create PostLike instance.
Expected:
- post_id matches input
- liked_by matches input
- id is a valid UUID
- created_at is set
"""
post_id = UUID("00000000-0000-0000-0000-000000000001")
liked_by = "user-123"
like = PostLike(post_id=post_id, liked_by=liked_by)
assert like.post_id == post_id
assert like.liked_by == liked_by
assert isinstance(like.id, UUID)
assert like.created_at is not None
def test_post_like_to_dict(self) -> None:
"""Test PostLike to_dict serialization."""
post_id = UUID("00000000-0000-0000-0000-000000000001")
liked_by = "device-abc-123"
like = PostLike(post_id=post_id, liked_by=liked_by)
data = like.to_dict()
assert data["post_id"] == str(post_id)
assert data["liked_by"] == liked_by
assert "id" in data
assert "created_at" in data

View File

@@ -0,0 +1,196 @@
"""Tests for i18n infrastructure.
Covers TranslationService, locale detection helpers, and the
convenience _() function.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.infrastructure.i18n.translator import (
DEFAULT_LOCALE,
TranslationService,
_,
)
from app.presentation.web.locale import (
_get_best_locale,
_parse_accept_language,
setup_locale_manager,
)
def _make_mock_request(
cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
) -> MagicMock:
"""Create a mock FastAPI Request with controlled state, cookies and headers."""
request = MagicMock()
request.state = SimpleNamespace()
request.cookies = cookies or {}
request.headers = headers or {}
return request
class TestTranslationService:
"""Test TranslationService get_text resolution and fallback chain."""
def test_get_text_returns_translation_for_existing_key(self) -> None:
"""Test get_text returns the correct translation for a known key."""
ts = TranslationService()
result = ts.get_text("nav.home", "ru")
assert result == "Главная"
def test_get_text_returns_english_fallback_for_missing_key(self) -> None:
"""Test get_text falls back to English when locale lacks the key."""
ts = TranslationService()
result = ts.get_text("nav.home", "fr")
assert result == "Accueil"
def test_get_text_returns_key_when_neither_locale_nor_en_has_it(self) -> None:
"""Test get_text returns the key itself when no translation exists anywhere."""
ts = TranslationService()
result = ts.get_text("nonexistent.key", "de")
assert result == "nonexistent.key"
def test_get_text_returns_english_fallback_for_unknown_locale(self) -> None:
"""Test get_text returns English when the requested locale does not exist."""
ts = TranslationService()
result = ts.get_text("nav.home", "zz")
assert result == "Home"
def test_get_text_returns_en_when_requested_locale_is_en(self) -> None:
"""Test get_text returns English string when locale is en."""
ts = TranslationService()
result = ts.get_text("nav.home", "en")
assert result == "Home"
def test_get_text_returns_en_fallback_when_requested_locale_missing_key(self) -> None:
"""Test get_text falls back to English when locale is unknown and key exists in en."""
ts = TranslationService()
result = ts.get_text("about.signed_in", "zz")
assert result == "Signed in as {username}."
def test_singleton_returns_same_instance(self) -> None:
"""Test TranslationService.get_instance always returns the same instance."""
instance_a = TranslationService.get_instance()
instance_b = TranslationService.get_instance()
assert instance_a is instance_b
def test_singleton_shares_translations(self) -> None:
"""Test that multiple calls via singleton share the same data."""
instance_a = TranslationService.get_instance()
instance_b = TranslationService.get_instance()
assert instance_a.translations is instance_b.translations
class TestConvenienceFunction:
"""Test the module-level _() convenience function."""
def test_convenience_function_returns_translation(self) -> None:
"""Test _() returns correct translation for a given key."""
result = _("header.logo", "de")
assert result == "Blog"
def test_convenience_function_defaults_to_english(self) -> None:
"""Test _() uses DEFAULT_LOCALE when no locale is given."""
result = _("nav.posts")
assert result == "Posts"
assert DEFAULT_LOCALE == "en"
def test_convenience_function_returns_key_on_missing(self) -> None:
"""Test _() returns key when translation does not exist."""
result = _("utterly.fake.key", "fr")
assert result == "utterly.fake.key"
class TestParseAcceptLanguage:
"""Test the Accept-Language header parser."""
def test_empty_header_returns_empty_list(self) -> None:
"""Test empty Accept-Language returns empty list."""
assert _parse_accept_language("") == []
def test_single_locale(self) -> None:
"""Test a single locale code is returned as a single-element list."""
assert _parse_accept_language("fr") == ["fr"]
def test_multiple_locales_with_quality(self) -> None:
"""Test multiple locales with q-values are returned in order."""
result = _parse_accept_language("fr, en;q=0.9, de;q=0.8")
assert result == ["fr", "en", "de"]
def test_region_code_strips_to_base_language(self) -> None:
"""Test fr-FR is normalised to fr."""
assert _parse_accept_language("fr-FR") == ["fr"]
def test_complex_header(self) -> None:
"""Test a realistic Accept-Language header with multiple locales."""
result = _parse_accept_language("ru-RU, ru;q=0.9, en;q=0.5")
assert result == ["ru", "ru", "en"]
def test_whitespace_around_locales(self) -> None:
"""Test whitespace around locale codes is handled gracefully."""
result = _parse_accept_language(" en , fr;q=0.5 ")
assert result == ["en", "fr"]
class TestGetBestLocale:
"""Test locale detection from cookie and Accept-Language header."""
def test_cookie_takes_priority(self) -> None:
"""Test cookie value is used when it is a supported locale."""
request = _make_mock_request(cookies={"locale": "de"}, headers={"accept-language": "ru"})
assert _get_best_locale(request) == "de"
def test_invalid_cookie_falls_back_to_header(self) -> None:
"""Test unsupported locale in cookie falls back to Accept-Language."""
request = _make_mock_request(cookies={"locale": "zz"}, headers={"accept-language": "fr"})
assert _get_best_locale(request) == "fr"
def test_no_cookie_uses_accept_language(self) -> None:
"""Test Accept-Language is used when no cookie is present."""
request = _make_mock_request(headers={"accept-language": "de"})
assert _get_best_locale(request) == "de"
def test_no_cookie_no_header_returns_default(self) -> None:
"""Test default locale returned when neither cookie nor header matches."""
request = _make_mock_request()
assert _get_best_locale(request) == DEFAULT_LOCALE
def test_accept_language_unsupported_returns_default(self) -> None:
"""Test unsupported language in header falls back to default."""
request = _make_mock_request(headers={"accept-language": "zh"})
assert _get_best_locale(request) == DEFAULT_LOCALE
def test_missing_cookie_key_uses_header(self) -> None:
"""Test absent locale cookie is treated as no preference."""
request = _make_mock_request(cookies={"other": "val"}, headers={"accept-language": "ru"})
assert _get_best_locale(request) == "ru"
class TestSetupLocaleManager:
"""Test the middleware helper that sets locale on request state."""
@pytest.mark.asyncio
async def test_sets_locale_on_request_state(self) -> None:
"""Test setup_locale_manager sets locale when not already present."""
request = _make_mock_request(headers={"accept-language": "fr"})
await setup_locale_manager(request)
assert request.state.locale == "fr"
@pytest.mark.asyncio
async def test_does_not_override_existing_locale(self) -> None:
"""Test setup_locale_manager does not override an already-set locale."""
request = _make_mock_request(headers={"accept-language": "fr"})
request.state.locale = "de"
await setup_locale_manager(request)
assert request.state.locale == "de"
@pytest.mark.asyncio
async def test_default_locale_when_no_match(self) -> None:
"""Test setup_locale_manager uses default when nothing matches."""
request = _make_mock_request()
await setup_locale_manager(request)
assert request.state.locale == DEFAULT_LOCALE