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.
123 lines
4.1 KiB
Python
123 lines
4.1 KiB
Python
"""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 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"``
|
|
|
|
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
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
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,
|
|
including migrations + seed.
|
|
"""
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path,expected_substring",
|
|
[
|
|
("/", "Chicken Babies"),
|
|
# 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"),
|
|
],
|
|
)
|
|
def test_public_route_renders_html(
|
|
client: TestClient,
|
|
path: str,
|
|
expected_substring: str,
|
|
) -> None:
|
|
"""Every public page returns 200 HTML containing a page-specific string.
|
|
|
|
The substring is intentionally a headline the template owns so the
|
|
test fails loudly if the wrong template is accidentally wired up.
|
|
"""
|
|
response = client.get(path)
|
|
|
|
assert response.status_code == 200, (
|
|
f"{path} returned {response.status_code}: {response.text[:200]}"
|
|
)
|
|
|
|
content_type = response.headers.get("content-type", "")
|
|
assert content_type.startswith("text/html"), (
|
|
f"{path} returned unexpected content-type: {content_type!r}"
|
|
)
|
|
|
|
assert expected_substring in response.text, (
|
|
f"{path} body missing expected substring {expected_substring!r}"
|
|
)
|
|
|
|
|
|
def test_home_shows_welcome_post(client: TestClient) -> None:
|
|
"""The Phase 2 seed inserts a welcome post; its title appears on /.
|
|
|
|
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 "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:
|
|
"""Shared layout references the generated logo asset paths.
|
|
|
|
We check for the stem (``/static/img/logo.``) rather than a specific
|
|
extension so both the <source srcset="...webp"> and <img src="...png">
|
|
markup are covered by a single assertion.
|
|
"""
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
assert "/static/img/logo." in response.text
|
|
|
|
|
|
def test_nav_marks_active_page(client: TestClient) -> None:
|
|
"""The About page renders ``aria-current=\"page\"`` on its nav link.
|
|
|
|
Exercises the shared layout's active-nav logic end-to-end without
|
|
inspecting internals.
|
|
"""
|
|
response = client.get("/about")
|
|
assert response.status_code == 200
|
|
# The pair should appear exactly once per active link; we only need
|
|
# to prove it's present at all.
|
|
assert 'aria-current="page"' in response.text
|