"""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 "world" 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)