feat: add maintenance module for message deletion and related utilities
feat: implement tests for maintenance functions and dashboard interactions feat: add timezone and country tests for thctime module
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
from datetime import datetime
|
||||
|
||||
import dashboard
|
||||
|
||||
|
||||
def _make_app():
|
||||
return dashboard.create_app(
|
||||
get_state=lambda: {
|
||||
"running": True,
|
||||
"started_at": datetime(2026, 1, 1, 10, 0, 0),
|
||||
"last_type": "420",
|
||||
"last_attempt_at": datetime(2026, 1, 1, 10, 15, 0),
|
||||
"last_success_at": datetime(2026, 1, 1, 10, 20, 0),
|
||||
"last_status_code": 204,
|
||||
"last_error": None,
|
||||
"last_locations": ["Nowhere"],
|
||||
},
|
||||
get_next_event=lambda: {
|
||||
"type": "reminder",
|
||||
"at": datetime(2026, 1, 1, 11, 15, 0),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_fmt_dt_none_and_datetime():
|
||||
assert dashboard._fmt_dt(None) == "—"
|
||||
assert dashboard._fmt_dt(
|
||||
datetime(2026, 1, 1, 10, 0, 0)) == "2026-01-01T10:00:00"
|
||||
|
||||
|
||||
def test_get_html_template_wraps_content():
|
||||
html = dashboard.get_html_template("<p>hello</p>")
|
||||
assert "<title>thc-webhook admin</title>" in html
|
||||
assert "<p>hello</p>" in html
|
||||
|
||||
|
||||
def test_index_route_renders_status_page():
|
||||
app = _make_app()
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_data(as_text=True)
|
||||
assert "thc-webhook" in body
|
||||
assert "last_type: 420" in body
|
||||
assert "type: reminder" in body
|
||||
assert "Nowhere" in body
|
||||
|
||||
|
||||
def test_admin_get_renders_template_form(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
dashboard,
|
||||
"load_templates",
|
||||
lambda path: {
|
||||
"420": {
|
||||
"text": "Blaze",
|
||||
"color": 3066993,
|
||||
"image_url": "https://example.com/img.png",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app = _make_app()
|
||||
app.config["TEMPLATES_PATH"] = "templates.json"
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/admin")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_data(as_text=True)
|
||||
assert "Admin: templates" in body
|
||||
assert "name='420__text'" in body
|
||||
assert "name='420__color'" in body
|
||||
assert "name='420__image_url'" in body
|
||||
|
||||
|
||||
def test_admin_post_validation_error(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
dashboard,
|
||||
"load_templates",
|
||||
lambda path: {"420": {"text": "x", "color": 1}},
|
||||
)
|
||||
monkeypatch.setattr(dashboard, "parse_color", lambda raw: (
|
||||
_ for _ in ()).throw(ValueError("bad color")))
|
||||
|
||||
save_called = {"value": False}
|
||||
|
||||
def _save_templates(path, updated):
|
||||
save_called["value"] = True
|
||||
|
||||
monkeypatch.setattr(dashboard, "save_templates", _save_templates)
|
||||
|
||||
app = _make_app()
|
||||
client = app.test_client()
|
||||
|
||||
response = client.post(
|
||||
"/admin",
|
||||
data={
|
||||
"420__text": "Updated",
|
||||
"420__color": "bad",
|
||||
"420__image_url": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid color" in response.get_data(as_text=True)
|
||||
assert save_called["value"] is False
|
||||
|
||||
|
||||
def test_admin_post_success(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
dashboard,
|
||||
"load_templates",
|
||||
lambda path: {"420": {"text": "x", "color": 1}},
|
||||
)
|
||||
monkeypatch.setattr(dashboard, "parse_color", lambda raw: 123)
|
||||
|
||||
saved = {"path": None, "payload": None}
|
||||
|
||||
def _save_templates(path, updated):
|
||||
saved["path"] = path
|
||||
saved["payload"] = updated
|
||||
|
||||
monkeypatch.setattr(dashboard, "save_templates", _save_templates)
|
||||
|
||||
app = _make_app()
|
||||
app.config["TEMPLATES_PATH"] = "custom_templates.json"
|
||||
client = app.test_client()
|
||||
|
||||
response = client.post(
|
||||
"/admin",
|
||||
data={
|
||||
"420__text": "Updated",
|
||||
"420__color": "123",
|
||||
"420__image_url": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Saved." in response.get_data(as_text=True)
|
||||
assert saved["path"] == "custom_templates.json"
|
||||
assert saved["payload"] == {"420": {"text": "Updated", "color": 123}}
|
||||
@@ -1,132 +0,0 @@
|
||||
import io
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import main
|
||||
import thctime
|
||||
|
||||
|
||||
SAMPLE_TIMEZONE_CSV = """Etc/UTC,ZZ,UTC,0,0,0
|
||||
America/New_York,US,EST,0,-18000,0
|
||||
Europe/London,GB,BST,0,0,1
|
||||
"""
|
||||
|
||||
SAMPLE_COUNTRY_CSV = """ZZ,Unknown
|
||||
US,United States
|
||||
GB,United Kingdom
|
||||
"""
|
||||
|
||||
|
||||
def test_load_timezones_and_countries(monkeypatch):
|
||||
tzs = main.load_timezones()
|
||||
countries = main.load_countries()
|
||||
assert any(t['zone_name'] == 'America/New_York' for t in tzs)
|
||||
assert any(c['country_code'] == 'US' for c in countries)
|
||||
|
||||
|
||||
def test_get_tz_and_country_info():
|
||||
timezones = [{'zone_name': 'A/B', 'country_code': 'US'}]
|
||||
countries = [{'country_code': 'US', 'country_name': 'United States'}]
|
||||
assert main.get_tz_info('A/B', timezones)['zone_name'] == 'A/B'
|
||||
assert main.get_country_info('US', countries)[
|
||||
'country_name'] == 'United States'
|
||||
assert main.get_tz_info('X/Y', timezones) is None
|
||||
assert main.get_country_info('XX', countries) is None
|
||||
|
||||
|
||||
def test_create_embed_all_types(monkeypatch):
|
||||
# Prevent create_embed from trying to read actual CSV files by patching loaders
|
||||
monkeypatch.setattr(main, 'load_timezones', lambda: [
|
||||
{'zone_name': 'Etc/UTC', 'country_code': 'ZZ'}])
|
||||
monkeypatch.setattr(main, 'load_countries', lambda: [
|
||||
{'country_code': 'ZZ', 'country_name': 'Nowhere'}])
|
||||
|
||||
# reminder
|
||||
emb = main.create_embed('reminder')
|
||||
assert emb['title'] == 'Reminder'
|
||||
assert '5 minute' in emb['description']
|
||||
assert emb['color'] == 0xe67e22
|
||||
|
||||
# reminder_halftime
|
||||
emb = main.create_embed('reminder_halftime')
|
||||
assert emb['title'] == 'Reminder halftime'
|
||||
assert 'Half-time in 5 minutes' in emb['description']
|
||||
|
||||
# halftime (should include image)
|
||||
monkeypatch.setattr(main, 'where_is_it_420', lambda tzs, cs, **kwargs: [])
|
||||
emb = main.create_embed('halftime')
|
||||
assert emb['title'] == 'Halftime'
|
||||
assert emb['image'] is not None
|
||||
|
||||
# 420 (should include image and appended tz info string when list empty)
|
||||
monkeypatch.setattr(main, 'where_is_it_420', lambda tzs, cs, **kwargs: [])
|
||||
emb = main.create_embed('420')
|
||||
assert emb['title'] == '420'
|
||||
assert emb['image'] is not None
|
||||
|
||||
# unknown
|
||||
emb = main.create_embed('nope')
|
||||
assert emb['description'] == 'Unknown notification type'
|
||||
|
||||
|
||||
def test_where_is_it_420(monkeypatch):
|
||||
# Limit timezones to a predictable set
|
||||
monkeypatch.setattr(thctime.pytz, 'all_timezones', ['Etc/UTC'])
|
||||
|
||||
tzs = [{'zone_name': 'Etc/UTC', 'country_code': 'ZZ'}]
|
||||
countries = [{'country_code': 'ZZ', 'country_name': 'Nowhere'}]
|
||||
|
||||
class FakeDatetime:
|
||||
@staticmethod
|
||||
def now(tz):
|
||||
class R:
|
||||
hour = 4
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(thctime, 'datetime', FakeDatetime)
|
||||
|
||||
res = main.where_is_it_420(tzs, countries)
|
||||
assert res == ['Nowhere']
|
||||
|
||||
|
||||
def test_main_exits_quickly(monkeypatch):
|
||||
# Patch send_notification so it doesn't perform network
|
||||
monkeypatch.setattr(main, 'send_notification', lambda x: None)
|
||||
# Don't start dashboard during this test
|
||||
monkeypatch.setattr(main, 'start_dashboard', lambda: None)
|
||||
# Make schedule.run_pending raise KeyboardInterrupt to exit loop
|
||||
monkeypatch.setattr(main.schedule, 'run_pending', lambda: (
|
||||
_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
# Patch time.sleep to no-op
|
||||
monkeypatch.setattr(main.time, 'sleep', lambda s: None)
|
||||
# Ensure WEBHOOK_URL present to avoid early return
|
||||
monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'http://example.com/webhook')
|
||||
main.WEBHOOK_URL = 'http://example.com/webhook'
|
||||
|
||||
# Should exit quickly due to KeyboardInterrupt from run_pending
|
||||
main.main()
|
||||
|
||||
|
||||
def test_get_next_scheduled_event():
|
||||
# 10:14 -> next is 10:15 reminder
|
||||
now = main.datetime(2025, 1, 1, 10, 14, 30)
|
||||
nxt = main.get_next_scheduled_event(now)
|
||||
assert nxt["type"] == "reminder"
|
||||
assert nxt["at"].hour == 10 and nxt["at"].minute == 15
|
||||
|
||||
# 10:50:01 -> next is 11:15 reminder
|
||||
now = main.datetime(2025, 1, 1, 10, 50, 1)
|
||||
nxt = main.get_next_scheduled_event(now)
|
||||
assert nxt["type"] == "reminder"
|
||||
assert nxt["at"].hour == 11 and nxt["at"].minute == 15
|
||||
|
||||
|
||||
def test_split_tz_name():
|
||||
assert main.split_tz_name("America/New_York") == ("America", "New_York")
|
||||
assert main.split_tz_name("America/Argentina/Buenos_Aires") == (
|
||||
"America",
|
||||
"Argentina/Buenos_Aires",
|
||||
)
|
||||
assert main.split_tz_name("UTC") == ("UTC", "")
|
||||
@@ -0,0 +1,32 @@
|
||||
import main
|
||||
|
||||
|
||||
def test_create_embed_all_types(monkeypatch):
|
||||
monkeypatch.setattr(main, "load_timezones", lambda: [
|
||||
{"zone_name": "Etc/UTC", "country_code": "ZZ"}
|
||||
])
|
||||
monkeypatch.setattr(main, "load_countries", lambda: [
|
||||
{"country_code": "ZZ", "country_name": "Nowhere"}
|
||||
])
|
||||
|
||||
emb = main.create_embed("reminder")
|
||||
assert emb["title"] == "Reminder"
|
||||
assert "5 minute" in emb["description"]
|
||||
assert emb["color"] == 0xE67E22
|
||||
|
||||
emb = main.create_embed("reminder_halftime")
|
||||
assert emb["title"] == "Reminder halftime"
|
||||
assert "Half-time in 5 minutes" in emb["description"]
|
||||
|
||||
monkeypatch.setattr(main, "where_is_it_420", lambda tzs, cs, **kwargs: [])
|
||||
emb = main.create_embed("halftime")
|
||||
assert emb["title"] == "Halftime"
|
||||
assert emb["image"] is not None
|
||||
|
||||
monkeypatch.setattr(main, "where_is_it_420", lambda tzs, cs, **kwargs: [])
|
||||
emb = main.create_embed("420")
|
||||
assert emb["title"] == "420"
|
||||
assert emb["image"] is not None
|
||||
|
||||
emb = main.create_embed("nope")
|
||||
assert emb["description"] == "Unknown notification type"
|
||||
@@ -0,0 +1,25 @@
|
||||
import main
|
||||
|
||||
|
||||
def test_main_exits_quickly(monkeypatch):
|
||||
monkeypatch.setattr(main, "send_notification", lambda x: None)
|
||||
monkeypatch.setattr(main, "start_dashboard", lambda: None)
|
||||
monkeypatch.setattr(main.schedule, "run_pending", lambda: (
|
||||
_ for _ in ()).throw(KeyboardInterrupt()))
|
||||
monkeypatch.setattr(main.time, "sleep", lambda s: None)
|
||||
monkeypatch.setenv("DISCORD_WEBHOOK_URL", "http://example.com/webhook")
|
||||
main.WEBHOOK_URL = "http://example.com/webhook"
|
||||
|
||||
main.main()
|
||||
|
||||
|
||||
def test_get_next_scheduled_event():
|
||||
now = main.datetime(2025, 1, 1, 10, 14, 30)
|
||||
nxt = main.get_next_scheduled_event(now)
|
||||
assert nxt["type"] == "reminder"
|
||||
assert nxt["at"].hour == 10 and nxt["at"].minute == 15
|
||||
|
||||
now = main.datetime(2025, 1, 1, 10, 50, 1)
|
||||
nxt = main.get_next_scheduled_event(now)
|
||||
assert nxt["type"] == "reminder"
|
||||
assert nxt["at"].hour == 11 and nxt["at"].minute == 15
|
||||
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import maintenance
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, headers=None, payload=None):
|
||||
self.headers = headers or {}
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_parse_float():
|
||||
assert maintenance.parse_float("1.5") == 1.5
|
||||
assert maintenance.parse_float(2) == 2.0
|
||||
assert maintenance.parse_float(None) is None
|
||||
assert maintenance.parse_float("nope") is None
|
||||
|
||||
|
||||
def test_parse_message_timestamp_and_build_delete_entry():
|
||||
msg = {"id": "42", "timestamp": "2026-01-01T10:00:00Z"}
|
||||
parsed = maintenance.parse_message_timestamp(msg)
|
||||
assert parsed == datetime(2026, 1, 1, 10, 0, 0)
|
||||
|
||||
entry = maintenance.build_delete_entry(msg)
|
||||
assert entry["id"] == "42"
|
||||
assert entry["timestamp"] == parsed
|
||||
|
||||
|
||||
def test_should_delete_message():
|
||||
ts = int(datetime(2026, 1, 1, 10, 0, 0, tzinfo=timezone.utc).timestamp())
|
||||
message = {
|
||||
"timestamp": "2026-01-01T10:00:00Z",
|
||||
"webhook_id": "w",
|
||||
"author": {"id": "a"},
|
||||
}
|
||||
|
||||
assert maintenance.should_delete_message(
|
||||
message,
|
||||
webhook_id="w",
|
||||
author_id="a",
|
||||
cutoff=ts,
|
||||
)
|
||||
assert not maintenance.should_delete_message(
|
||||
message,
|
||||
webhook_id="x",
|
||||
author_id="a",
|
||||
cutoff=ts,
|
||||
)
|
||||
|
||||
|
||||
def test_get_rate_limit_retry_after_header_priority():
|
||||
response = DummyResponse(
|
||||
headers={
|
||||
"Retry-After": "2.5",
|
||||
"X-RateLimit-Reset-After": "10",
|
||||
},
|
||||
payload={"retry_after": "20"},
|
||||
)
|
||||
assert maintenance.get_rate_limit_retry_after(response) == 2.5
|
||||
|
||||
|
||||
def test_get_rate_limit_retry_after_json_fallback():
|
||||
response = DummyResponse(payload={"retry_after": "3"})
|
||||
assert maintenance.get_rate_limit_retry_after(response) == 3.0
|
||||
|
||||
|
||||
def test_get_bucket_exhausted_delay():
|
||||
response = DummyResponse(
|
||||
headers={
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset-After": "1.25",
|
||||
}
|
||||
)
|
||||
assert maintenance.get_bucket_exhausted_delay(response) == 1.25
|
||||
|
||||
response_not_exhausted = DummyResponse(
|
||||
headers={
|
||||
"X-RateLimit-Remaining": "1",
|
||||
"X-RateLimit-Reset-After": "1.25",
|
||||
}
|
||||
)
|
||||
assert maintenance.get_bucket_exhausted_delay(
|
||||
response_not_exhausted) is None
|
||||
@@ -0,0 +1,47 @@
|
||||
import thctime
|
||||
|
||||
|
||||
def test_load_timezones_and_countries():
|
||||
tzs = thctime.load_timezones()
|
||||
countries = thctime.load_countries()
|
||||
assert any(t["zone_name"] == "America/New_York" for t in tzs)
|
||||
assert any(c["country_code"] == "US" for c in countries)
|
||||
|
||||
|
||||
def test_get_tz_and_country_info():
|
||||
timezones = [{"zone_name": "A/B", "country_code": "US"}]
|
||||
countries = [{"country_code": "US", "country_name": "United States"}]
|
||||
assert thctime.get_tz_info("A/B", timezones)["zone_name"] == "A/B"
|
||||
assert thctime.get_country_info("US", countries)[
|
||||
"country_name"] == "United States"
|
||||
assert thctime.get_tz_info("X/Y", timezones) is None
|
||||
assert thctime.get_country_info("XX", countries) is None
|
||||
|
||||
|
||||
def test_where_is_it_420(monkeypatch):
|
||||
monkeypatch.setattr(thctime.pytz, "all_timezones", ["Etc/UTC"])
|
||||
|
||||
tzs = [{"zone_name": "Etc/UTC", "country_code": "ZZ"}]
|
||||
countries = [{"country_code": "ZZ", "country_name": "Nowhere"}]
|
||||
|
||||
class FakeDatetime:
|
||||
@staticmethod
|
||||
def now(tz):
|
||||
class Result:
|
||||
hour = 4
|
||||
|
||||
return Result()
|
||||
|
||||
monkeypatch.setattr(thctime, "datetime", FakeDatetime)
|
||||
|
||||
res = thctime.where_is_it_420(tzs, countries)
|
||||
assert res == ["Nowhere"]
|
||||
|
||||
|
||||
def test_split_tz_name():
|
||||
assert thctime.split_tz_name("America/New_York") == ("America", "New_York")
|
||||
assert thctime.split_tz_name("America/Argentina/Buenos_Aires") == (
|
||||
"America",
|
||||
"Argentina/Buenos_Aires",
|
||||
)
|
||||
assert thctime.split_tz_name("UTC") == ("UTC", "")
|
||||
Reference in New Issue
Block a user