thermograph/tests/test_discord_dm.py

162 lines
6.2 KiB
Python
Raw Normal View History

"""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
import app as appmod
import db
import discord
import discord_link as dl
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.httpx.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.httpx, "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_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.httpx, "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(user_id="u1", lat=47.6, lon=-122.3)
def test_dispatch_sends_for_linked_opted_in_user(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
sent = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: sent.append(a) or True)
user = types.SimpleNamespace(discord_id="d9", discord_dm=True)
notify._dispatch_discord(_fake_session(user), _sub(), "T", "B", "2026-07-19")
assert len(sent) == 1 and sent[0][0] == "d9"
def test_dispatch_skips_unlinked_or_opted_out(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
sent = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: sent.append(a) or True)
# linked but opted out
notify._dispatch_discord(_fake_session(types.SimpleNamespace(discord_id="d", discord_dm=False)),
_sub(), "T", "B", "2026-07-19")
# not linked
notify._dispatch_discord(_fake_session(types.SimpleNamespace(discord_id=None, discord_dm=True)),
_sub(), "T", "B", "2026-07-19")
assert sent == []
def test_dispatch_noop_when_bot_unconfigured(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: False)
called = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: called.append(1))
notify._dispatch_discord(_fake_session(types.SimpleNamespace(discord_id="d", discord_dm=True)),
_sub(), "T", "B", "2026-07-19")
assert called == [] # never even looked the user up beyond the gate
# --- 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.setattr(dl, "CLIENT_ID", "app")
monkeypatch.setattr(dl, "CLIENT_SECRET", "sec")
monkeypatch.setattr(dl.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.
r = c.get(f"{V2}/discord/link/callback?code=x&state={dl._sign_state(uid)}", 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