thermograph/tests/notifications/test_discord.py
Emi Griffith f830bce4d9 Port 871ab9c Route notifier output to Discord channels via the bot from monorepo (drift reconciliation)
Note: this commit's deploy/thermograph.env.example changes live at the
monorepo root (outside backend/) and are out of scope for this split repo.

Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
2026-07-22 12:07:46 -07:00

154 lines
6.1 KiB
Python

"""Discord daily-feed post: embed shape, webhook delivery, and the once-a-day guard.
No network — httpx.post is stubbed, like the IndexNow tests."""
import datetime
import time
import pytest
from notifications import discord
from notifications import notify
def _feed(ranked, generated_at=None, date=None):
# Fresh by default so it passes homepage.is_stale(); tests that assert on the
# embed timestamp pin generated_at explicitly.
return {
"generated_at": generated_at if generated_at is not None else time.time(),
"date": date or datetime.date.today().isoformat(),
"ranked": ranked,
}
_CARD_HOT = {
"display": "Tehran, Iran", "name": "Tehran", "metric": "tmax",
"metric_label": "High temp", "value": 104.2, "normal": 96.8,
"percentile": 99.0, "pct_label": "99th percentile", "grade": "Near Record",
"grade_label": "Near Record hot", "tail": "warm", "date_label": None,
}
_CARD_COLD = {
"display": "Oslo, Norway", "name": "Oslo", "metric": "tmin",
"metric_label": "Low temp", "value": 5.0, "normal": 20.0,
"percentile": 3.0, "pct_label": "3rd percentile", "grade": "Near Record",
"grade_label": "Near Record cold", "tail": "cold", "date_label": "Jul 18",
}
def test_build_embed_has_numbers_and_cities():
embed = discord.build_embed(_feed([_CARD_HOT, _CARD_COLD], generated_at=1_700_000_000.0))
assert "unusual weather" in embed["title"].lower()
desc = embed["description"]
assert "Tehran, Iran" in desc and "Oslo, Norway" in desc
# Real readings, in both units, with the normal for context.
assert "104°F (40°C)" in desc
assert "normal 97°F" in desc
# The percentile + grade tag rides along.
assert "99th percentile" in desc and "Near Record hot" in desc
# A non-today card is dated so it can't pass for "right now".
assert "(Jul 18)" in desc
# Warm top card => warm accent; timestamp from the feed's build time.
assert embed["color"] == discord._HOT
assert embed["timestamp"].startswith("2023-11-14")
def test_embed_accent_follows_the_top_cards_tail():
assert discord.build_embed(_feed([_CARD_COLD]))["color"] == discord._COLD
assert discord.build_embed(_feed([_CARD_HOT]))["color"] == discord._HOT
def test_build_embed_rejects_empty_feed():
with pytest.raises(ValueError):
discord.build_embed(_feed([]))
def test_post_disabled_without_webhook(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "")
posted = []
monkeypatch.setattr(discord.httpx, "post", lambda *a, **k: posted.append(a) or None)
assert discord.post_daily_feed(_feed([_CARD_HOT])) is False
assert posted == [] # never touched the network
class _Resp:
def __init__(self, code): self.status_code = code
def test_post_sends_embed_to_the_webhook(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
calls = []
monkeypatch.setattr(discord.httpx, "post",
lambda url, json=None, **k: (calls.append((url, json)), _Resp(204))[1])
assert discord.post_daily_feed(_feed([_CARD_HOT])) is True
(url, payload), = calls
assert url == "https://discord.test/webhook/abc"
assert payload["username"] == "Thermograph"
assert payload["embeds"][0]["description"].startswith("**Tehran, Iran**")
def test_post_also_broadcasts_to_weather_channel(monkeypatch):
# No webhook, but a bot + weather channel configured -> digest goes to the
# channel via the bot REST API.
monkeypatch.setattr(discord, "WEBHOOK_URL", "")
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord, "WEATHER_CHANNEL_ID", "chan-weather")
calls = []
monkeypatch.setattr(discord.httpx, "post",
lambda url, json=None, **k: (calls.append((url, json)), _Resp(204))[1])
assert discord.post_daily_feed(_feed([_CARD_HOT])) is True
(url, body), = calls
assert url.endswith("/channels/chan-weather/messages")
assert body["embeds"][0]["description"].startswith("**Tehran, Iran**")
def test_post_is_best_effort_on_http_error(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
def _boom(*a, **k):
raise RuntimeError("network down")
monkeypatch.setattr(discord.httpx, "post", _boom)
# Never raises; just reports failure.
assert discord.post_daily_feed(_feed([_CARD_HOT])) is False
def test_post_skips_stale_or_empty_feed(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
monkeypatch.setattr(discord.httpx, "post", lambda *a, **k: _Resp(204))
# Yesterday's feed is stale -> no post.
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
assert discord.post_daily_feed(_feed([_CARD_HOT], date=yesterday)) is False
# Empty ranked -> no post.
assert discord.post_daily_feed(_feed([])) is False
def test_daily_guard_posts_once_per_day(monkeypatch):
monkeypatch.setattr(notify, "_last_discord_post_day", None)
monkeypatch.setattr(discord, "enabled", lambda: True)
posts = []
monkeypatch.setattr(discord, "post_daily_feed", lambda: posts.append(1) or True)
day1 = datetime.date(2026, 7, 19)
day2 = datetime.date(2026, 7, 20)
class _D(datetime.date):
_t = day1
@classmethod
def today(cls): return cls._t
monkeypatch.setattr(notify.datetime, "date", _D)
notify._maybe_post_discord()
notify._maybe_post_discord() # same day -> still one post
assert posts == [1]
_D._t = day2
notify._maybe_post_discord() # new day -> a second post
assert posts == [1, 1]
def test_daily_guard_retries_after_a_failed_post(monkeypatch):
monkeypatch.setattr(notify, "_last_discord_post_day", None)
monkeypatch.setattr(discord, "enabled", lambda: True)
outcomes = iter([False, True])
monkeypatch.setattr(discord, "post_daily_feed", lambda: next(outcomes))
# First call fails -> day not marked done -> second call retries and succeeds.
notify._maybe_post_discord()
notify._maybe_post_discord()
assert notify._last_discord_post_day == datetime.date.today().isoformat()