thermograph/backend/tests/notifications/test_discord_dm.py
emi deb039ee24
All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 11s
secrets-guard / encrypted (push) Successful in 17s
shell-lint / shellcheck (push) Successful in 15s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m5s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m8s
Deploy / deploy (backend) (push) Successful in 1m29s
Deploy / deploy (frontend) (push) Successful in 1m38s
secrets-guard / encrypted (pull_request) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 10s
shell-lint / shellcheck (pull_request) Successful in 11s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 59s
PR build (required check) / build-backend (pull_request) Successful in 1m22s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Google, on a shared provider-agnostic OAuth engine (#122)
2026-07-27 00:56:43 +00:00

187 lines
7.5 KiB
Python

"""Discord DM alerts: send_dm REST mechanics (mocked), the notify dispatch gate,
the on/off toggle endpoint, and that linking opts in."""
import types
import pytest
from fastapi.testclient import TestClient
from web import app as appmod
from accounts import db
from accounts import oauth
from notifications import discord
from notifications import notify
V2 = "/thermograph/api/v2"
PW = "supersecret123"
@pytest.fixture(scope="module", autouse=True)
def _tables():
db.Base.metadata.create_all(db.sync_engine)
# --- send_dm REST mechanics --------------------------------------------------
class _Resp:
def __init__(self, code, data=None): self.status_code = code; self._data = data or {}
def json(self): return self._data
def _mock_posts(monkeypatch, script):
"""Route discord._client.post by URL; `script` maps a URL substring -> _Resp."""
calls = []
def _post(url, json=None, headers=None, timeout=None):
calls.append((url, json))
for frag, resp in script.items():
if frag in url:
return resp
return _Resp(404)
monkeypatch.setattr(discord._client, "post", _post)
return calls
def test_send_dm_opens_channel_then_posts(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
calls = _mock_posts(monkeypatch, {
"/users/@me/channels": _Resp(200, {"id": "chan-1"}),
"/channels/chan-1/messages": _Resp(204),
})
ok = discord.send_dm("discord-9", "Tehran hit a Near Record high", "104F, 99th pct", "/day#x")
assert ok is True
assert calls[0][0].endswith("/users/@me/channels") and calls[0][1] == {"recipient_id": "discord-9"}
assert "/channels/chan-1/messages" in calls[1][0]
embed = calls[1][1]["embeds"][0]
assert embed["title"].startswith("Tehran") and embed["url"].endswith("/day#x")
def test_send_dm_disabled_or_no_recipient(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "")
assert discord.send_dm("d", "t", "b") is False
monkeypatch.setattr(discord, "BOT_TOKEN", "bot")
assert discord.send_dm("", "t", "b") is False
def test_send_dm_gives_up_if_channel_open_fails(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
calls = _mock_posts(monkeypatch, {"/users/@me/channels": _Resp(403)})
assert discord.send_dm("d", "t", "b") is False
assert len(calls) == 1 # never tried to post a message
def test_post_subscription_alert_posts_to_channel(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "chan-sub")
calls = _mock_posts(monkeypatch, {"/channels/chan-sub/messages": _Resp(204)})
ok = discord.post_subscription_alert("Seattle: unusually hot day", "97th pct", "/day#x")
assert ok is True
(url, body), = calls
assert url.endswith("/channels/chan-sub/messages")
embed = body["embeds"][0]
assert embed["title"].startswith("Seattle") and embed["url"].endswith("/day#x")
def test_post_subscription_alert_noop_without_config(monkeypatch):
# Channel set but no bot token, and bot token set but no channel: both no-op.
monkeypatch.setattr(discord, "BOT_TOKEN", "")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "chan-sub")
calls = _mock_posts(monkeypatch, {})
assert discord.post_subscription_alert("t", "b") is False
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "")
assert discord.post_subscription_alert("t", "b") is False
assert calls == [] # never touched the network
def test_bot_post_retries_once_on_429(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord.time, "sleep", lambda s: None) # don't actually wait
seq = iter([_Resp(429, {"retry_after": 0.01}), _Resp(200, {"id": "c"})])
monkeypatch.setattr(discord._client, "post", lambda *a, **k: next(seq))
resp = discord._bot_post("/users/@me/channels", {"recipient_id": "d"})
assert resp.status_code == 200 # the retry succeeded
# --- notify dispatch gate ----------------------------------------------------
def _fake_session(user):
return types.SimpleNamespace(get=lambda model, uid: user)
def _sub():
return types.SimpleNamespace(id="s1", user_id="u1", lat=47.6, lon=-122.3)
def test_discord_job_gathers_linked_opted_in_user(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
user = types.SimpleNamespace(discord_id="d9", discord_dm=True)
job = notify._discord_job(_fake_session(user), _sub(), "T", "B", "2026-07-19")
assert job is not None and job[0] == "d9"
def test_discord_job_skips_unlinked_or_opted_out(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
# linked but opted out
assert notify._discord_job(
_fake_session(types.SimpleNamespace(discord_id="d", discord_dm=False)),
_sub(), "T", "B", "2026-07-19") is None
# not linked
assert notify._discord_job(
_fake_session(types.SimpleNamespace(discord_id=None, discord_dm=True)),
_sub(), "T", "B", "2026-07-19") is None
def test_discord_job_noop_when_bot_unconfigured(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: False)
user = types.SimpleNamespace(discord_id="d", discord_dm=True)
assert notify._discord_job(_fake_session(user), _sub(), "T", "B", "2026-07-19") is None
def test_send_discord_job_calls_send_dm(monkeypatch):
sent = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: sent.append(a) or True)
notify._send_discord_job(("d9", "T", "B", "/day#x"))
assert sent == [("d9", "T", "B", "/day#x")]
# --- the on/off toggle + link opt-in -----------------------------------------
def _login(client, email):
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
assert r.status_code in (201, 400)
assert client.post(f"{V2}/auth/login", data={"username": email, "password": PW}).status_code == 204
class _MockClient:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k): return _Resp(200, {"access_token": "tok"})
async def get(self, url, **k): return _Resp(200, {"id": "discord-777"})
def test_linking_opts_in_and_toggle_flips(monkeypatch):
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("app", "sec"))
monkeypatch.setattr(oauth.httpx, "AsyncClient", _MockClient)
c = TestClient(appmod.app)
_login(c, "dm-toggle@example.com")
uid = c.get(f"{V2}/users/me").json()["id"]
# Link -> opted in by default. discord_id is a delivery address, so the link
# has to write it as well as the oauth_account row.
state = oauth._sign_state(uid, "link", "discord")
r = c.get(f"{V2}/discord/link/callback?code=x&state={state}", follow_redirects=False)
assert r.status_code == 303
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-777" and me["discord_dm"] is True
# Mute without unlinking.
assert c.post(f"{V2}/discord/dm", json={"enabled": False}).status_code == 204
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-777" and me["discord_dm"] is False
# Turn back on.
assert c.post(f"{V2}/discord/dm", json={"enabled": True}).status_code == 204
assert c.get(f"{V2}/users/me").json()["discord_dm"] is True
def test_dm_toggle_requires_auth():
c = TestClient(appmod.app)
assert c.post(f"{V2}/discord/dm", json={"enabled": True}).status_code == 401