Files
chicken_babies_site/tests/test_post_service.py
Phillip Tarrant 0306f71763 feat: phase 2 content model + cache — SQLite schema, markdown, TTL
Stand up the full SQLite content layer: all 7 tables from the authoritative
schema with WAL + foreign-keys enforced per-connection, entity dataclasses
plus row mappers, hand-rolled versioned migrations tracked in
schema_migrations, and an idempotent Python seed (system user + welcome
post + About page).

Add a Markdown->HTML service using markdown-it-py with a strict bleach
allowlist (tables intentionally omitted on both sides). Add a typed
in-process TTLCache[K,V] and wire it into real DB-backed PostService and
PageService, both exposing invalidate_all() for Phase 4 admin writes.

Rewire / and /about to read from the DB; homepage renders the seeded
welcome post, About renders page.title + sanitized body_html_cached.
Update the Phase 1 route tests accordingly.

Mark Phase 2 complete in docs/ROADMAP.md.
2026-04-21 15:40:35 -05:00

64 lines
2.2 KiB
Python

"""Tests for :class:`app.services.posts.PostService`.
Uses the session-scoped ``db_engine`` fixture (temp file, migrated,
seeded) so we exercise the real SQL path — not a mock.
"""
from __future__ import annotations
from sqlalchemy import Engine
from app.models.posts import PostSummary
from app.services.posts import PostService
def test_list_published_returns_seeded_welcome_post(db_engine: Engine) -> None:
"""The seeded welcome post is visible to the published-list query."""
service = PostService(db_engine)
posts = service.list_published()
assert len(posts) >= 1
slugs = [p.slug for p in posts]
assert "welcome-to-the-farm" in slugs
welcome = next(p for p in posts if p.slug == "welcome-to-the-farm")
assert isinstance(welcome, PostSummary)
assert welcome.title == "Welcome to the Farm"
assert welcome.published_at is not None
# Excerpt must be populated and short enough for a card layout.
assert welcome.excerpt
assert len(welcome.excerpt) <= 281 # 280 + optional ellipsis
def test_list_published_is_cached(db_engine: Engine) -> None:
"""Subsequent calls with the same limit return the same object.
The cache is keyed by limit. Two consecutive calls within the
TTL window should hand back the identical list object, which
proves the cache hit path works.
"""
service = PostService(db_engine)
first = service.list_published()
second = service.list_published()
# Same list object = cache hit. Replaces an explicit "spy on SQL"
# test — simpler and more robust to refactors.
assert first is second
def test_invalidate_all_forces_reload(db_engine: Engine) -> None:
"""After :meth:`invalidate_all` the next call re-hits the DB."""
service = PostService(db_engine)
first = service.list_published()
service.invalidate_all()
second = service.list_published()
assert first is not second
# Content should still match — same DB, same rows.
assert [p.slug for p in first] == [p.slug for p in second]
def test_list_published_respects_limit(db_engine: Engine) -> None:
"""``limit`` is forwarded to the SQL query."""
service = PostService(db_engine)
posts = service.list_published(limit=1)
assert len(posts) <= 1