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

56
tests/conftest.py Normal file
View File

@@ -0,0 +1,56 @@
"""Shared pytest fixtures for the ``chicken_babies_site`` suite.
Key fixtures:
- ``db_engine``: a session-scoped SQLAlchemy engine pointed at a
temp-file SQLite database. Migrations + seed run once per test
session. Per the CLAUDE.md mandate, tests do NOT mock the DB —
they use a real SQLite file so behavior matches production.
- ``clean_db_engine``: a function-scoped engine with migrations
applied but seed NOT run, for tests that need to exercise the
first-boot path.
"""
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
@pytest.fixture(scope="session")
def db_engine(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Engine]:
"""Return a migrated + seeded SQLite engine shared across the session.
Uses a real on-disk file (NOT ``:memory:``) because the CLAUDE.md
project rules forbid mocking the DB in auth / magic-link tests,
and doing the same here keeps the behavior identical to
production.
"""
db_path: Path = tmp_path_factory.mktemp("db") / "test.db"
engine = build_engine(f"sqlite:///{db_path}")
run_migrations(engine)
run_seed(engine)
yield engine
engine.dispose()
@pytest.fixture
def clean_db_engine(tmp_path: Path) -> Iterator[Engine]:
"""Return a fresh engine with tables created but NO seed data.
Function-scoped so each test that uses it starts with a virgin
database — useful for asserting first-run seed behavior and
migration idempotency without contaminating the session-scoped
``db_engine``.
"""
db_path = tmp_path / "clean.db"
engine = build_engine(f"sqlite:///{db_path}")
run_migrations(engine)
yield engine
engine.dispose()

76
tests/test_cache.py Normal file
View File

@@ -0,0 +1,76 @@
"""Tests for the in-process TTL cache.
Covers:
- stored values round-trip via ``get`` before TTL expiry
- entries expire after TTL elapses
- ``invalidate_all`` drops every entry
- construction rejects a non-positive TTL
- the cache is typed-generic (spot check at runtime that multiple
concrete types work — the real type safety comes from static
checking, which isn't part of the runtime suite)
"""
from __future__ import annotations
import time
import pytest
from app.services.cache import TTLCache
def test_set_then_get_returns_stored_value() -> None:
"""A value stored via ``set`` is visible to ``get`` until expiry."""
cache: TTLCache[str, int] = TTLCache(ttl_seconds=5.0)
cache.set("answer", 42)
assert cache.get("answer") == 42
def test_get_returns_none_for_missing_key() -> None:
"""Absent keys return ``None`` cleanly (no KeyError)."""
cache: TTLCache[str, str] = TTLCache(ttl_seconds=5.0)
assert cache.get("nope") is None
def test_entries_expire_after_ttl() -> None:
"""An entry past its TTL is treated as absent.
Uses a tiny TTL + ``time.sleep`` rather than mocking
``time.monotonic`` so the test exercises the real code path.
"""
cache: TTLCache[str, str] = TTLCache(ttl_seconds=0.05)
cache.set("k", "v")
time.sleep(0.1)
assert cache.get("k") is None
def test_invalidate_all_clears_everything() -> None:
"""``invalidate_all`` drops every entry regardless of TTL."""
cache: TTLCache[str, int] = TTLCache(ttl_seconds=60.0)
cache.set("a", 1)
cache.set("b", 2)
cache.invalidate_all()
assert cache.get("a") is None
assert cache.get("b") is None
def test_non_positive_ttl_is_rejected() -> None:
"""Zero/negative TTL raises at construction time.
A zero TTL would make every write immediately expire, which is
almost certainly a bug; the defensive check turns it into a loud
failure.
"""
with pytest.raises(ValueError):
TTLCache(ttl_seconds=0.0)
with pytest.raises(ValueError):
TTLCache(ttl_seconds=-1.0)
def test_cache_works_with_int_keys_and_list_values() -> None:
"""Runtime smoke: generic over both ``K`` and ``V``."""
cache: TTLCache[int, list[str]] = TTLCache(ttl_seconds=5.0)
cache.set(10, ["a", "b"])
stored = cache.get(10)
assert stored == ["a", "b"]

155
tests/test_db_migrations.py Normal file
View File

