Files
brain-of-reese/tests/integration/test_favicon_route.py
T
ducoterra 36eddce672
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 177_themed_favicon
Everything is verified. Final report:

## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end)

**Verified (no code fixes needed — implementation matched the design D1–D4 exactly):**
- `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins.

**Tests / lint / coverage (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed
- `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed

**Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run).

**Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed.

**Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched.

**Next pending phase:** none — 177 was the last; TODO.md is cleared.
2026-10-11 15:33:39 -04:00

336 lines
13 KiB
Python

"""Integration: the themed favicon route — ``GET /favicon.svg`` (phase
177, task 02, D2; A2/A3 owner-confirmed) + the template pins (task 03,
D3 — the route is the SINGLE source; the static file is deleted).
The route's contract, driven through the real app (TestClient + the
house ``db`` fixture — the ``test_ui_settings_api`` pattern):
* **default state** (no ``ui_settings`` row): 200 ``image/svg+xml`` +
``Cache-Control: no-cache``, the body byte-identical to the golden
art (``FAVICON_GOLDEN`` from ``tests.unit.test_theming`` — ONE
source, so the route and the renderer can never serve different
bytes), the CSP header present (the ``SecurityHeadersMiddleware``
pass-through — no new header wiring was needed);
* **themed state**: a set row's ``surface``/``brand``/``brand_ink``
land in their exact SVG attribute roles (asserted by PARSING the
attributes — the ``test_theming`` unit-pin idiom, not substring) and
none of the other 14 built-ins appears in the body;
* **no process cache**: a row update is visible on the very next
request — the palette is re-read per request (the runtime-change
contract; a theme change never needs a restart to reach the tab
icon);
* **DB-failure state**: ``effective_settings`` raising (a DB blip / a
pre-migration boot) → 200 with the built-in golden bytes (NEVER 500
— the never-break contract) + the fallback log line;
* **HEAD parity**: the same headers as GET, an empty body (the
phase-76 static-file parity idiom — the pre-phase static file
answered both methods);
* **routes-first**: a file named ``favicon.svg`` in the static dir
could NOT shadow the route (the registration position holds);
* **task 03 (template pins)**: all five served page bodies link the
EXACT bare ``<link … href="/favicon.svg">`` (NO ``?v=`` — the href
is not under ``/assets/``, so ``CachingMiddleware``'s
``_ASSET_REF_RE`` can never touch it; the pin keeps that contract
explicit against a future regex drift) and contain no
``/assets/favicon.svg`` reference; ``GET /assets/favicon.svg``
404s (the file is deleted); a tree scan of ``frontend/`` finds zero
remaining ``/assets/favicon.svg`` references.
The route is deliberately OUTSIDE ``/api/*`` (no per-response
byte-budget line — AGENTS rule 10 is ``/api/*``-only) and carries NO
auth dependency (the favicon is chrome — public like the login page):
every test below is the anonymous ``client``.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text, update
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import theming
from app.models import UiSettings
from tests.unit.test_theming import FAVICON_GOLDEN
# Phase 177 (task 03, D3): the template pins — every page links the
# BARE route; the static file is gone.
#: The EXACT favicon link tag every template must carry (bare — no
#: ``?v=``: the href is not under ``/assets/``).
FAVICON_LINK_TAG = (
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
)
#: The five L8 pages — the shell + the four standalone pages.
PAGE_PATHS: tuple[str, ...] = (
"/",
"/document.html",
"/login.html",
"/shared.html",
"/doc-edit.html",
)
#: The frontend tree root (the house tree-scan idiom).
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
"""The single ui_settings row is global state — wiped so every
test starts from the state it asserts (the house DELETE reset
pattern, ``test_ui_settings_api``)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
yield
db.execute(text("DELETE FROM ui_settings"))
db.commit()
def _assert_roles(
svg_text: str, *, surface: str, brand: str, brand_ink: str
) -> None:
"""The three hexes in their EXACT SVG attribute roles — asserted by
PARSING the attributes (the ``test_theming`` unit-pin idiom, not
substring) — the geometry invariants along the way (the hexagon
``d`` path, the three element order), and none of the other 14
built-in hexes anywhere in the body (a themed favicon must not
leak a built-in color)."""
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(svg_text)
assert root.tag == f"{ns}svg"
hexagon, circle, spokes = list(root)
assert hexagon.tag == f"{ns}path"
assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z"
assert hexagon.get("fill") == surface
assert hexagon.get("stroke") == brand
assert circle.tag == f"{ns}circle"
assert circle.get("fill") == brand
assert spokes.tag == f"{ns}path"
assert spokes.get("stroke") == brand_ink
for key, value in theming.BUILTIN_COLORS.items():
if value not in (surface, brand, brand_ink):
assert value not in svg_text, (
f"built-in {key} ({value}) must not appear in a themed favicon"
)
def test_default_state_serves_the_golden_bytes(
client: TestClient, db: Session
) -> None:
"""No ui_settings row: the BUILT-IN palette renders — 200
``image/svg+xml`` + ``Cache-Control: no-cache``, the body
byte-identical to the golden art (the D1 contract through the
wire), and the CSP header is present on the response (the
``SecurityHeadersMiddleware`` pass-through — no header change was
needed; the phase-123 ``img-src 'self' data:`` carve-out is part
of the policy that covers task 04's ``data:`` preview)."""
assert (
db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0
), "the test starts from a row-missing state"
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert r.text == FAVICON_GOLDEN
assert "content-security-policy" in r.headers
assert "default-src 'self'" in r.headers["content-security-policy"]
assert "img-src 'self' data:" in r.headers["content-security-policy"]
def test_themed_state_carries_the_three_roles(
client: TestClient, db: Session
) -> None:
"""A set row (distinct ``surface``/``brand``/``brand_ink``): the
three hexes land in their exact SVG attribute roles (parsed, not
substring) and none of the other 14 built-ins appears in the
body; the header contract (no-cache + content-type) is unchanged
by the palette."""
db.add(
UiSettings(
id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
)
db.commit()
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
_assert_roles(
r.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
def test_themed_state_is_live_no_process_cache(
client: TestClient, db: Session
) -> None:
"""The runtime-change contract (D2): the palette is re-read per
request — NO process cache, so a row update is visible on the very
next request (a theme change never needs a restart to reach the
tab icon), and the OLD palette is gone from the body (nothing
stale is served)."""
db.add(
UiSettings(
id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
)
db.commit()
r1 = client.get("/favicon.svg")
assert r1.status_code == 200
_assert_roles(
r1.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
db.execute(
update(UiSettings)
.where(UiSettings.id == 1)
.values(surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd")
)
db.commit()
r2 = client.get("/favicon.svg")
assert r2.status_code == 200
_assert_roles(
r2.text, surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd"
)
for old in ("#102030", "#4f46e5", "#a5b4fc"):
assert old not in r2.text
def test_db_failure_serves_the_builtin_art_with_200(
client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A DB blip / pre-migration boot (``effective_settings`` raising):
the handler's fallback serves the BUILT-IN golden bytes with 200
(NEVER 500 — the never-break contract) and logs the failure via
``logger.exception`` (the ``caching.py`` DB-blip pattern)."""
def _boom(_session: Session) -> dict[str, str]:
raise RuntimeError("db blip (test)")
monkeypatch.setattr(theming, "effective_settings", _boom)
with caplog.at_level(logging.ERROR, logger="app"):
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert r.text == FAVICON_GOLDEN
assert any(
"favicon: theme read failed" in record.message for record in caplog.records
), "the fallback path must log the failure (logger.exception)"
def test_head_parity_matches_get(client: TestClient, db: Session) -> None:
"""``HEAD /favicon.svg``: 200 with the SAME headers as GET
(content-type, no-cache, the CSP pass-through) and an empty body
(the phase-76 static-file parity idiom — the pre-phase static
file answered both methods, the dynamic route keeps that)."""
r = client.head("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert "content-security-policy" in r.headers
assert r.content == b""
def test_route_wins_over_the_static_mount(client: TestClient) -> None:
"""Routes-first: a file named ``favicon.svg`` in the static dir
could NOT shadow the route — the file is created with
SHADOW-ATTEMPT bytes (and deleted again in the ``finally``), and
the response is still the golden art (the registration position —
after the router includes, before the static catch-all mount —
holds)."""
shadow = Path(get_settings().static_dir).resolve() / "favicon.svg"
assert not shadow.exists(), (
"the test must start without a static-dir favicon.svg"
)
shadow.write_text("SHADOW ATTEMPT", encoding="utf-8")
try:
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.text == FAVICON_GOLDEN
assert "SHADOW ATTEMPT" not in r.text
finally:
shadow.unlink()
def test_route_is_public_no_auth_dependency(client: TestClient) -> None:
"""The favicon is chrome — public like the login page: the
anonymous (unsigned-in) client gets 200, and the route carries no
``/api/`` prefix (deliberately outside ``/api/*`` — no
per-response byte-budget line, AGENTS rule 10's scope)."""
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
r = client.get("/favicon.svg")
assert r.status_code == 200
# ---------- Phase 177 (task 03, D3): the template pins ----------
@pytest.mark.parametrize("path", PAGE_PATHS)
def test_served_page_links_the_bare_route(
client: TestClient, path: str
) -> None:
"""Each of the five served page bodies carries the EXACT bare
favicon link (task 03, D3 — ``href="/favicon.svg"``) and contains
NO ``/assets/favicon.svg`` reference of any kind. The link stays
BARE — no ``?v=`` (the href is not under ``/assets/``, so
``CachingMiddleware``'s ``_ASSET_REF_RE`` can never touch it;
the pin keeps that contract explicit against a future regex
drift)."""
r = client.get(path)
assert r.status_code == 200
assert FAVICON_LINK_TAG in r.text, f"{path}: favicon link pin missing"
assert "/assets/favicon.svg" not in r.text
assert "/favicon.svg?v=" not in r.text, (
f"{path}: the favicon link must stay bare (no ?v= version token)"
)
def test_the_static_favicon_file_is_gone(client: TestClient) -> None:
"""The single-source decision (task 03, D3): ``frontend/assets/
favicon.svg`` no longer exists — on disk AND over the wire (the
static catch-all 404s it). Nothing is lost: the built-in bytes
stay pinned by ``FAVICON_GOLDEN`` in ``test_theming`` (task 01's
golden constant is the durable contract), and ``GET
/favicon.svg`` still serves the golden art (the route is the
single source now)."""
assert not (FRONTEND / "assets" / "favicon.svg").exists()
r = client.get("/assets/favicon.svg")
assert r.status_code == 404
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.text == FAVICON_GOLDEN
def test_tree_scan_has_zero_assets_favicon_references() -> None:
"""A tree scan of ``frontend/`` (the house idiom — every file,
markup + scripts + styles, comments included): ZERO remaining
references to ``/assets/favicon.svg`` (the deletion is complete —
no template, script, or stylesheet still asks for the retired
URL)."""
files = [
p
for p in sorted(FRONTEND.rglob("*"))
if p.is_file()
]
assert files, "frontend/ contains no files — test is blind"
for path in files:
assert "/assets/favicon.svg" not in path.read_text(encoding="utf-8"), (
f"/assets/favicon.svg survived in {path.relative_to(FRONTEND)}"
)