thermograph/frontend/tests/unit/test_pages.py
Emi Griffith 0ddc457d8c UI product-event instrumentation (design + flagged-off prototype)
Extends the existing /api/v2/event beacon into a small, typed, durable event
schema so the interactive views can answer product questions the request log
cannot: whether a visitor ever gets a location, whether search finds anything,
which controls and views earn their maintenance, and which dead ends people
actually hit.

Seven events with three enum dimension slots, stored as hourly aggregates in a
TimescaleDB hypertable plus one JSONL line per event for the 30-day Loki view.
No row per interaction and no column an identifier could go in: no cookie, no
session id, no user id, no IP, no coordinates, no free text, no URLs. The IP is
a per-minute rate-limit key and nothing else.

Abuse resistance for a public endpoint on a 60%-crawler site: closed allowlist
(unknown names collapse to one bucket), 4 KB streaming body cap, per-IP ceiling
raised to 120/min (30 was sized for four coarse events and would have silently
truncated a batched stream), soft Sec-Fetch-Site first-party check, uniform 204.

Ships inert -- THERMOGRAPH_EVENTS gates both the recorder and the flag stamp on
<html> that makes the client send anything, and is unset everywhere. See
UI-EVENTS.md for the schema, the rejected transport/storage alternatives, and
the privacy decisions that must be settled before the flag is turned on.
2026-07-23 15:49:55 -07:00

68 lines
2.9 KiB
Python

"""SPA-shell pages (/calendar, /day, /score, /compare, /legend, /alerts) and
static-asset serving -- moved here from backend/tests/web/test_api.py
(repo-split Stage 7a: frontend now owns these, not backend). These routes are static
shells/assets that don't call the backend, so the hermetic `client` fixture serves them."""
B = "/thermograph" # matches THERMOGRAPH_BASE set in tests/conftest.py
def test_calendar_serves_with_origin_filled_in(client):
r = client.get(f"{B}/calendar")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "__ORIGIN__" not in r.text
assert client.head(f"{B}/calendar").status_code == 200
def test_other_spa_shells_serve(client):
for path in ("/day", "/score", "/compare", "/legend", "/alerts"):
r = client.get(f"{B}{path}")
assert r.status_code == 200, path
assert "__ORIGIN__" not in r.text, path
def test_static_asset_serves(client):
r = client.get(f"{B}/app.js")
assert r.status_code == 200
assert "javascript" in r.headers["content-type"]
def test_old_static_index_is_gone(client):
"""index.html must not linger behind the static mount as indexable
duplicate content now that / is server-rendered (moved here from
backend/tests/web/test_homepage.py, repo-split Stage 7a -- frontend's own
StaticFiles mount is what actually answers this now)."""
assert client.get(f"{B}/index.html").status_code == 404
# --- product-event instrumentation flag ---------------------------------------
# track.js sends nothing unless it finds data-tg-events on <html>. The whole
# rollout story rests on that stamp being absent by default, so assert both
# halves: off unless THERMOGRAPH_EVENTS is set, and applied to BOTH HTML paths
# (the Jinja-rendered content pages and the static SPA shells).
def test_event_flag_is_off_by_default_on_every_html_path(client):
for path in ("/", "/calendar", "/day", "/score", "/compare", "/alerts"):
r = client.get(f"{B}{path}")
assert r.status_code == 200, path
assert "data-tg-events" not in r.text, path
def test_event_flag_stamps_both_html_paths_when_enabled(monkeypatch):
"""Rebuilt in-process rather than reusing `client`: the flag is read at
import and the SPA shells memoize their prepped template."""
import importlib
import content as content_mod
from fastapi.testclient import TestClient
monkeypatch.setenv("THERMOGRAPH_EVENTS", "1")
content_mod = importlib.reload(content_mod)
assert content_mod.EVENTS_ENABLED
import app as app_mod
app_mod = importlib.reload(app_mod)
flagged = TestClient(app_mod.app)
for path in ("/calendar", "/compare"):
assert 'data-tg-events="1"' in flagged.get(f"{B}{path}").text, path
# Leave the modules as the rest of the suite expects to find them.
monkeypatch.delenv("THERMOGRAPH_EVENTS")
importlib.reload(content_mod)
importlib.reload(app_mod)