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.
336 lines
13 KiB
Python
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)}"
|
|
)
|