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.
64 lines
2.2 KiB
Python
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
|