Compare commits
21 Commits
ada161f5fc
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 99acd9d287 | |||
| 96ecad0c6f | |||
| ca84bd7fac | |||
| 9124aa17d5 | |||
| 0e46a5f41b | |||
| 7bf9cce337 | |||
| 8ca36cdb44 | |||
| 7ff3fa0992 | |||
| 63da25174e | |||
| 30d9e287a7 | |||
| c8e19e3ce5 | |||
| 3cf6c94da2 | |||
| 4497f452a1 | |||
| 391ecaa4b0 | |||
| de92f73f58 | |||
| d32ad29abc | |||
| 4e6505c598 | |||
| c9b380c601 | |||
| 448da0396a | |||
| c790b6edc6 | |||
| 9cc2f6284d |
@@ -2,6 +2,14 @@ when:
|
||||
event: [push, pull_request]
|
||||
branch: [dev, main, master]
|
||||
|
||||
services:
|
||||
- name: postgres
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: blog_test
|
||||
|
||||
steps:
|
||||
- name: deps
|
||||
image: python:3.13
|
||||
@@ -13,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
|
||||
@@ -66,10 +59,27 @@ steps:
|
||||
UV_CACHE_DIR: /root/.cache/uv
|
||||
UV_LINK_MODE: copy
|
||||
UV_PYTHON: "3.13"
|
||||
COVERAGE_FILE: .coverage.unit
|
||||
depends_on: [deps]
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv run --no-sync pytest tests/unit/
|
||||
- uv run --no-sync pytest tests/unit/ -o "addopts=--cov=app --cov-report=term-missing --cov-fail-under=0"
|
||||
|
||||
- name: test-integration
|
||||
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"
|
||||
DB_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/blog_test
|
||||
SKIP_INIT_DB: "1"
|
||||
COVERAGE_FILE: .coverage.integration
|
||||
depends_on: [deps]
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv run --no-sync pytest tests/integration/ -v -o "addopts=--cov=app --cov-report=term-missing --cov-fail-under=0"
|
||||
|
||||
- name: test-e2e
|
||||
image: python:3.13
|
||||
@@ -79,11 +89,59 @@ steps:
|
||||
UV_CACHE_DIR: /root/.cache/uv
|
||||
UV_LINK_MODE: copy
|
||||
UV_PYTHON: "3.13"
|
||||
depends_on: [deps]
|
||||
DB_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/blog_test
|
||||
SKIP_INIT_DB: "1"
|
||||
depends_on: [test-integration]
|
||||
commands:
|
||||
- pip install uv
|
||||
- uv run --no-sync alembic upgrade head
|
||||
- apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2
|
||||
- uv run --no-sync playwright install chromium
|
||||
- uv run --no-sync blog &
|
||||
- sleep 5
|
||||
- uv run --no-sync pytest tests/e2e/ -v --no-cov
|
||||
|
||||
- name: coverage
|
||||
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"
|
||||
depends_on: [test-unit, test-integration, test-e2e]
|
||||
commands:
|
||||
- pip install uv
|
||||
- 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"
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -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
|
||||
|
||||
149
alembic.ini
Normal file
149
alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite+aiosqlite:///./blog.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
64
alembic/env.py
Normal file
64
alembic/env.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from alembic import context
|
||||
from app.infrastructure.database.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
url = os.environ.get("DB_URL") or config.get_main_option("sqlalchemy.url")
|
||||
if not url:
|
||||
raise RuntimeError("Database URL not configured")
|
||||
return url
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = _get_database_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
db_url = _get_database_url()
|
||||
connectable = create_async_engine(db_url, poolclass=pool.NullPool)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
30
alembic/script.py.mako
Normal file
30
alembic/script.py.mako
Normal file
@@ -0,0 +1,30 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Initial migration for PostORM.
|
||||
|
||||
Revision ID: 5357028a1574
|
||||
Revises:
|
||||
Create Date: 2026-05-09 20:56:26.292255
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5357028a1574"
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
"posts",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("slug", sa.String(200), nullable=False),
|
||||
sa.Column("author_id", sa.String(100), nullable=False),
|
||||
sa.Column("published", sa.Boolean(), nullable=False),
|
||||
sa.Column("tags", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("slug"),
|
||||
)
|
||||
op.create_index("ix_posts_author_id", "posts", ["author_id"])
|
||||
op.create_index("ix_posts_published", "posts", ["published"])
|
||||
op.create_index("ix_posts_slug", "posts", ["slug"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("ix_posts_slug", table_name="posts")
|
||||
op.drop_index("ix_posts_published", table_name="posts")
|
||||
op.drop_index("ix_posts_author_id", table_name="posts")
|
||||
op.drop_table("posts")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
55
app/application/dtos/comment.py
Normal file
55
app/application/dtos/comment.py
Normal 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
|
||||
@@ -100,3 +100,5 @@ class PostResponseDTO:
|
||||
tags: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
like_count: int = 0
|
||||
comment_count: int = 0
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
100
app/application/use_cases/create_comment.py
Normal file
100
app/application/use_cases/create_comment.py
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
60
app/application/use_cases/delete_comment.py
Normal file
60
app/application/use_cases/delete_comment.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
63
app/application/use_cases/list_comments.py
Normal file
63
app/application/use_cases/list_comments.py
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
96
app/application/use_cases/toggle_comment_like.py
Normal file
96
app/application/use_cases/toggle_comment_like.py
Normal 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,
|
||||
)
|
||||
102
app/application/use_cases/toggle_like.py
Normal file
102
app/application/use_cases/toggle_like.py
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
79
app/domain/entities/comment.py
Normal file
79
app/domain/entities/comment.py
Normal 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,
|
||||
)
|
||||
40
app/domain/entities/comment_like.py
Normal file
40
app/domain/entities/comment_like.py
Normal 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(),
|
||||
}
|
||||
40
app/domain/entities/like.py
Normal file
40
app/domain/entities/like.py
Normal 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(),
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
80
app/domain/repositories/comment.py
Normal file
80
app/domain/repositories/comment.py
Normal 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.
|
||||
"""
|
||||
...
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
47
app/domain/value_objects/comment_content.py
Normal file
47
app/domain/value_objects/comment_content.py
Normal 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")
|
||||
@@ -78,7 +78,13 @@ async def init_db() -> None:
|
||||
|
||||
Creates all tables defined in the metadata.
|
||||
Should be called on application startup.
|
||||
Skipped in CI/production where alembic manages schema.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.environ.get("SKIP_INIT_DB", "").lower() in ("1", "true", "yes"):
|
||||
return
|
||||
|
||||
from app.infrastructure.database.models import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
app/infrastructure/i18n/__init__.py
Normal file
9
app/infrastructure/i18n/__init__.py
Normal 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", "_"]
|
||||
377
app/infrastructure/i18n/translations.py
Normal file
377
app/infrastructure/i18n/translations.py
Normal 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",
|
||||
},
|
||||
}
|
||||
78
app/infrastructure/i18n/translator.py
Normal file
78
app/infrastructure/i18n/translator.py
Normal 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)
|
||||
240
app/infrastructure/repositories/comment.py
Normal file
240
app/infrastructure/repositories/comment.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
10
app/main.py
10
app/main.py
@@ -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=["*"],
|
||||
|
||||
@@ -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,11 +33,17 @@ 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)
|
||||
|
||||
|
||||
def get_keycloak_client(request: Request) -> KeycloakAuthClient:
|
||||
async def get_keycloak_client(request: Request) -> KeycloakAuthClient:
|
||||
"""Get Keycloak client from DI container via request state.
|
||||
|
||||
Args:
|
||||
@@ -41,7 +52,7 @@ def get_keycloak_client(request: Request) -> KeycloakAuthClient:
|
||||
Returns:
|
||||
KeycloakAuthClient instance from container.
|
||||
"""
|
||||
client: KeycloakAuthClient = request.state.dishka_container.get(KeycloakAuthClient)
|
||||
client: KeycloakAuthClient = await request.state.dishka_container.get(KeycloakAuthClient)
|
||||
return client
|
||||
|
||||
|
||||
@@ -64,7 +75,7 @@ async def get_current_token_info(
|
||||
if not credentials:
|
||||
raise UnauthorizedException("Authentication required")
|
||||
|
||||
keycloak_client = get_keycloak_client(request)
|
||||
keycloak_client = await get_keycloak_client(request)
|
||||
token = credentials.credentials
|
||||
token_info = await keycloak_client.introspect_token(token)
|
||||
|
||||
@@ -110,7 +121,7 @@ async def get_optional_token_info(
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
keycloak_client = get_keycloak_client(request)
|
||||
keycloak_client = await get_keycloak_client(request)
|
||||
token = credentials.credentials
|
||||
token_info = await keycloak_client.introspect_token(token)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
131
app/presentation/api/v1/comments.py
Normal file
131
app/presentation/api/v1/comments.py
Normal 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)
|
||||
@@ -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__)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
58
app/presentation/schemas/comment.py
Normal file
58
app/presentation/schemas/comment.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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">×</button>
|
||||
<button type="button" class="flash-close" data-testid="flash-close" aria-label="{{ _('base.close_message', current_locale) }}">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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> — <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 %}
|
||||
|
||||
@@ -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', '|',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
72
app/presentation/web/locale.py
Normal file
72
app/presentation/web/locale.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"sqlalchemy>=2.0.0",
|
||||
"aiosqlite>=0.21.0",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.15.0",
|
||||
"dishka>=1.5.0",
|
||||
"httpx>=0.28.0",
|
||||
"jinja2>=3.1.6",
|
||||
@@ -42,6 +43,7 @@ dev = [
|
||||
tests = [
|
||||
"httpx>=0.28.1",
|
||||
"mimesis>=19.1.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
@@ -59,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"
|
||||
@@ -81,7 +83,7 @@ target-version = "py313"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W", "B", "C4", "SIM"]
|
||||
select = ["E", "F", "W", "B", "C4", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.isort]
|
||||
|
||||
@@ -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
285
tests/FEATURE_COMMENTS.md
Normal 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
|
||||
@@ -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
208
tests/FEATURE_LIKES.md
Normal 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-001–003) — test infrastructure pending
|
||||
- [ ] Full device_id middleware for guest like support
|
||||
@@ -250,17 +250,199 @@ Covers both API use cases and web UI end-to-end flows.
|
||||
- Post no longer appears on home page
|
||||
- **Last Verified:** 2026-05-07
|
||||
|
||||
## API Test Cases
|
||||
|
||||
### TC-API-001: Create Post — Success
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestCreatePost::test_create_post_success`
|
||||
- **Steps:**
|
||||
1. POST `/api/v1/posts` with valid payload and user auth
|
||||
- **Expected:** 201 with correct title, content, tags, author_id, generated UUID/slug, published=False
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-002: Create Post — Validation Error
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestCreatePost::test_create_post_invalid_payload`
|
||||
- **Steps:**
|
||||
1. POST `/api/v1/posts` with too-short title
|
||||
- **Expected:** 422 validation error
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-004: List Posts — Default
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_default`
|
||||
- **Steps:**
|
||||
1. GET `/api/v1/posts` without auth
|
||||
- **Expected:** 200 with `items` and `total` fields
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-005: List Posts — Include Unpublished as Admin
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_include_unpublished_as_admin`
|
||||
- **Steps:**
|
||||
1. GET `/api/v1/posts?include_unpublished=true` with admin auth
|
||||
- **Expected:** 200 including unpublished posts
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-006: List Posts — Include Unpublished as User (Forbidden)
|
||||
- **Type:** Policy
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_include_unpublished_as_user_returns_403`
|
||||
- **Steps:**
|
||||
1. GET `/api/v1/posts?include_unpublished=true` with user auth
|
||||
- **Expected:** 403 ForbiddenException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-007: List Posts — Include Unpublished as Guest (Forbidden)
|
||||
- **Type:** Policy
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestListPosts::test_list_posts_include_unpublished_as_guest_returns_403`
|
||||
- **Steps:**
|
||||
1. GET `/api/v1/posts?include_unpublished=true` with guest auth
|
||||
- **Expected:** 403 ForbiddenException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-008: List Published Posts
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestListPublishedPosts::test_list_published_posts_success`
|
||||
- **Expected:** 200 with published posts only, public endpoint
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-009: Search Posts
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestSearchPosts::test_search_posts_success`
|
||||
- **Expected:** 200 with matching posts, public endpoint
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-010: Get Posts by Tag
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestGetPostsByTag::test_get_posts_by_tag_success`
|
||||
- **Expected:** 200 with tagged posts, public endpoint
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-011: Get Posts by Author
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestGetPostsByAuthor::test_get_posts_by_author_success`
|
||||
- **Expected:** 200 with author's posts, public endpoint
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-012: Get Post by ID — Success
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestGetPost::test_get_post_by_id_success`
|
||||
- **Expected:** 200 with post data, public endpoint
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-013: Get Post by ID — Not Found
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestGetPost::test_get_post_by_id_not_found`
|
||||
- **Expected:** 404 NotFoundException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-014: Get Post by Slug — Success
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestGetPostBySlug::test_get_post_by_slug_success`
|
||||
- **Expected:** 200 with post data, public endpoint
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-015: Get Post by Slug — Not Found
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestGetPostBySlug::test_get_post_by_slug_not_found`
|
||||
- **Expected:** 404 NotFoundException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-016: Update Post — Own Post
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestUpdatePost::test_update_own_post_success`
|
||||
- **Expected:** 200 with updated fields
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-017: Update Post — Other User's Post (Forbidden)
|
||||
- **Type:** Policy
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestUpdatePost::test_update_other_user_post_returns_403`
|
||||
- **Expected:** 403 ForbiddenException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-018: Update Post — No Auth
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestUpdatePost::test_update_post_no_auth`
|
||||
- **Expected:** 401 UnauthorizedException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-019: Delete Post — Own Post
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestDeletePost::test_delete_own_post_success`
|
||||
- **Expected:** 204, post no longer accessible
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-020: Delete Post — Other User's Post (Forbidden)
|
||||
- **Type:** Policy
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestDeletePost::test_delete_other_user_post_returns_403`
|
||||
- **Expected:** 403 ForbiddenException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-021: Delete Post — No Auth
|
||||
- **Type:** Negative
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestDeletePost::test_delete_post_no_auth`
|
||||
- **Expected:** 401 UnauthorizedException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-022: Publish Post — Own Post
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestPublishPost::test_publish_own_post_success`
|
||||
- **Expected:** 200 with published=True
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-023: Publish Post — Other User's Post (Forbidden)
|
||||
- **Type:** Policy
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestPublishPost::test_publish_other_user_post_returns_403`
|
||||
- **Expected:** 403 ForbiddenException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-024: Unpublish Post — Own Post
|
||||
- **Type:** Positive
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestUnpublishPost::test_unpublish_own_post_success`
|
||||
- **Expected:** 200 with published=False
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
### TC-API-025: Unpublish Post — Other User's Post (Forbidden)
|
||||
- **Type:** Policy
|
||||
- **Layer:** API
|
||||
- **File:** `api/test_posts.py::TestUnpublishPost::test_unpublish_other_user_post_returns_403`
|
||||
- **Expected:** 403 ForbiddenException
|
||||
- **Last Verified:** 2026-05-10
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| Aspect | Coverage | Notes |
|
||||
|--------|----------|-------|
|
||||
| Create post | Unit + E2E | Both happy path and duplicate slug covered |
|
||||
| Read post (by id/slug) | Unit | E2E implicitly via detail page |
|
||||
| Update post | Unit | No dedicated E2E |
|
||||
| Delete post | Unit + E2E | Own-post and admin-delete covered |
|
||||
| Publish / Unpublish | Unit + E2E | Draft-to-publish flow covered via edit |
|
||||
| List posts (all filters) | Unit | Pagination arguments passed but not edge-case tested |
|
||||
| Search posts | Unit | No E2E search flow |
|
||||
| Create post | Unit + API + E2E | TC-API-001, TC-API-101, TC-API-102 |
|
||||
| Read post (by id/slug) | Unit + API | TC-API-012, TC-API-013, TC-API-014, TC-API-015 |
|
||||
| Update post | Unit + API | TC-API-016, TC-API-017, TC-API-018 |
|
||||
| Delete post | Unit + API + E2E | TC-API-019, TC-API-020, TC-API-021 |
|
||||
| Publish / Unpublish | Unit + API | TC-API-022, TC-API-023, TC-API-024, TC-API-025 |
|
||||
| List posts (all filters) | Unit + API | TC-API-004, TC-API-005, TC-API-006, TC-API-007, TC-API-008 |
|
||||
| Search posts | Unit + API | TC-API-009 |
|
||||
|
||||
## Gaps (Not Yet Covered)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ unit tests for the web layer.
|
||||
| Role definitions | Unit | Enum values and permission mapping fully tested |
|
||||
| Permission checks | Unit | `has_permission` and `get_effective_role` fully tested |
|
||||
| Web-level enforcement | E2E | Visibility and ownership rules tested via browser |
|
||||
| API-level enforcement | — | No API tests exist after refactor |
|
||||
| API-level enforcement | API | All RBAC policies tested via API (TC-API-001 to TC-API-025) |
|
||||
|
||||
## Gaps (Not Yet Covered)
|
||||
|
||||
@@ -165,8 +165,18 @@ unit tests for the web layer.
|
||||
- [x] TC-UNIT-114: Web deps — `can_edit_post` for owner vs non-owner
|
||||
- [x] TC-UNIT-115: Web deps — `can_delete_post` for owner vs non-owner
|
||||
- [x] TC-UNIT-116: Web deps — `can_see_draft` for each role combination
|
||||
- [ ] TC-API-101: API POST create — unauthorized (no token)
|
||||
- [ ] TC-API-102: API POST create — forbidden (guest token)
|
||||
- [ ] TC-API-103: API GET unpublished post — forbidden (other user)
|
||||
- [x] TC-API-101: API POST create — unauthorized (no token)
|
||||
- [x] TC-API-102: API POST create — forbidden (guest token)
|
||||
- [x] TC-API-103: API GET unpublished post — forbidden (other user)
|
||||
- [x] TC-API-104: API list posts include_unpublished — user forbidden
|
||||
- [x] TC-API-105: API list posts include_unpublished — guest forbidden
|
||||
- [x] TC-API-106: API update other user's post — forbidden
|
||||
- [x] TC-API-107: API delete other user's post — forbidden
|
||||
- [x] TC-API-108: API publish other user's post — forbidden
|
||||
- [x] TC-API-109: API unpublish other user's post — forbidden
|
||||
- [x] TC-API-110: API admin can update any post (policy override)
|
||||
- [x] TC-API-111: API admin can delete any post (policy override)
|
||||
- [x] TC-API-112: API admin can publish any post (policy override)
|
||||
- [x] TC-API-113: API admin can unpublish any post (policy override)
|
||||
- [ ] TC-E2E-104: Admin can delete any post via web UI
|
||||
- [ ] TC-E2E-105: User cannot delete other user's post via web UI
|
||||
|
||||
@@ -8,8 +8,8 @@ adding new tests.
|
||||
|
||||
| Feature | Unit | Integration | API | E2E | Priority | Status |
|
||||
|---------|:----:|:-----------:|:---:|:---:|:--------:|:------:|
|
||||
| Post Lifecycle (CRUD, Publish) | 85% | — | — | 70% | P0 | ✅ Active |
|
||||
| RBAC & Access Control | 100% | — | — | 60% | P0 | ✅ Active |
|
||||
| Post Lifecycle (CRUD, Publish) | 85% | — | 90% | 70% | P0 | ✅ Active |
|
||||
| RBAC & Access Control | 100% | — | 90% | 60% | P0 | ✅ Active |
|
||||
| Domain Value Objects | 100% | — | — | — | P0 | ✅ Stable |
|
||||
| Domain Entities | 95% | — | — | — | P0 | ✅ Stable |
|
||||
| Domain Exceptions | 100% | — | — | — | P1 | ✅ Stable |
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +56,7 @@ Legend: ✅ Covered / ⚠️ Partial / ❌ Missing / — Not Applicable
|
||||
## Risk Areas
|
||||
|
||||
1. **No Integration Tests**: SQLAlchemy repository has no integration tests against a real database.
|
||||
2. **Deleted API Tests**: API endpoint tests were removed in a previous refactor and need restoration.
|
||||
2. **Restored API Tests**: API endpoint tests restored in `tests/api/` covering all CRUD, publish/unpublish, and RBAC policies.
|
||||
3. **Web UI Error Handling**: Only covered indirectly via E2E; no dedicated error-scenario E2E tests.
|
||||
4. **Pagination Edge Cases**: Page boundaries, empty pages, and large offsets are not explicitly tested.
|
||||
5. **Edit/Delete Web Flows**: No E2E coverage for editing or deleting posts through the web UI.
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
218
tests/api/conftest.py
Normal file
218
tests/api/conftest.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""API test configuration.
|
||||
|
||||
This module provides fixtures for testing API endpoints with a real database
|
||||
and mock authentication client in development mode.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from app.infrastructure.config import settings
|
||||
from app.infrastructure.database.models import Base
|
||||
from app.main import app_factory
|
||||
|
||||
API_PREFIX = "/api/v1/posts"
|
||||
|
||||
USER_TOKEN = "dev-token-user"
|
||||
USER2_TOKEN = "dev-token-user2"
|
||||
ADMIN_TOKEN = "dev-token-admin"
|
||||
GUEST_TOKEN = "dev-token-guest"
|
||||
|
||||
USER_ID = "dev-user"
|
||||
USER2_ID = "dev-user2"
|
||||
ADMIN_ID = "dev-admin"
|
||||
|
||||
|
||||
def _sync_url(db_url: str) -> str:
|
||||
"""Strip async driver suffix for sync engine.
|
||||
|
||||
Args:
|
||||
db_url: Database URL with async driver.
|
||||
|
||||
Returns:
|
||||
Database URL without async driver suffix.
|
||||
"""
|
||||
return db_url.replace("+aiosqlite", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def _build_alembic_config(db_url: str) -> Config:
|
||||
"""Build alembic config with given database URL.
|
||||
|
||||
Args:
|
||||
db_url: Database URL to use.
|
||||
|
||||
Returns:
|
||||
Alembic Config instance.
|
||||
"""
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
return alembic_cfg
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator[asyncio.AbstractEventLoop]:
|
||||
"""Create event loop for the test session.
|
||||
|
||||
Yields:
|
||||
Event loop instance.
|
||||
"""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database() -> Generator[None]:
|
||||
"""Set up database tables once per test session.
|
||||
|
||||
Drops and recreates all tables, then stamps alembic head
|
||||
so that alembic-aware operations (like E2E tests) can proceed.
|
||||
Uses sync SQLAlchemy engine to avoid async complications.
|
||||
"""
|
||||
db_url = os.environ.get("DB_URL", settings.database_url)
|
||||
sync_engine = create_engine(_sync_url(db_url))
|
||||
Base.metadata.drop_all(sync_engine)
|
||||
Base.metadata.create_all(sync_engine)
|
||||
sync_engine.dispose()
|
||||
|
||||
alembic_cfg = _build_alembic_config(db_url)
|
||||
command.stamp(alembic_cfg, "head")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app() -> TestClient:
|
||||
"""Create FastAPI test application.
|
||||
|
||||
The app uses MockKeycloakClient in dev mode, so auth tokens
|
||||
like 'dev-token-user', 'dev-token-admin', etc. are recognized.
|
||||
Lifespan is not entered (no init_db/close_db) — the
|
||||
setup_database fixture handles table creation instead.
|
||||
|
||||
Returns:
|
||||
Configured TestClient instance.
|
||||
"""
|
||||
return TestClient(app_factory())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app: TestClient) -> TestClient:
|
||||
"""Provide TestClient for API requests.
|
||||
|
||||
The TestClient is created without entering the app lifespan
|
||||
to avoid disposing the module-level database engine. Database
|
||||
lifecycle is managed by setup_database fixture.
|
||||
|
||||
Args:
|
||||
app: Test application fixture.
|
||||
|
||||
Returns:
|
||||
TestClient instance.
|
||||
"""
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_headers() -> dict[str, str]:
|
||||
"""Headers for standard user authentication.
|
||||
|
||||
User has role 'user', user_id 'dev-user'.
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {USER_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_headers() -> dict[str, str]:
|
||||
"""Headers for second user authentication.
|
||||
|
||||
User2 has role 'user', user_id 'dev-user2'. Used for ownership
|
||||
policy tests (editing/deleting another user's post).
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {USER2_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers() -> dict[str, str]:
|
||||
"""Headers for admin authentication.
|
||||
|
||||
Admin has role 'admin', user_id 'dev-admin'. Can edit/delete
|
||||
any post regardless of ownership.
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {ADMIN_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guest_headers() -> dict[str, str]:
|
||||
"""Headers for guest (inactive token).
|
||||
|
||||
Guest token returns inactive TokenInfo, which the API treats
|
||||
as unauthenticated.
|
||||
|
||||
Returns:
|
||||
Headers dict with Authorization bearer token.
|
||||
"""
|
||||
return {"Authorization": f"Bearer {GUEST_TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_payload() -> dict[str, Any]:
|
||||
"""Generate a unique post payload for testing.
|
||||
|
||||
Uses uuid4 for title to ensure unique slug generation
|
||||
across tests. Prevents slug uniqueness constraint errors.
|
||||
|
||||
Returns:
|
||||
Dict with title, content, and tags fields.
|
||||
"""
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
return {
|
||||
"title": f"Test Post {unique_id}",
|
||||
"content": f"This is the content of test post {unique_id}. It has enough length to pass validation.",
|
||||
"tags": ["test", f"tag-{unique_id}"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def created_post(
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a post via API and return its response data.
|
||||
|
||||
Used as test data for read, update, delete, and publish tests.
|
||||
Created by the standard user ('dev-user').
|
||||
|
||||
Args:
|
||||
client: TestClient fixture.
|
||||
user_headers: User auth headers.
|
||||
post_payload: Post creation payload.
|
||||
|
||||
Returns:
|
||||
Post response data dict.
|
||||
"""
|
||||
response = client.post(
|
||||
f"{API_PREFIX}",
|
||||
json=post_payload,
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 201, f"Failed to create test post: {response.text}"
|
||||
return cast(dict[str, Any], response.json())
|
||||
239
tests/api/test_comments.py
Normal file
239
tests/api/test_comments.py
Normal 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
94
tests/api/test_likes.py
Normal 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"
|
||||
788
tests/api/test_posts.py
Normal file
788
tests/api/test_posts.py
Normal file
@@ -0,0 +1,788 @@
|
||||
"""API tests for blog post CRUD and publish operations.
|
||||
|
||||
This module tests all 12 blog post API endpoints covering create, read,
|
||||
update, delete, publish, and unpublish operations with full authorization
|
||||
policy coverage across guest, user, and admin roles.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.api.conftest import API_PREFIX, USER_ID
|
||||
|
||||
|
||||
class TestCreatePost:
|
||||
"""Tests for POST /api/v1/posts — create a new blog post."""
|
||||
|
||||
def test_create_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a post returns 201 with correct fields.
|
||||
|
||||
TC-API-001: Positive — create post as authenticated user.
|
||||
"""
|
||||
response = client.post(API_PREFIX, json=post_payload, headers=user_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["title"] == post_payload["title"]
|
||||
assert data["content"] == post_payload["content"]
|
||||
assert data["author_id"] == USER_ID
|
||||
assert data["published"] is False
|
||||
assert data["tags"] == post_payload["tags"]
|
||||
assert UUID(data["id"])
|
||||
assert data["slug"]
|
||||
assert data["created_at"]
|
||||
assert data["updated_at"]
|
||||
|
||||
def test_create_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a post without auth returns 401.
|
||||
|
||||
TC-API-101: Negative — no authorization header.
|
||||
"""
|
||||
response = client.post(API_PREFIX, json=post_payload)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_create_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a post with inactive token returns 401.
|
||||
|
||||
TC-API-102: Negative — guest/inactive token.
|
||||
"""
|
||||
response = client.post(API_PREFIX, json=post_payload, headers=guest_headers)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_create_post_invalid_payload(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test creating a post with too-short title returns 422."""
|
||||
response = client.post(
|
||||
API_PREFIX,
|
||||
json={"title": "ab", "content": "valid content here"},
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
class TestListPosts:
|
||||
"""Tests for GET /api/v1/posts — list posts with filters."""
|
||||
|
||||
def test_list_posts_default(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test default listing returns published posts.
|
||||
|
||||
TC-API-004: Positive — default listing without auth.
|
||||
"""
|
||||
response = client.get(API_PREFIX)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert isinstance(data["items"], list)
|
||||
assert isinstance(data["total"], int)
|
||||
|
||||
def test_list_posts_pagination(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test listing posts with limit and offset query params."""
|
||||
for i in range(3):
|
||||
payload = {
|
||||
"title": f"Pagination Post {i}",
|
||||
"content": f"Content for pagination test post {i}. Enough characters here.",
|
||||
"tags": [],
|
||||
}
|
||||
client.post(API_PREFIX, json=payload, headers=user_headers)
|
||||
|
||||
response = client.get(f"{API_PREFIX}?limit=2&offset=0")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) <= 2
|
||||
|
||||
def test_list_posts_include_unpublished_as_admin(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test admin can list unpublished posts.
|
||||
|
||||
TC-API-005: Positive — admin can include unpublished.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
|
||||
def test_list_posts_include_unpublished_as_user_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test user gets 403 when requesting unpublished posts.
|
||||
|
||||
TC-API-006: Policy — user cannot list unpublished.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true", headers=user_headers)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_list_posts_include_unpublished_as_guest_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test guest gets 403 when requesting unpublished posts.
|
||||
|
||||
TC-API-007: Policy — guest cannot list unpublished.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true", headers=guest_headers)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_list_posts_without_auth_include_unpublished_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test anonymous gets 403 when requesting unpublished posts."""
|
||||
response = client.get(f"{API_PREFIX}?include_unpublished=true")
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
|
||||
class TestListPublishedPosts:
|
||||
"""Tests for GET /api/v1/posts/published — list published posts."""
|
||||
|
||||
def test_list_published_posts_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test listing published posts returns 200 with items.
|
||||
|
||||
TC-API-008: Positive — public endpoint, no auth needed.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}/published")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
|
||||
class TestSearchPosts:
|
||||
"""Tests for GET /api/v1/posts/search — search posts."""
|
||||
|
||||
def test_search_posts_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test searching posts returns matching results.
|
||||
|
||||
TC-API-009: Positive — public endpoint, no auth needed.
|
||||
"""
|
||||
payload = {
|
||||
"title": "Searchable Unique Title",
|
||||
"content": "This content contains a very special search keyword. Enough length.",
|
||||
"tags": [],
|
||||
}
|
||||
client.post(API_PREFIX, json=payload, headers=user_headers)
|
||||
|
||||
response = client.get(f"{API_PREFIX}/search?query=Searchable")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
assert any("Searchable" in item["title"] for item in data["items"])
|
||||
|
||||
def test_search_posts_no_results(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test searching with no matches returns empty list."""
|
||||
response = client.get(f"{API_PREFIX}/search?query=xyznonexistent12345")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_search_posts_public(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test search is accessible without authentication."""
|
||||
response = client.get(f"{API_PREFIX}/search?query=test")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGetPostsByTag:
|
||||
"""Tests for GET /api/v1/posts/by-tag/{tag} — posts by tag."""
|
||||
|
||||
def test_get_posts_by_tag_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test filtering posts by tag returns matching items.
|
||||
|
||||
TC-API-010: Positive — public endpoint.
|
||||
"""
|
||||
payload = {
|
||||
"title": "Tagged Test Post",
|
||||
"content": "Post with a specific tag for testing. Enough characters here.",
|
||||
"tags": ["unique-test-tag-xyz"],
|
||||
}
|
||||
client.post(API_PREFIX, json=payload, headers=user_headers)
|
||||
|
||||
response = client.get(f"{API_PREFIX}/by-tag/unique-test-tag-xyz")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
def test_get_posts_by_tag_no_results(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test filtering by nonexistent tag returns empty list."""
|
||||
response = client.get(f"{API_PREFIX}/by-tag/nonexistent-tag-xyz-123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
|
||||
|
||||
class TestGetPostsByAuthor:
|
||||
"""Tests for GET /api/v1/posts/by-author/{author_id} — posts by author."""
|
||||
|
||||
def test_get_posts_by_author_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test filtering posts by author returns their posts.
|
||||
|
||||
TC-API-011: Positive — public endpoint.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}/by-author/{USER_ID}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
assert all(item["author_id"] == USER_ID for item in data["items"])
|
||||
|
||||
def test_get_posts_by_author_no_results(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test filtering by nonexistent author returns empty list."""
|
||||
response = client.get(f"{API_PREFIX}/by-author/nonexistent-author-123")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
|
||||
|
||||
class TestGetPost:
|
||||
"""Tests for GET /api/v1/posts/{post_id} — get post by ID."""
|
||||
|
||||
def test_get_post_by_id_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test getting a post by ID returns 200 with correct data.
|
||||
|
||||
TC-API-012: Positive — public endpoint.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == post_id
|
||||
assert data["title"] == created_post["title"]
|
||||
assert data["author_id"] == USER_ID
|
||||
|
||||
def test_get_post_by_id_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test getting a nonexistent post returns 404.
|
||||
|
||||
TC-API-013: Negative — nonexistent post ID.
|
||||
"""
|
||||
fake_id = "00000000-0000-0000-0000-000000000000"
|
||||
response = client.get(f"{API_PREFIX}/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
def test_get_unpublished_post_by_id(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test that unpublished posts are accessible by ID.
|
||||
|
||||
The current implementation does not filter by published status
|
||||
for individual post retrieval.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
assert created_post["published"] is False
|
||||
response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is False
|
||||
|
||||
|
||||
class TestGetPostBySlug:
|
||||
"""Tests for GET /api/v1/posts/slug/{slug} — get post by slug."""
|
||||
|
||||
def test_get_post_by_slug_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test getting a post by slug returns 200 with correct data.
|
||||
|
||||
TC-API-014: Positive — public endpoint.
|
||||
"""
|
||||
slug = created_post["slug"]
|
||||
response = client.get(f"{API_PREFIX}/slug/{slug}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["slug"] == slug
|
||||
assert data["id"] == created_post["id"]
|
||||
|
||||
def test_get_post_by_slug_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""Test getting a post by nonexistent slug returns 404.
|
||||
|
||||
TC-API-015: Negative — nonexistent slug.
|
||||
"""
|
||||
response = client.get(f"{API_PREFIX}/slug/nonexistent-slug-xyz-123")
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
|
||||
class TestUpdatePost:
|
||||
"""Tests for PATCH /api/v1/posts/{post_id} — update a post."""
|
||||
|
||||
def test_update_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating own post returns 200 with updated fields.
|
||||
|
||||
TC-API-016: Positive — owner updates own post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
update_data = {"title": "Updated Title For Testing"}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == "Updated Title For Testing"
|
||||
assert data["id"] == post_id
|
||||
assert data["updated_at"] != created_post["updated_at"]
|
||||
|
||||
def test_update_own_post_all_fields(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating all fields of own post."""
|
||||
post_id = created_post["id"]
|
||||
update_data = {
|
||||
"title": "Completely New Title Here",
|
||||
"content": "Updated content with sufficient length for validation check.",
|
||||
"tags": ["new-tag"],
|
||||
}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == update_data["title"]
|
||||
assert data["content"] == update_data["content"]
|
||||
assert data["tags"] == update_data["tags"]
|
||||
|
||||
def test_update_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating another user's post returns 403.
|
||||
|
||||
TC-API-017: Policy — user2 cannot update user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
update_data = {"title": "Unauthorized Update Attempt"}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=user2_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_update_post_admin_can_update_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can update any user's post."""
|
||||
post_id = created_post["id"]
|
||||
update_data = {"title": "Admin Updated This Post Title"}
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json=update_data,
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["title"] == "Admin Updated This Post Title"
|
||||
|
||||
def test_update_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating a post without auth returns 401.
|
||||
|
||||
TC-API-018: Negative — no authorization header.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json={"title": "No Auth Update Attempt"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_update_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test updating a post with guest token returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.patch(
|
||||
f"{API_PREFIX}/{post_id}",
|
||||
json={"title": "Guest Update Attempt"},
|
||||
headers=guest_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
|
||||
class TestDeletePost:
|
||||
"""Tests for DELETE /api/v1/posts/{post_id} — delete a post."""
|
||||
|
||||
def test_delete_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
post_payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting own post returns 204.
|
||||
|
||||
TC-API-019: Positive — owner deletes own post.
|
||||
"""
|
||||
create_resp = client.post(API_PREFIX, json=post_payload, headers=user_headers)
|
||||
post_id = create_resp.json()["id"]
|
||||
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=user_headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
get_response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_delete_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting another user's post returns 403.
|
||||
|
||||
TC-API-020: Policy — user2 cannot delete user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=user2_headers)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_delete_post_admin_can_delete_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can delete any user's post."""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=admin_headers)
|
||||
assert response.status_code == 204
|
||||
|
||||
get_response = client.get(f"{API_PREFIX}/{post_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
def test_delete_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting a post without auth returns 401.
|
||||
|
||||
TC-API-021: Negative — no authorization header.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}")
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_delete_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test deleting a post with guest token returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.delete(f"{API_PREFIX}/{post_id}", headers=guest_headers)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
|
||||
class TestPublishPost:
|
||||
"""Tests for POST /api/v1/posts/{post_id}/publish — publish a post."""
|
||||
|
||||
def test_publish_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing own post returns 200 with published=True.
|
||||
|
||||
TC-API-022: Positive — owner publishes own post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
assert created_post["published"] is False
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is True
|
||||
assert data["id"] == post_id
|
||||
|
||||
def test_publish_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing another user's post returns 403.
|
||||
|
||||
TC-API-023: Policy — user2 cannot publish user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=user2_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_publish_admin_can_publish_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can publish any user's post."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is True
|
||||
|
||||
def test_publish_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing a post without auth returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(f"{API_PREFIX}/{post_id}/publish")
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_publish_post_guest_token(
|
||||
self,
|
||||
client: TestClient,
|
||||
guest_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test publishing a post with guest token returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/publish",
|
||||
headers=guest_headers,
|
||||
)
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
|
||||
def test_publish_post_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test publishing a nonexistent post returns 404."""
|
||||
fake_id = "00000000-0000-0000-0000-000000000000"
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{fake_id}/publish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
|
||||
class TestUnpublishPost:
|
||||
"""Tests for POST /api/v1/posts/{post_id}/unpublish — unpublish a post."""
|
||||
|
||||
def test_unpublish_own_post_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test unpublishing own post returns 200 with published=False.
|
||||
|
||||
TC-API-024: Positive — owner unpublishes own post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
|
||||
client.post(f"{API_PREFIX}/{post_id}/publish", headers=user_headers)
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/unpublish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is False
|
||||
assert data["id"] == post_id
|
||||
|
||||
def test_unpublish_other_user_post_returns_403(
|
||||
self,
|
||||
client: TestClient,
|
||||
user2_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test unpublishing another user's post returns 403.
|
||||
|
||||
TC-API-025: Policy — user2 cannot unpublish user's post.
|
||||
"""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/unpublish",
|
||||
headers=user2_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
error = response.json()
|
||||
assert error["error"] == "ForbiddenException"
|
||||
|
||||
def test_unpublish_admin_can_unpublish_any(
|
||||
self,
|
||||
client: TestClient,
|
||||
admin_headers: dict[str, str],
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test admin can unpublish any user's post."""
|
||||
post_id = created_post["id"]
|
||||
|
||||
client.post(f"{API_PREFIX}/{post_id}/publish", headers=admin_headers)
|
||||
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{post_id}/unpublish",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["published"] is False
|
||||
|
||||
def test_unpublish_post_not_found(
|
||||
self,
|
||||
client: TestClient,
|
||||
user_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Test unpublishing a nonexistent post returns 404."""
|
||||
fake_id = "00000000-0000-0000-0000-000000000000"
|
||||
response = client.post(
|
||||
f"{API_PREFIX}/{fake_id}/unpublish",
|
||||
headers=user_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
error = response.json()
|
||||
assert error["error"] == "NotFoundException"
|
||||
|
||||
def test_unpublish_post_no_auth(
|
||||
self,
|
||||
client: TestClient,
|
||||
created_post: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test unpublishing a post without auth returns 401."""
|
||||
post_id = created_post["id"]
|
||||
response = client.post(f"{API_PREFIX}/{post_id}/unpublish")
|
||||
assert response.status_code == 401
|
||||
error = response.json()
|
||||
assert error["error"] == "UnauthorizedException"
|
||||
@@ -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
190
tests/e2e/test_comments.py
Normal 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
183
tests/e2e/test_likes.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""End-to-end tests for post likes via web UI.
|
||||
|
||||
Tests the like/unlike toggle flow, multi-user like isolation,
|
||||
and guest authentication redirect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from pytfm.generators import PostDataGenerator
|
||||
|
||||
from tests.e2e.pages import HomePage, PostDetailPage, PostFormPage
|
||||
|
||||
|
||||
def _unique_title(base: str) -> str:
|
||||
"""Append a short UUID to a title to avoid slug collisions."""
|
||||
return f"{base} {uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_like_unlike_flow(
|
||||
user_page: Page,
|
||||
base_url: str,
|
||||
) -> None:
|
||||
"""Test like/unlike toggle through the web UI.
|
||||
|
||||
Steps:
|
||||
1. Create and publish a post.
|
||||
2. Verify initial like count is 0.
|
||||
3. Click the like button.
|
||||
4. Verify like count becomes 1.
|
||||
5. Click the like button again.
|
||||
6. Verify like count returns to 0.
|
||||
|
||||
Args:
|
||||
user_page: Playwright page authenticated as regular user.
|
||||
base_url: Application base URL.
|
||||
"""
|
||||
generator = PostDataGenerator()
|
||||
post_data = generator.generate_post()
|
||||
title = _unique_title(str(post_data["title"]))
|
||||
content = str(post_data["content"])
|
||||
tags = ", ".join(post_data["tags"])
|
||||
|
||||
home = HomePage(user_page, base_url)
|
||||
home.open()
|
||||
home.create_post()
|
||||
|
||||
form = PostFormPage(user_page, base_url)
|
||||
form.fill_form(title, content, tags)
|
||||
with user_page.expect_navigation(wait_until="networkidle"):
|
||||
form.publish()
|
||||
current_url = user_page.url
|
||||
assert "new" not in current_url, f"Still on form page: {current_url}"
|
||||
slug = current_url.rstrip("/").split("/")[-1]
|
||||
|
||||
user_page.wait_for_selector('[data-testid="post-detail-title"]')
|
||||
detail = PostDetailPage(user_page, base_url, slug)
|
||||
assert detail.get_title() == title
|
||||
|
||||
# Initial like count should be 0 for a new post
|
||||
assert detail.get_like_count() == 0
|
||||
|
||||
# Like the post
|
||||
detail.click_like()
|
||||
expect(user_page.locator("#like-count")).to_have_text("1", timeout=15000)
|
||||
|
||||
# Unlike the post
|
||||
detail.click_like()
|
||||
expect(user_page.locator("#like-count")).to_have_text("0", timeout=15000)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_multiple_users_can_like(
|
||||
user_page: Page,
|
||||
user2_page: Page,
|
||||
base_url: str,
|
||||
) -> None:
|
||||
"""Test that two users can independently like the same post.
|
||||
|
||||
Steps:
|
||||
1. User creates and publishes a post.
|
||||
2. User likes the post (count becomes 1).
|
||||
3. User2 opens the same post (sees count=1).
|
||||
4. User2 clicks like (count becomes 2).
|
||||
|
||||
Args:
|
||||
user_page: Playwright page authenticated as first regular user.
|
||||
user2_page: Playwright page authenticated as second regular user.
|
||||
base_url: Application base URL.
|
||||
"""
|
||||
generator = PostDataGenerator()
|
||||
post_data = generator.generate_post()
|
||||
title = _unique_title(str(post_data["title"]))
|
||||
content = str(post_data["content"])
|
||||
tags = ", ".join(post_data["tags"])
|
||||
|
||||
home = HomePage(user_page, base_url)
|
||||
home.open()
|
||||
home.create_post()
|
||||
|
||||
form = PostFormPage(user_page, base_url)
|
||||
form.fill_form(title, content, tags)
|
||||
with user_page.expect_navigation(wait_until="networkidle"):
|
||||
form.publish()
|
||||
current_url = user_page.url
|
||||
assert "new" not in current_url, f"Still on form page: {current_url}"
|
||||
slug = current_url.rstrip("/").split("/")[-1]
|
||||
|
||||
user_page.wait_for_selector('[data-testid="post-detail-title"]')
|
||||
detail = PostDetailPage(user_page, base_url, slug)
|
||||
assert detail.get_title() == title
|
||||
|
||||
# User likes the post
|
||||
assert detail.get_like_count() == 0
|
||||
detail.click_like()
|
||||
expect(user_page.locator("#like-count")).to_have_text("1", timeout=15000)
|
||||
|
||||
# Verify like_count persists after page reload
|
||||
user_page.reload(wait_until="networkidle")
|
||||
user_page.wait_for_selector('[data-testid="post-detail-title"]')
|
||||
assert detail.get_like_count() == 1
|
||||
|
||||
# User2 opens same post and likes
|
||||
user2_detail = PostDetailPage(user2_page, base_url, slug)
|
||||
user2_detail.open()
|
||||
user2_page.wait_for_selector('[data-testid="post-detail-title"]')
|
||||
assert user2_detail.get_like_count() == 1
|
||||
|
||||
user2_detail.click_like()
|
||||
expect(user2_page.locator("#like-count")).to_have_text("2", timeout=15000)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_guest_redirect_on_like(
|
||||
user_page: Page,
|
||||
guest_page: Page,
|
||||
base_url: str,
|
||||
) -> None:
|
||||
"""Test that unauthenticated guests are redirected to login when liking.
|
||||
|
||||
Steps:
|
||||
1. User creates and publishes a post.
|
||||
2. Guest opens the post detail page.
|
||||
3. Guest clicks the like button.
|
||||
4. Guest is redirected to the development login page.
|
||||
|
||||
Args:
|
||||
user_page: Playwright page authenticated as regular user.
|
||||
guest_page: Unauthenticated Playwright page.
|
||||
base_url: Application base URL.
|
||||
"""
|
||||
generator = PostDataGenerator()
|
||||
post_data = generator.generate_post()
|
||||
title = _unique_title(str(post_data["title"]))
|
||||
content = str(post_data["content"])
|
||||
tags = ", ".join(post_data["tags"])
|
||||
|
||||
home = HomePage(user_page, base_url)
|
||||
home.open()
|
||||
home.create_post()
|
||||
|
||||
form = PostFormPage(user_page, base_url)
|
||||
form.fill_form(title, content, tags)
|
||||
with user_page.expect_navigation(wait_until="networkidle"):
|
||||
form.publish()
|
||||
current_url = user_page.url
|
||||
assert "new" not in current_url, f"Still on form page: {current_url}"
|
||||
slug = current_url.rstrip("/").split("/")[-1]
|
||||
|
||||
# Guest opens the published post
|
||||
guest_detail = PostDetailPage(guest_page, base_url, slug)
|
||||
guest_detail.open()
|
||||
guest_page.wait_for_selector('[data-testid="post-detail-title"]')
|
||||
|
||||
# Guest clicks like -> should be redirected to dev login page
|
||||
with guest_page.expect_navigation(wait_until="networkidle", timeout=15000):
|
||||
guest_detail.click_like()
|
||||
|
||||
assert "dev-login" in guest_page.url
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
55
tests/integration/conftest.py
Normal file
55
tests/integration/conftest.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from app.infrastructure.config import settings
|
||||
from app.infrastructure.database.models import Base
|
||||
|
||||
|
||||
def _sync_url(db_url: str) -> str:
|
||||
return db_url.replace("+aiosqlite", "").replace("+asyncpg", "")
|
||||
|
||||
|
||||
def _build_alembic_config(db_url: str) -> Config:
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
return alembic_cfg
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator[asyncio.AbstractEventLoop]:
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_database() -> Generator[None]:
|
||||
db_url = os.environ.get("DB_URL", settings.database_url)
|
||||
sync_engine = create_engine(_sync_url(db_url))
|
||||
Base.metadata.drop_all(sync_engine)
|
||||
Base.metadata.create_all(sync_engine)
|
||||
sync_engine.dispose()
|
||||
|
||||
alembic_cfg = _build_alembic_config(db_url)
|
||||
command.stamp(alembic_cfg, "head")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession]:
|
||||
db_url = os.environ.get("DB_URL", settings.database_url)
|
||||
test_engine = create_async_engine(db_url)
|
||||
session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
|
||||
await test_engine.dispose()
|
||||
44
tests/integration/test_migrations.py
Normal file
44
tests/integration/test_migrations.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
|
||||
class TestMigrations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_migration_creates_post_table(self, db_session: AsyncSession) -> None:
|
||||
engine = cast(AsyncEngine, db_session.bind)
|
||||
async with engine.connect() as conn:
|
||||
tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names())
|
||||
assert "posts" in tables
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posts_table_has_expected_columns(self, db_session: AsyncSession) -> None:
|
||||
engine = cast(AsyncEngine, db_session.bind)
|
||||
async with engine.connect() as conn:
|
||||
columns = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_columns("posts"))
|
||||
column_names = [col["name"] for col in columns]
|
||||
expected = [
|
||||
"id",
|
||||
"title",
|
||||
"content",
|
||||
"slug",
|
||||
"author_id",
|
||||
"published",
|
||||
"tags",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
for col in expected:
|
||||
assert col in column_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posts_table_has_indexes(self, db_session: AsyncSession) -> None:
|
||||
engine = cast(AsyncEngine, db_session.bind)
|
||||
async with engine.connect() as conn:
|
||||
indexes = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_indexes("posts"))
|
||||
index_names = [idx["name"] for idx in indexes]
|
||||
assert "ix_posts_slug" in index_names
|
||||
assert "ix_posts_author_id" in index_names
|
||||
assert "ix_posts_published" in index_names
|
||||
139
tests/integration/test_repositories.py
Normal file
139
tests/integration/test_repositories.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities import Post
|
||||
from app.domain.value_objects import Title
|
||||
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
|
||||
|
||||
|
||||
class TestSQLAlchemyPostRepository:
|
||||
@pytest.fixture
|
||||
async def repo(self, db_session: AsyncSession) -> SQLAlchemyPostRepository:
|
||||
return SQLAlchemyPostRepository(db_session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_creates_post(
|
||||
self, repo: SQLAlchemyPostRepository, db_session: AsyncSession
|
||||
) -> None:
|
||||
post = Post.create(
|
||||
title_str="Test Title",
|
||||
content_str="Test content with enough characters",
|
||||
author_id="user-1",
|
||||
tags=["test"],
|
||||
)
|
||||
await repo.add(post)
|
||||
await db_session.commit()
|
||||
|
||||
result = await repo.get_by_id(post.id)
|
||||
assert result is not None
|
||||
assert result.title.value == "Test Title"
|
||||
assert result.slug.value == "test-title"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_returns_post(
|
||||
self, repo: SQLAlchemyPostRepository, db_session: AsyncSession
|
||||
) -> None:
|
||||
post = Post.create(
|
||||
title_str="Find Me",
|
||||
content_str="Content with enough characters for validation",
|
||||
author_id="user-1",
|
||||
)
|
||||
await repo.add(post)
|
||||
await db_session.commit()
|
||||
|
||||
result = await repo.get_by_id(post.id)
|
||||
assert result is not None
|
||||
assert result.id == post.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id_returns_none_for_missing(self, repo: SQLAlchemyPostRepository) -> None:
|
||||
result = await repo.get_by_id(uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_slug_returns_post(
|
||||
self, repo: SQLAlchemyPostRepository, db_session: AsyncSession
|
||||
) -> None:
|
||||
post = Post.create(
|
||||
title_str="Slug Test",
|
||||
content_str="Content with enough characters for validation",
|
||||
author_id="user-1",
|
||||
)
|
||||
await repo.add(post)
|
||||
await db_session.commit()
|
||||
|
||||
result = await repo.get_by_slug(post.slug.value)
|
||||
assert result is not None
|
||||
assert result.id == post.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_with_pagination(
|
||||
self, repo: SQLAlchemyPostRepository, db_session: AsyncSession
|
||||
) -> None:
|
||||
for i in range(5):
|
||||
post = Post.create(
|
||||
title_str=f"Post {i}",
|
||||
content_str="Content with enough characters for validation",
|
||||
author_id="user-1",
|
||||
)
|
||||
await repo.add(post)
|
||||
await db_session.commit()
|
||||
|
||||
result = await repo.get_published(limit=3, offset=0)
|
||||
assert len(result) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_modifies_post(
|
||||
self, repo: SQLAlchemyPostRepository, db_session: AsyncSession
|
||||
) -> None:
|
||||
post = Post.create(
|
||||
title_str="Original",
|
||||
content_str="Content with enough characters for validation",
|
||||
author_id="user-1",
|
||||
)
|
||||
await repo.add(post)
|
||||
await db_session.commit()
|
||||
|
||||
post.update_title(Title("Updated"))
|
||||
await repo.update(post)
|
||||
await db_session.commit()
|
||||
|
||||
result = await repo.get_by_id(post.id)
|
||||
assert result is not None
|
||||
assert result.title.value == "Updated"
|
||||
assert result.slug.value == "updated"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_post(
|
||||
self, repo: SQLAlchemyPostRepository, db_session: AsyncSession
|
||||
) -> None:
|
||||
post = Post.create(
|
||||
title_str="To Delete",
|
||||
content_str="Content with enough characters for validation",
|
||||
author_id="user-1",
|
||||
)
|
||||
await repo.add(post)
|
||||
await db_session.commit()
|
||||
|
||||
await repo.delete(post.id)
|
||||
await db_session.commit()
|
||||
|
||||
result = await repo.get_by_id(post.id)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slug_exists_checks_uniqueness(
|
||||
self, repo: SQLAlchemyPostRepository, db_session: AsyncSession
|
||||
) -> None:
|
||||
post = Post.create(
|
||||
title_str="Unique Slug",
|
||||
content_str="Content with enough characters for validation",
|
||||
author_id="user-1",
|
||||
)
|
||||
await repo.add(post)
|
||||
await db_session.commit()
|
||||
|
||||
assert await repo.slug_exists(post.slug.value) is True
|
||||
assert await repo.slug_exists("nonexistent-slug") is False
|
||||
128
tests/unit/application/test_create_comment.py
Normal file
128
tests/unit/application/test_create_comment.py
Normal 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()
|
||||
81
tests/unit/application/test_delete_comment.py
Normal file
81
tests/unit/application/test_delete_comment.py
Normal 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()
|
||||
59
tests/unit/application/test_list_comments.py
Normal file
59
tests/unit/application/test_list_comments.py
Normal 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)
|
||||
119
tests/unit/application/test_toggle_comment_like.py
Normal file
119
tests/unit/application/test_toggle_comment_like.py
Normal 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()
|
||||
170
tests/unit/application/test_toggle_like.py
Normal file
170
tests/unit/application/test_toggle_like.py
Normal 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
|
||||
98
tests/unit/domain/test_comment_entity.py
Normal file
98
tests/unit/domain/test_comment_entity.py
Normal 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
|
||||
50
tests/unit/domain/test_comment_like_entity.py
Normal file
50
tests/unit/domain/test_comment_like_entity.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
50
tests/unit/domain/test_like_entity.py
Normal file
50
tests/unit/domain/test_like_entity.py
Normal 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
|
||||
18
tests/unit/infrastructure/database/test_connection.py
Normal file
18
tests/unit/infrastructure/database/test_connection.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infrastructure.database.connection import init_db
|
||||
|
||||
|
||||
class TestInitDB:
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_db_skipped_when_skip_env_set(self) -> None:
|
||||
with patch.dict(os.environ, {"SKIP_INIT_DB": "1"}):
|
||||
await init_db()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_db_runs_when_no_env(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
await init_db()
|
||||
196
tests/unit/infrastructure/test_i18n.py
Normal file
196
tests/unit/infrastructure/test_i18n.py
Normal 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
|
||||
Reference in New Issue
Block a user