@@ -0,0 +1,155 @@
"""Tests for the DB migration runner and per-connection PRAGMAs.
Goals:
- ``run_migrations`` applies every file once and is a no-op on re-run.
- Tables and indexes declared in ``001_init.sql`` exist after a fresh
migration.
- The ``schema_migrations`` tracker records the applied version.
- ``journal_mode=WAL`` and ``foreign_keys=ON`` hold on *every* new
connection (the whole point of the ``@event.listens_for`` hook).
Uses a function-scoped temp SQLite file so each test is hermetic.
"""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import Engine, text
from app.db import build_engine, run_migrations
def _fresh_engine(tmp_path: Path) -> Engine:
"""Build a fresh engine on a temp-file SQLite DB.
Helper kept local to this test module — conftest provides
higher-level fixtures, but for migration tests we want direct
control over migration order and re-runs.
"""
return build_engine(f"sqlite:///{tmp_path / 'mig.db'}")
def test_first_migration_creates_all_tables(tmp_path: Path) -> None:
"""Running migrations on a fresh DB creates every authoritative table.
We check for each of the 7 domain tables + ``schema_migrations``;
absence of any one would indicate the SQL file got truncated.
"""
engine = _fresh_engine(tmp_path)
applied = run_migrations(engine)
assert applied == ["001_init"], applied
expected_tables = {
"users",
"magic_link_tokens",
"sessions",
"pages",
"posts",
"media",
"contact_submissions",
"auth_events",
"schema_migrations",
}
with engine.connect() as conn:
rows = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
found = {r[0] for r in rows}
missing = expected_tables - found
assert not missing, f"migration did not create: {missing}"
def test_required_indexes_exist(tmp_path: Path) -> None:
"""The three named indexes from the ROADMAP schema are present."""
engine = _fresh_engine(tmp_path)
run_migrations(engine)
with engine.connect() as conn:
rows = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='index'")
).fetchall()
names = {r[0] for r in rows}
for required in (
"idx_magic_email_created",
"idx_posts_status_pub",
"idx_auth_events_created",
):
assert required in names, f"missing index: {required} (have {names})"
def test_migrations_are_idempotent(tmp_path: Path) -> None:
"""Re-running migrations on an already-migrated DB is a no-op."""
engine = _fresh_engine(tmp_path)
first = run_migrations(engine)
second = run_migrations(engine)
assert first == ["001_init"]
assert second == [], "re-run should not re-apply migrations"
with engine.connect() as conn:
rows = conn.execute(
text("SELECT version FROM schema_migrations")
).fetchall()
versions = [r[0] for r in rows]
# Exactly one tracker row for the initial migration. Seed is not
# run in this test, so seed_001 should NOT be present here.
assert versions == ["001_init"], versions
def test_posts_status_check_constraint_rejects_invalid(tmp_path: Path) -> None:
"""The CHECK constraint on ``posts.status`` rejects unknown values.
This is a direct smoke test of the authoritative schema — if the
migration accidentally drops the CHECK clause, this test fails.
"""
import sqlalchemy.exc
engine = _fresh_engine(tmp_path)
run_migrations(engine)
# Seed a minimal user so the FK is satisfied.
with engine.connect() as conn:
conn.execute(
text(
"INSERT INTO users (id, email, display_name, created_at,"
" active) VALUES (1, 'x@x', 'x', '2026-01-01T00:00:00+00:00', 1)"
)
)
conn.commit()
with engine.connect() as conn:
try:
conn.execute(
text(
"INSERT INTO posts (slug, title, body_md, body_html_cached,"
" status, updated_at, author_user_id)"
" VALUES ('x', 't', 'm', 'h', 'bogus',"
" '2026-01-01T00:00:00+00:00', 1)"
)
)
conn.commit()
raised = False
except sqlalchemy.exc.IntegrityError:
raised = True
assert raised, "CHECK constraint should have rejected status='bogus'"
def test_pragmas_apply_on_every_connection(tmp_path: Path) -> None:
"""Both PRAGMAs are active on every new connection from the pool.
Issues two separate connect calls; both must show WAL +
foreign_keys=1. This is the regression guard for the
``@event.listens_for(Engine, "connect")`` contract.
"""
engine = _fresh_engine(tmp_path)
run_migrations(engine)
for _ in range(2):
with engine.connect() as conn:
jm = conn.execute(text("PRAGMA journal_mode")).scalar_one()
fk = conn.execute(text("PRAGMA foreign_keys")).scalar_one()
assert str(jm).lower() == "wal", f"expected WAL, got {jm!r}"
assert int(fk) == 1, f"expected foreign_keys=1, got {fk!r}"

112
tests/test_markdown.py Normal file
View File

@@ -0,0 +1,112 @@
"""Tests for the Markdown → sanitized HTML pipeline.
We care about three things:
1. Safe inline markup (``**bold**``, ``*italic*``, links, lists) round-trips
into the expected HTML tags.
2. Dangerous constructs (``<script>``, ``<iframe>``, ``<style>``, inline
``onclick`` handlers, ``javascript:`` URLs) are stripped — not
escaped — from the output.
3. Tables render (we enabled the ``table`` plugin in
:class:`MarkdownService`).
These are spot checks, not a full fuzz of bleach. The full allowlist
is already enforced in ``app.services.markdown``.
"""
from __future__ import annotations
import pytest
from app.services.markdown import MarkdownService, render_markdown_safe
@pytest.fixture
def md() -> MarkdownService:
"""Return a fresh :class:`MarkdownService`.
Function-scoped to keep tests independent; the service is cheap
to construct.
"""
return MarkdownService()
def test_basic_markdown_renders_paragraphs_and_emphasis(md: MarkdownService) -> None:
"""Simple Markdown constructs produce the expected safe HTML."""
html = md.render("Hello **world** and *friends*.")
assert "<p>" in html
assert "<strong>world</strong>" in html
assert "<em>friends</em>" in html
def test_script_tags_are_stripped(md: MarkdownService) -> None:
"""A ``<script>`` injected through raw HTML is stripped entirely."""
src = "Hello<script>alert('xss')</script>world"
html = md.render(src)
# bleach strip=True drops the tag; the (potentially dangerous)
# content can remain as text but cannot execute.
assert "<script" not in html
assert "</script>" not in html
def test_iframe_and_style_tags_are_stripped(md: MarkdownService) -> None:
"""Disallowed block-level tags are removed from the output."""
html = md.render(
"<iframe src='evil'></iframe>\n\n<style>body{}</style>\n\nsafe"
)
assert "<iframe" not in html
assert "<style" not in html
assert "safe" in html
def test_javascript_urls_are_stripped_from_links(md: MarkdownService) -> None:
"""Raw ``<a href="javascript:...">`` links lose the dangerous href.
We construct the link as raw HTML (rather than ``[text](url)``
Markdown syntax, which commonmark silently refuses to turn into
an anchor for the unknown ``javascript:`` protocol) so the
bleach allowlist actually has an anchor to filter. The assertion
is that the ``javascript:`` URL does not make it into the
sanitized output.
"""
html = md.render('<a href="javascript:alert(1)">click</a>')
assert "javascript:" not in html
def test_allowed_link_and_image_attributes_survive(md: MarkdownService) -> None:
"""Safe link/image attributes are preserved."""
html = md.render(
'[hello](https://example.com "Example")\n\n'
'![alt text](https://example.com/a.png "Caption")'
)
assert 'href="https://example.com"' in html
assert 'title="Example"' in html
assert 'alt="alt text"' in html
assert 'src="https://example.com/a.png"' in html
def test_inline_event_handler_attribute_is_stripped(md: MarkdownService) -> None:
"""``onclick`` and similar inline handlers never survive sanitization."""
html = md.render('<a href="/x" onclick="alert(1)">x</a>')
assert "onclick" not in html
def test_table_tags_are_stripped(md: MarkdownService) -> None:
"""Tables are not in the bleach allowlist, so their tags are stripped.
Documents the intentional policy: the Markdown parser is the
commonmark preset with NO table plugin, and the bleach allowlist
has no table tags — widening either without the other would be
a policy mismatch. If a future phase wants tables, this test
should flip to assert the opposite along with the matching
allowlist change.
"""
src = "| a | b |\n|---|---|\n| 1 | 2 |\n"
html = md.render(src)
assert "<table" not in html
def test_module_level_helper_matches_class(md: MarkdownService) -> None:
"""``render_markdown_safe`` produces the same output as the class."""
src = "Hello **there**."
assert render_markdown_safe(src) == md.render(src)

View File

@@ -0,0 +1,53 @@
"""Tests for :class:`app.services.pages.PageService`.
Uses the session-scoped seeded ``db_engine`` fixture.
"""
from __future__ import annotations
from sqlalchemy import Engine
from app.models.entities import Page
from app.services.pages import PageService
def test_get_by_slug_returns_seeded_about_page(db_engine: Engine) -> None:
"""The seeded About page loads as a :class:`Page` dataclass."""
service = PageService(db_engine)
page = service.get_by_slug("about")
assert page is not None
assert isinstance(page, Page)
assert page.slug == "about"
assert page.title == "About the Farm"
# The sanitized HTML must contain a <p> since the seed Markdown
# has multiple paragraphs; this also proves the Markdown pipeline
# ran at seed time.
assert "<p>" in page.body_html_cached
assert page.published is True
def test_get_by_slug_returns_none_for_unknown_slug(db_engine: Engine) -> None:
"""Unknown slugs return ``None`` rather than raising."""
service = PageService(db_engine)
assert service.get_by_slug("does-not-exist") is None
def test_get_by_slug_is_cached(db_engine: Engine) -> None:
"""The TTL cache wraps page lookups keyed by slug."""
service = PageService(db_engine)
first = service.get_by_slug("about")
second = service.get_by_slug("about")
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 = PageService(db_engine)
first = service.get_by_slug("about")
service.invalidate_all()
second = service.get_by_slug("about")
assert first is not second
# Same slug, same row — content equal, identity different.
assert first is not None and second is not None
assert first.slug == second.slug == "about"

View File

@@ -0,0 +1,63 @@
"""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

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: