thermograph/tests/notifications/test_discord_interactions.py
Emi Griffith d17ac794fd 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

116 lines
4.4 KiB
Python

"""Discord HTTP-interactions: Ed25519 verification, PING/PONG, /grade dispatch,
and the wired FastAPI route. No live Discord — we hold a test signing key and sign
requests ourselves."""
import datetime
import json
import pytest
from fastapi.testclient import TestClient
from nacl.signing import SigningKey
from web import app as appmod
from notifications import discord_interactions as di
from web import homepage
B = "/thermograph"
_SK = SigningKey.generate()
_PUBKEY = _SK.verify_key.encode().hex()
def _sign(body: bytes, ts: str = "1700000000") -> tuple[str, str]:
return _SK.sign(ts.encode() + body).signature.hex(), ts
_CARD = {
"display": "Tokyo, Japan", "name": "Tokyo", "slug": "tokyo-jp",
"metric_label": "High temp", "value": 96.0, "normal": 84.0,
"pct_label": "97th percentile", "grade_label": "Very High",
"tail": "warm", "date_label": None,
}
# --- signature verification --------------------------------------------------
def test_verify_accepts_a_good_signature():
body = b'{"type":1}'
sig, ts = _sign(body)
assert di.verify(sig, ts, body, _PUBKEY) is True
def test_verify_rejects_tampering_and_bad_inputs():
body = b'{"type":1}'
sig, ts = _sign(body)
assert di.verify(sig, ts, body + b" ", _PUBKEY) is False # body changed
assert di.verify(sig, "1700000001", body, _PUBKEY) is False # timestamp changed
assert di.verify("00" * 64, ts, body, _PUBKEY) is False # wrong signature
assert di.verify("nothex", ts, body, _PUBKEY) is False # malformed
assert di.verify(sig, ts, body, "") is False # no key configured
assert di.verify("", ts, body, _PUBKEY) is False # no signature
# --- handle(): routing -------------------------------------------------------
def test_handle_ping_pongs(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
body = b'{"type":1}'
sig, ts = _sign(body)
status, payload = di.handle(body, sig, ts)
assert status == 200 and payload == {"type": 1}
def test_handle_rejects_bad_signature(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
body = b'{"type":1}'
status, payload = di.handle(body, "00" * 64, "1700000000")
assert status == 401
def test_grade_command_returns_the_citys_grade(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
monkeypatch.setattr(homepage, "_grade_city", lambda city, today: dict(_CARD))
body = json.dumps({"type": 2, "data": {
"name": "grade", "options": [{"name": "city", "value": "Tokyo"}]}}).encode()
sig, ts = _sign(body)
status, payload = di.handle(body, sig, ts)
assert status == 200 and payload["type"] == 4
embed = payload["data"]["embeds"][0]
assert "Tokyo, Japan" in embed["title"]
assert "96°F" in embed["description"] and "97th percentile" in embed["description"]
assert embed["url"].endswith("/climate/tokyo-jp")
def test_grade_unknown_city_is_ephemeral(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
data = di._grade_message("Nowherecityville")
assert data["flags"] == di._EPHEMERAL and "don't track" in data["content"]
def test_grade_city_not_yet_warm_is_ephemeral(monkeypatch):
monkeypatch.setattr(homepage, "_grade_city", lambda city, today: None)
data = di._grade_message("Tokyo") # a real curated city, but no warm cache
assert data["flags"] == di._EPHEMERAL and "No recent reading" in data["content"]
def test_resolve_city_prefers_exact_then_population():
# Real cities.json: an exact name resolves, and it's a real curated entry.
tokyo = di._resolve_city("tokyo")
assert tokyo is not None and tokyo["name"] == "Tokyo"
assert di._resolve_city(" ") is None
assert di._resolve_city("Zzznotacity") is None
# --- the wired FastAPI route -------------------------------------------------
def test_interactions_route_pings_and_rejects(monkeypatch):
monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY)
client = TestClient(appmod.app)
body = b'{"type":1}'
sig, ts = _sign(body)
# Signed PING -> PONG. Send raw bytes so the signature matches byte-for-byte.
r = client.post(f"{B}/discord/interactions", content=body,
headers={"x-signature-ed25519": sig, "x-signature-timestamp": ts})
assert r.status_code == 200 and r.json() == {"type": 1}
# Unsigned -> 401 (Discord's security probe).
r = client.post(f"{B}/discord/interactions", content=body)
assert r.status_code == 401