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
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from web import app as appmod
|
|
|
|
|
from accounts import db
|
|
|
|
|
from notifications import digest
|
|
|
|
|
from notifications import mailer
|
|
|
|
|
from accounts.db import async_session_maker
|
|
|
|
|
from accounts.models import PendingDigest
|
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
|
|
|
|
|
|
|
|
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):
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from core import metrics
|
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
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 20:01:30 +00:00
|
|
|
# test_form_is_hidden_for_now moved to frontend_ssr/tests/test_content.py
|
|
|
|
|
# (repo-split Stage 4) -- every page it checked is frontend-owned now.
|