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.
This commit is contained in:
2026-04-21 15:40:35 -05:00
parent 28168f57b6
commit 0306f71763
21 changed files with 2055 additions and 108 deletions

View File

@@ -1,17 +1,18 @@
"""Smoke tests for the public-site skeleton routes.
"""Smoke tests for the public-site routes.
These tests focus on contract rather than styling:
- every public route returns 200 with an HTML content-type
- each page contains a page-specific substring (proves the template
actually rendered, not just that the route exists)
- the homepage renders the empty-state copy when PostService returns []
- the shared layout emits the logo image path so nav/logo aren't broken
by a future refactor
- the homepage renders the Phase 2 seeded welcome post title
- the About page renders the Phase 2 seeded About markdown
- the shared layout emits the logo image path
- the About nav link carries ``aria-current="page"``
No mocks of the DB (there is no DB in Phase 1). The PostService stub
already returns an empty list, which is exactly what we want to assert
against.
Phase 2 updates: the homepage no longer shows "No posts yet" because
the seed inserts a welcome post, and the About page content now comes
from the DB-backed ``pages`` row rather than the old static template.
"""
from __future__ import annotations
@@ -27,7 +28,8 @@ def client() -> TestClient:
"""Return a module-scoped FastAPI TestClient.
TestClient uses the module-level `app` built by `create_app()` at
import time, i.e. the exact same app uvicorn runs in production.
import time i.e. the exact same app uvicorn runs in production,
including migrations + seed.
"""
return TestClient(app)
@@ -36,7 +38,9 @@ def client() -> TestClient:
"path,expected_substring",
[
("/", "Chicken Babies"),
("/about", "About the farm"),
# Phase 2: the About page renders the seeded page title
# "About the Farm" (h1 from the template + page.title).
("/about", "About the Farm"),
("/contact", "Get in touch"),
("/shop", "Coming soon"),
],
@@ -67,15 +71,30 @@ def test_public_route_renders_html(
)
def test_home_shows_empty_state_when_no_posts(client: TestClient) -> None:
"""With the Phase 1 stub service, the home page shows 'No posts yet'.
def test_home_shows_welcome_post(client: TestClient) -> None:
"""The Phase 2 seed inserts a welcome post; its title appears on /.
This is the canonical empty-state marker; Phase 2 seeds a welcome
post so this test will need to be updated when the DB lands.
Replaces the Phase 1 "No posts yet" assertion now that the DB
has a real published row on first boot.
"""
response = client.get("/")
assert response.status_code == 200
assert "No posts yet" in response.text
assert "Welcome to the Farm" in response.text
def test_about_renders_seeded_markdown(client: TestClient) -> None:
"""The About page body comes from the seeded ``pages`` row.
Picks a distinctive substring from the seeded Markdown so the
assertion fails if the old static template ever comes back.
"""
response = client.get("/about")
assert response.status_code == 200
# Substring from the seeded About markdown paragraph 1.
assert "small family farm" in response.text
# Seeded copy explicitly does not expose a street address.
# Spot-check: the word "Morrison" appears (town-level).
assert "Morrison" in response.text
def test_layout_includes_logo_image(client: TestClient) -> None: