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:
56
tests/conftest.py
Normal file
56
tests/conftest.py
Normal 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
76
tests/test_cache.py
Normal 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
155
tests/test_db_migrations.py
Normal 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
112
tests/test_markdown.py
Normal 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'
|
||||
''
|
||||
)
|
||||
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)
|
||||
53
tests/test_page_service.py
Normal file
53
tests/test_page_service.py
Normal 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"
|
||||
63
tests/test_post_service.py
Normal file
63
tests/test_post_service.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user