feat: phase 4 admin CMS — dashboard, editor, media, CSRF
Head Hen CMS end-to-end: dashboard lists all posts (drafts + published), Markdown editor with live preview + drag-drop image upload, Pillow media pipeline re-encoding every upload to JPEG, post CRUD + publish toggle + hard delete, About page edit, and double-submit CSRF cookie enforced on every admin mutating endpoint (Phase 3's TODO markers resolved). Slug auto-generated on create and server-locked once a post has been published. Unpublish preserves `published_at` so re-publish keeps original date ordering. Every admin write invalidates the read-side Post/Page TTL caches and records an `auth_events` audit row. CSRF middleware is narrow by design — issues/refreshes the `cb_csrf` cookie only on `GET /admin*`, and mutating endpoints opt in via `require_csrf_form` or `require_csrf_header` Depends. Public routes, healthz, and pre-auth login stay untouched. 64 new tests cover slugs, CSRF, media, admin posts/pages services, and end-to-end CMS routes. Tests never mock the DB — real temp SQLite files per the CLAUDE.md mandate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
242
tests/test_admin_posts_service.py
Normal file
242
tests/test_admin_posts_service.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Tests for :class:`app.services.admin_posts.AdminPostsService`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.entities import PostStatus
|
||||
from app.models.seed import run_seed
|
||||
from app.services.admin_posts import AdminPostsService
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
# Dedicated function-scoped DB per test so publish / delete side
|
||||
# effects don't leak between tests.
|
||||
@pytest.fixture
|
||||
def engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return a migrated + seeded engine in an isolated file."""
|
||||
db_path = tmp_path / "phase4_posts.db"
|
||||
eng = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(eng)
|
||||
run_seed(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(engine: Engine) -> AdminPostsService:
|
||||
"""Return a fully-wired :class:`AdminPostsService`."""
|
||||
md = MarkdownService()
|
||||
posts = PostService(engine)
|
||||
pages = PageService(engine)
|
||||
audit = AuditService(engine)
|
||||
return AdminPostsService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
post_service=posts,
|
||||
page_service=pages,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_draft_post(service: AdminPostsService) -> None:
|
||||
"""Create generates a slug, renders HTML, and leaves ``published_at`` NULL."""
|
||||
post = service.create(
|
||||
title="My First Post",
|
||||
body_md="Hello **world**.",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert post.id > 0
|
||||
assert post.slug == "my-first-post"
|
||||
assert post.status is PostStatus.DRAFT
|
||||
assert post.published_at is None
|
||||
# Markdown pipeline ran and wrote sanitized HTML.
|
||||
assert "<strong>world</strong>" in post.body_html_cached
|
||||
|
||||
|
||||
def test_create_publish_stamps_published_at(service: AdminPostsService) -> None:
|
||||
"""``status=published`` sets ``published_at`` to the creation time."""
|
||||
post = service.create(
|
||||
title="Announcement",
|
||||
body_md="body",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
assert post.status is PostStatus.PUBLISHED
|
||||
assert post.published_at is not None
|
||||
|
||||
|
||||
def test_create_collision_yields_suffixed_slug(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Second post with the same title slugs as ``foo-2``."""
|
||||
a = service.create(
|
||||
title="Chicks Day",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
b = service.create(
|
||||
title="Chicks Day",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert a.slug == "chicks-day"
|
||||
assert b.slug == "chicks-day-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_update_changes_title_body_but_not_slug(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Updates regenerate HTML and bump updated_at; slug is locked."""
|
||||
post = service.create(
|
||||
title="Original",
|
||||
body_md="before",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
updated = service.update(
|
||||
post.id,
|
||||
title="Original Retitled",
|
||||
body_md="after",
|
||||
actor_user_id=1,
|
||||
)
|
||||
assert updated is not None
|
||||
# Slug is preserved — not regenerated even on title change.
|
||||
assert updated.slug == post.slug
|
||||
assert updated.title == "Original Retitled"
|
||||
assert "after" in updated.body_html_cached
|
||||
assert "before" not in updated.body_html_cached
|
||||
|
||||
|
||||
def test_update_unknown_post_returns_none(service: AdminPostsService) -> None:
|
||||
"""An update on a missing post id returns ``None``."""
|
||||
assert service.update(99999, title="x", body_md="y", actor_user_id=1) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# toggle_publish
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_toggle_publish_draft_to_published(service: AdminPostsService) -> None:
|
||||
"""Draft → published stamps ``published_at`` once."""
|
||||
post = service.create(
|
||||
title="Togglable",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
published = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert published is not None
|
||||
assert published.status is PostStatus.PUBLISHED
|
||||
first_published_at = published.published_at
|
||||
assert first_published_at is not None
|
||||
|
||||
|
||||
def test_toggle_publish_preserves_original_published_at(
|
||||
service: AdminPostsService,
|
||||
) -> None:
|
||||
"""Unpublish → re-publish keeps the original ``published_at``."""
|
||||
post = service.create(
|
||||
title="Preserve",
|
||||
body_md="x",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
original_published_at = post.published_at
|
||||
assert original_published_at is not None
|
||||
|
||||
unpublished = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert unpublished is not None
|
||||
assert unpublished.status is PostStatus.DRAFT
|
||||
# published_at preserved on unpublish per Phase 4 brief.
|
||||
assert unpublished.published_at == original_published_at
|
||||
|
||||
republished = service.toggle_publish(post.id, actor_user_id=1)
|
||||
assert republished is not None
|
||||
assert republished.status is PostStatus.PUBLISHED
|
||||
# published_at preserved across the re-publish cycle.
|
||||
assert republished.published_at == original_published_at
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_delete_removes_row(service: AdminPostsService, engine: Engine) -> None:
|
||||
"""Delete really removes the row — no soft-delete column exists."""
|
||||
post = service.create(
|
||||
title="DeleteMe",
|
||||
body_md="x",
|
||||
status=PostStatus.DRAFT,
|
||||
author_id=1,
|
||||
)
|
||||
assert service.delete(post.id, actor_user_id=1) is True
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM posts WHERE id = :id"),
|
||||
{"id": post.id},
|
||||
).first()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_delete_unknown_returns_false(service: AdminPostsService) -> None:
|
||||
"""Deleting a nonexistent post id returns False without raising."""
|
||||
assert service.delete(99999, actor_user_id=1) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cache invalidation
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_create_invalidates_public_post_cache(
|
||||
engine: Engine,
|
||||
) -> None:
|
||||
"""Creating a post clears the :class:`PostService` cache.
|
||||
|
||||
We prime the cache by calling ``list_published``, then use the
|
||||
admin service to insert a published post, and confirm the next
|
||||
``list_published`` call returns a freshly-built list (identity
|
||||
different, content includes the new slug).
|
||||
"""
|
||||
md = MarkdownService()
|
||||
posts = PostService(engine)
|
||||
pages = PageService(engine)
|
||||
audit = AuditService(engine)
|
||||
service = AdminPostsService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
post_service=posts,
|
||||
page_service=pages,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
primed = posts.list_published()
|
||||
# Hit the cache so we have a concrete reference to compare against.
|
||||
still_cached = posts.list_published()
|
||||
assert primed is still_cached
|
||||
|
||||
created = service.create(
|
||||
title="Fresh Post",
|
||||
body_md="x",
|
||||
status=PostStatus.PUBLISHED,
|
||||
author_id=1,
|
||||
)
|
||||
|
||||
after = posts.list_published()
|
||||
assert after is not primed
|
||||
assert any(p.slug == created.slug for p in after)
|
||||
Reference in New Issue
Block a user