* 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
121 lines
4.4 KiB
Python
121 lines
4.4 KiB
Python
"""Tests for the digest signup and the mailer seam."""
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from fastapi.testclient import TestClient
|
|
|
|
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
|
|
|
|
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):
|
|
from core 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."""
|
|
for path in ["/", "/about", "/climate", "/privacy", "/glossary"]:
|
|
html = client.get(f"{B}{path}").text
|
|
assert "data-digest" not in html, path
|