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:
97
tests/test_admin_pages_service.py
Normal file
97
tests/test_admin_pages_service.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Tests for :class:`app.services.admin_pages.AdminPagesService`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.models.seed import run_seed
|
||||
from app.services.admin_pages import AdminPagesService
|
||||
from app.services.audit import AuditService
|
||||
from app.services.markdown import MarkdownService
|
||||
from app.services.pages import PageService
|
||||
from app.services.posts import PostService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Return an isolated migrated + seeded engine."""
|
||||
db_path = tmp_path / "phase4_pages.db"
|
||||
eng = build_engine(f"sqlite:///{db_path}")
|
||||
run_migrations(eng)
|
||||
run_seed(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(engine: Engine) -> AdminPagesService:
|
||||
"""Return a fully-wired :class:`AdminPagesService`."""
|
||||
return AdminPagesService(
|
||||
engine=engine,
|
||||
markdown=MarkdownService(),
|
||||
page_service=PageService(engine),
|
||||
post_service=PostService(engine),
|
||||
audit=AuditService(engine),
|
||||
)
|
||||
|
||||
|
||||
def test_get_about_returns_seeded_page(service: AdminPagesService) -> None:
|
||||
"""The seeded About page loads."""
|
||||
page = service.get_about()
|
||||
assert page is not None
|
||||
assert page.slug == "about"
|
||||
assert page.title == "About the Farm"
|
||||
|
||||
|
||||
def test_update_about_rewrites_html_cache(
|
||||
service: AdminPagesService,
|
||||
) -> None:
|
||||
"""Update regenerates the sanitized HTML and bumps ``updated_at``."""
|
||||
before = service.get_about()
|
||||
assert before is not None
|
||||
updated = service.update_about(
|
||||
title="New About Title",
|
||||
body_md="Some **bold** copy.",
|
||||
actor_user_id=1,
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.slug == "about" # slug immutable
|
||||
assert updated.title == "New About Title"
|
||||
assert "<strong>bold</strong>" in updated.body_html_cached
|
||||
# updated_at should bump (or at least not go backwards).
|
||||
assert updated.updated_at >= before.updated_at
|
||||
|
||||
|
||||
def test_update_about_invalidates_public_page_cache(engine: Engine) -> None:
|
||||
"""After an update the public :class:`PageService` cache returns fresh copy."""
|
||||
md = MarkdownService()
|
||||
pages = PageService(engine)
|
||||
posts = PostService(engine)
|
||||
audit = AuditService(engine)
|
||||
service = AdminPagesService(
|
||||
engine=engine,
|
||||
markdown=md,
|
||||
page_service=pages,
|
||||
post_service=posts,
|
||||
audit=audit,
|
||||
)
|
||||
|
||||
primed = pages.get_by_slug("about")
|
||||
# Cache hit confirmation:
|
||||
assert pages.get_by_slug("about") is primed
|
||||
|
||||
service.update_about(
|
||||
title="After",
|
||||
body_md="updated copy",
|
||||
actor_user_id=1,
|
||||
)
|
||||
|
||||
after = pages.get_by_slug("about")
|
||||
assert after is not None
|
||||
assert after is not primed
|
||||
assert after.title == "After"
|
||||
Reference in New Issue
Block a user