thermograph/tests/notifications/test_digest.py

122 lines
4.3 KiB
Python
Raw Normal View History

Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
"""Tests for the digest signup and the mailer seam."""
import pytest
import sqlalchemy as sa
from fastapi.testclient import TestClient
import app as appmod
import db
import digest
import mailer
from db import async_session_maker
from models import PendingDigest
B = "/thermograph"
@pytest.fixture(scope="module", autouse=True)
def _tables():
# TestClient(app) (no context manager) skips the lifespan, so create the
# account tables directly against the temp DB.
db.Base.metadata.create_all(db.sync_engine)
@pytest.fixture
def client():
return TestClient(appmod.app)
def _rows(client, email):
"""Count stored rows for an address, via the running event loop."""
import asyncio
async def go():
async with async_session_maker() as session:
return (await session.execute(
sa.select(sa.func.count()).select_from(PendingDigest)
.where(PendingDigest.email == email)
)).scalar_one()
return asyncio.run(go())
# --- mailer ------------------------------------------------------------------
def test_console_backend_sends_nothing(monkeypatch):
"""The default backend must never open a socket — this is what makes it safe
to run the full signup path in dev and in tests."""
monkeypatch.setattr(mailer, "BACKEND", "console")
def boom(*a, **k):
raise AssertionError("console backend must not connect to SMTP")
monkeypatch.setattr(mailer.smtplib, "SMTP", boom)
assert mailer.send("a@example.com", "s", "t") is True
assert mailer.enabled() is False
def test_send_swallows_connection_failure(monkeypatch):
"""A dead mail server must not raise into the request that triggered it."""
monkeypatch.setattr(mailer, "BACKEND", "smtp")
def boom(*a, **k):
raise OSError("connection refused")
monkeypatch.setattr(mailer.smtplib, "SMTP", boom)
assert mailer.send("a@example.com", "s", "t") is False
def test_disabled_backend_drops(monkeypatch):
monkeypatch.setattr(mailer, "BACKEND", "disabled")
assert mailer.send("a@example.com", "s", "t") is False
def test_invalid_recipient_rejected():
assert mailer.send("", "s", "t") is False
assert mailer.send("not-an-address", "s", "t") is False
# --- signup ------------------------------------------------------------------
def test_signup_stores_and_confirms(client):
r = client.post(f"{B}/digest", json={"email": "reader@example.com"})
assert r.status_code == 200
assert r.json()["message"] == digest.GENERIC_OK
assert _rows(client, "reader@example.com") == 1
def test_signup_is_idempotent_and_case_insensitive(client):
first = client.post(f"{B}/digest", json={"email": "Dup@Example.COM"})
second = client.post(f"{B}/digest", json={"email": "dup@example.com"})
# Same answer either way: the endpoint must not reveal whether an address is
# already on the list.
assert first.json()["message"] == second.json()["message"] == digest.GENERIC_OK
assert _rows(client, "dup@example.com") == 1
def test_signup_rejects_junk(client):
for bad in ["", "nope", "a@b", "@example.com", "x@", "a b@example.com"]:
r = client.post(f"{B}/digest", json={"email": bad})
assert r.status_code == 400, bad
def test_signup_accepts_plain_form_post(client):
"""The no-JS path: a real <form method=post> submission."""
r = client.post(f"{B}/digest", data={"email": "nojs@example.com"})
assert r.status_code == 200
assert _rows(client, "nojs@example.com") == 1
def test_signup_records_event(client):
import metrics
before = metrics.snapshot()["events"].get("home.digest_signup", {})
before_n = sum(sum(days.values()) for days in before.values())
client.post(f"{B}/digest", json={"email": "counted@example.com"})
after = metrics.snapshot()["events"].get("home.digest_signup", {})
assert sum(sum(days.values()) for days in after.values()) == before_n + 1
def test_form_is_hidden_for_now(client):
"""The footer digest signup is temporarily hidden (commented out in the
base template). The /digest endpoint stays live; only the UI is pulled.
Restore the form and flip this back to asserting presence."""
Rebuild the homepage as a distribution landing; add an SMTP seam (#178) The homepage was a bare tool: a find-bar and an empty panel reading "Find a location to begin." A visitor arriving from a search result or a shared link learned nothing about what the product does before deciding to leave. Rebuild it around the Weekly view, which is untouched: - Hero with the question as the h1, and a grade card showing a real graded example — the most unusual city we're currently tracking, either tail. The card's frame and text slots are server-rendered with reserved heights, so app.js re-pointing it at the visitor's own place shifts nothing. - "Unusual right now" strip, CSS scroll-snap, no JS carousel. A cold-tail city is force-included whenever one qualifies. - Stance line, how-it-works, explore cards, and 12 city chips linking into the ~1000-page /climate surface. - Monthly digest form, in the footer of every page. Serve / from Jinja instead of a static file with placeholder substitution, so crawlers and no-JS readers get the whole page as real HTML. home.html.j2 extends base.html.j2 and carries app.js's DOM contract over verbatim; the brand degrades to a <p> so the hero owns the sole h1. frontend/index.html is deleted rather than left behind the static mount, where it would keep serving indexable duplicate content. "Where is it most unusual right now" has no cheap answer at request time — percentiles live inside zlib-compressed payload blobs with no column to sort on. So homepage.py sweeps the warm cache and writes data/homepage.json, read by the template. The sweep is strictly cache-only (climate.load_cached_recent_forecast is new, the sibling of load_cached_history), so grading ~1000 cities costs zero upstream requests. It rides the notifier's timer behind an hourly guard rather than starting a second daemon, and also runs at the tail of warm_cities so a fresh deploy has a populated feed. Instrumentation: metrics gains a product-event counter keyed by (event, referrer domain, UTC day), behind an allowlist and a per-IP rate limit, fed by POST /api/v2/event. The referrer is taken from the request's own header, never from the client. The beacon gets its own inbound category that record_inbound ignores, so reporting an interaction doesn't also count as traffic. The dashboard grows an events block with per-referrer attribution. Email is scaffolded but sends nothing yet. mailer.py talks stdlib smtplib to a local Postfix null client on 127.0.0.1:25 (deploy/provision-mail.sh), so the choice between direct-to-MX and relaying through a provider stays a Postfix config change with no code change. The backend defaults to "console", which logs and sends nothing, so dev and tests exercise the whole signup path safely. Signups land in pending_digest unconfirmed; collecting the list shouldn't wait on delivery. Also adds /privacy, linked from the footer and kept out of the sitemap. The strip's classes are named unusual-* rather than record-*: .record-card is already the SEO records page's, and reusing it leaked layout rules onto /climate/<slug>/records.
2026-07-18 07:39:47 +00:00
for path in ["/", "/about", "/climate", "/privacy", "/glossary"]:
html = client.get(f"{B}{path}").text
assert "data-digest" not in html, path