Files
derp/tests/test_remind.py
user f888faf2bd feat: add calendar-based reminders (at/yearly) with persistence
Calendar reminders use bot.state (SQLite KV) for persistence across
restarts. Supports one-shot at specific date/time and yearly recurring
reminders with leap day handling. Restored automatically on connect
via 001 event handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:39:42 +01:00

1160 lines
36 KiB
Python

"""Tests for the remind plugin."""
import asyncio
import importlib.util
import json
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from derp.irc import Message
# plugins/ is not a Python package -- load the module from file path
_spec = importlib.util.spec_from_file_location(
"plugins.remind", Path(__file__).resolve().parent.parent / "plugins" / "remind.py",
)
_mod = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = _mod
_spec.loader.exec_module(_mod)
from plugins.remind import ( # noqa: E402
_by_user,
_calendar,
_cleanup,
_delete_saved,
_format_duration,
_get_tz,
_make_id,
_next_yearly,
_parse_date,
_parse_duration,
_parse_time,
_remind_once,
_remind_repeat,
_reminders,
_restore,
_save,
_schedule_at,
cmd_remind,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeState:
"""In-memory stand-in for bot.state (SQLite KV)."""
def __init__(self):
self._store: dict[str, dict[str, str]] = {}
def get(self, plugin: str, key: str, default: str | None = None) -> str | None:
return self._store.get(plugin, {}).get(key, default)
def set(self, plugin: str, key: str, value: str) -> None:
self._store.setdefault(plugin, {})[key] = value
def delete(self, plugin: str, key: str) -> bool:
try:
del self._store[plugin][key]
return True
except KeyError:
return False
def keys(self, plugin: str) -> list[str]:
return sorted(self._store.get(plugin, {}).keys())
class _FakeBot:
"""Minimal bot stand-in that captures sent/replied messages."""
def __init__(self, *, tz: str = "UTC"):
self.sent: list[tuple[str, str]] = []
self.replied: list[str] = []
self.config: dict = {"bot": {"timezone": tz}}
self.state = _FakeState()
async def send(self, target: str, text: str) -> None:
self.sent.append((target, text))
async def reply(self, message, text: str) -> None:
self.replied.append(text)
def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message:
"""Create a channel PRIVMSG."""
return Message(
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
command="PRIVMSG", params=[target, text], tags={},
)
def _pm(text: str, nick: str = "alice") -> Message:
"""Create a private PRIVMSG (target = bot nick)."""
return Message(
raw="", prefix=f"{nick}!~{nick}@host", nick=nick,
command="PRIVMSG", params=["botname", text], tags={},
)
def _clear() -> None:
"""Reset global module state between tests."""
for entry in _reminders.values():
task = entry[0]
if task is not None and not task.done():
task.cancel()
_reminders.clear()
_by_user.clear()
_calendar.clear()
async def _run_cmd(bot, msg):
"""Run cmd_remind and cancel any spawned tasks afterward."""
await cmd_remind(bot, msg)
# Yield so tasks can start
await asyncio.sleep(0)
async def _run_cmd_and_cleanup(bot, msg):
"""Run cmd_remind, yield, then cancel all spawned tasks."""
await cmd_remind(bot, msg)
await asyncio.sleep(0)
for entry in list(_reminders.values()):
if entry[0] is not None and not entry[0].done():
entry[0].cancel()
await asyncio.sleep(0)
# ---------------------------------------------------------------------------
# Pure helpers: _parse_duration
# ---------------------------------------------------------------------------
class TestParseDurationRawSeconds:
def test_positive_integer(self):
assert _parse_duration("60") == 60
def test_zero_returns_none(self):
assert _parse_duration("0") is None
def test_negative_returns_none(self):
assert _parse_duration("-5") is None
def test_large_value(self):
assert _parse_duration("86400") == 86400
class TestParseDurationSpecs:
def test_minutes(self):
assert _parse_duration("5m") == 300
def test_hours_and_minutes(self):
assert _parse_duration("1h30m") == 5400
def test_days(self):
assert _parse_duration("2d") == 172800
def test_seconds_suffix(self):
assert _parse_duration("90s") == 90
def test_full_combo(self):
assert _parse_duration("1d12h30m15s") == 131415
class TestParseDurationInvalid:
def test_empty_string(self):
assert _parse_duration("") is None
def test_letters_only(self):
assert _parse_duration("abc") is None
def test_all_zeros(self):
assert _parse_duration("0m0s") is None
class TestParseDurationEdgeCases:
def test_uppercase_works(self):
assert _parse_duration("5M") == 300
def test_hours_with_zero_minutes(self):
assert _parse_duration("1h0m") == 3600
def test_all_zeros_except_one_second(self):
assert _parse_duration("0d0h0m1s") == 1
# ---------------------------------------------------------------------------
# Pure helpers: _format_duration
# ---------------------------------------------------------------------------
class TestFormatDuration:
def test_minutes_and_seconds(self):
assert _format_duration(90) == "1m30s"
def test_exact_hour(self):
assert _format_duration(3600) == "1h"
def test_exact_day(self):
assert _format_duration(86400) == "1d"
def test_zero(self):
assert _format_duration(0) == "0s"
def test_full_combo(self):
assert _format_duration(90061) == "1d1h1m1s"
def test_seconds_only(self):
assert _format_duration(45) == "45s"
# ---------------------------------------------------------------------------
# Pure helpers: _make_id
# ---------------------------------------------------------------------------
class TestMakeId:
def test_returns_six_char_hex(self):
rid = _make_id("user", "check oven")
assert len(rid) == 6
assert all(c in "0123456789abcdef" for c in rid)
def test_different_inputs_differ(self):
rid1 = _make_id("alice", "task one")
rid2 = _make_id("bob", "task two")
assert rid1 != rid2
# ---------------------------------------------------------------------------
# _cleanup
# ---------------------------------------------------------------------------
class TestCleanup:
def test_removes_from_both_structures(self):
_clear()
_reminders["abc123"] = (None, "#ch", "alice", "label", "12:00", False)
_by_user[("#ch", "alice")] = ["abc123"]
_cleanup("abc123", "#ch", "alice")
assert "abc123" not in _reminders
assert ("#ch", "alice") not in _by_user
def test_removes_single_entry_from_multi(self):
_clear()
_reminders["aaa"] = (None, "#ch", "alice", "", "12:00", False)
_reminders["bbb"] = (None, "#ch", "alice", "", "12:00", False)
_by_user[("#ch", "alice")] = ["aaa", "bbb"]
_cleanup("aaa", "#ch", "alice")
assert "aaa" not in _reminders
assert _by_user[("#ch", "alice")] == ["bbb"]
def test_missing_rid_no_error(self):
_clear()
_cleanup("nonexistent", "#ch", "alice")
def test_missing_user_key_no_error(self):
_clear()
_reminders["abc"] = (None, "#ch", "alice", "", "12:00", False)
_cleanup("abc", "#ch", "bob") # different nick, user key absent
assert "abc" not in _reminders
def test_clears_calendar_set(self):
_clear()
_reminders["cal01"] = (None, "#ch", "alice", "", "12:00", False)
_by_user[("#ch", "alice")] = ["cal01"]
_calendar.add("cal01")
_cleanup("cal01", "#ch", "alice")
assert "cal01" not in _calendar
# ---------------------------------------------------------------------------
# _remind_once
# ---------------------------------------------------------------------------
class TestRemindOnce:
def test_fires_metadata_and_label(self):
_clear()
bot = _FakeBot()
async def inner():
rid = "once01"
task = asyncio.create_task(
_remind_once(bot, rid, "#ch", "alice", "check oven", 0, "12:00:00 UTC"),
)
_reminders[rid] = (task, "#ch", "alice", "check oven", "12:00:00 UTC", False)
_by_user[("#ch", "alice")] = [rid]
await task
asyncio.run(inner())
assert len(bot.sent) == 2
assert "alice: reminder #once01" in bot.sent[0][1]
assert "12:00:00 UTC" in bot.sent[0][1]
assert bot.sent[1] == ("#ch", "check oven")
assert "once01" not in _reminders
def test_empty_label_sends_one_line(self):
_clear()
bot = _FakeBot()
async def inner():
rid = "once02"
task = asyncio.create_task(
_remind_once(bot, rid, "#ch", "bob", "", 0, "12:00:00 UTC"),
)
_reminders[rid] = (task, "#ch", "bob", "", "12:00:00 UTC", False)
_by_user[("#ch", "bob")] = [rid]
await task
asyncio.run(inner())
assert len(bot.sent) == 1
def test_cancellation_cleans_up(self):
_clear()
bot = _FakeBot()
async def inner():
rid = "once03"
task = asyncio.create_task(
_remind_once(bot, rid, "#ch", "alice", "text", 9999, "12:00:00 UTC"),
)
_reminders[rid] = (task, "#ch", "alice", "text", "12:00:00 UTC", False)
_by_user[("#ch", "alice")] = [rid]
await asyncio.sleep(0)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(inner())
assert len(bot.sent) == 0
assert "once03" not in _reminders
# ---------------------------------------------------------------------------
# _remind_repeat
# ---------------------------------------------------------------------------
class TestRemindRepeat:
def test_fires_at_least_once(self):
_clear()
bot = _FakeBot()
async def inner():
rid = "rpt01"
task = asyncio.create_task(
_remind_repeat(bot, rid, "#ch", "alice", "hydrate", 0, "12:00:00 UTC"),
)
_reminders[rid] = (task, "#ch", "alice", "hydrate", "12:00:00 UTC", True)
_by_user[("#ch", "alice")] = [rid]
for _ in range(5):
await asyncio.sleep(0)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(inner())
assert len(bot.sent) >= 2 # at least one fire (metadata + label)
assert any("rpt01" in t for _, t in bot.sent)
assert "rpt01" not in _reminders
def test_cancellation_cleans_up(self):
_clear()
bot = _FakeBot()
async def inner():
rid = "rpt02"
task = asyncio.create_task(
_remind_repeat(bot, rid, "#ch", "bob", "stretch", 9999, "12:00:00 UTC"),
)
_reminders[rid] = (task, "#ch", "bob", "stretch", "12:00:00 UTC", True)
_by_user[("#ch", "bob")] = [rid]
await asyncio.sleep(0)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(inner())
assert len(bot.sent) == 0
assert "rpt02" not in _reminders
# ---------------------------------------------------------------------------
# cmd_remind: usage / errors
# ---------------------------------------------------------------------------
class TestCmdRemindUsage:
def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind")))
assert "Usage:" in bot.replied[0]
def test_invalid_duration(self):
_clear()
bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind xyz some text")))
assert "Invalid duration" in bot.replied[0]
def test_every_no_args(self):
_clear()
bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind every")))
assert "Invalid duration" in bot.replied[0]
def test_every_invalid_duration(self):
_clear()
bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind every abc")))
assert "Invalid duration" in bot.replied[0]
# ---------------------------------------------------------------------------
# cmd_remind: one-shot creation
# ---------------------------------------------------------------------------
class TestCmdRemindOneshot:
def test_creates_with_duration(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind 5m check the oven"))
asyncio.run(inner())
assert len(bot.replied) == 1
assert "set (5m)" in bot.replied[0]
assert "#" in bot.replied[0]
def test_no_label(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind 5m"))
asyncio.run(inner())
assert "set (5m)" in bot.replied[0]
def test_stores_in_tracking(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind 9999s task"))
assert len(_reminders) == 1
entry = next(iter(_reminders.values()))
assert entry[1] == "#test" # target
assert entry[2] == "alice" # nick
assert entry[3] == "task" # label
assert entry[5] is False # not repeating
assert ("#test", "alice") in _by_user
# cleanup
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_days_duration(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind 2d12h renew cert"))
asyncio.run(inner())
assert "set (2d12h)" in bot.replied[0]
# ---------------------------------------------------------------------------
# cmd_remind: repeating creation
# ---------------------------------------------------------------------------
class TestCmdRemindRepeat:
def test_creates_repeating(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind every 1h drink water"))
asyncio.run(inner())
assert len(bot.replied) == 1
assert "every 1h" in bot.replied[0]
def test_repeating_stores_flag(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind every 30m stretch"))
entry = next(iter(_reminders.values()))
assert entry[5] is True # repeating flag
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_repeating_no_label(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind every 1h"))
asyncio.run(inner())
assert "every 1h" in bot.replied[0]
# ---------------------------------------------------------------------------
# cmd_remind: list
# ---------------------------------------------------------------------------
class TestCmdRemindList:
def test_empty_list(self):
_clear()
bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind list")))
assert "No active reminders" in bot.replied[0]
def test_shows_active(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind 9999s task"))
bot.replied.clear()
await cmd_remind(bot, _msg("!remind list"))
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
assert "Reminders:" in bot.replied[0]
assert "#" in bot.replied[0]
def test_shows_repeat_tag(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind every 9999s task"))
bot.replied.clear()
await cmd_remind(bot, _msg("!remind list"))
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
assert "(repeat)" in bot.replied[0]
# ---------------------------------------------------------------------------
# cmd_remind: cancel
# ---------------------------------------------------------------------------
class TestCmdRemindCancel:
def _extract_rid(self, reply: str) -> str:
"""Extract reminder ID from 'Reminder #abc123 set (...)' reply."""
return reply.split("#")[1].split(" ")[0]
def test_cancel_valid(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind 9999s task"))
rid = self._extract_rid(bot.replied[0])
bot.replied.clear()
await cmd_remind(bot, _msg(f"!remind cancel {rid}"))
await asyncio.sleep(0)
asyncio.run(inner())
assert "Cancelled" in bot.replied[0]
def test_cancel_with_hash_prefix(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind 9999s task"))
rid = self._extract_rid(bot.replied[0])
bot.replied.clear()
await cmd_remind(bot, _msg(f"!remind cancel #{rid}"))
await asyncio.sleep(0)
asyncio.run(inner())
assert "Cancelled" in bot.replied[0]
def test_cancel_wrong_user(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind 9999s secret"))
rid = self._extract_rid(bot.replied[0])
bot.replied.clear()
await cmd_remind(bot, _msg(f"!remind cancel {rid}", nick="eve"))
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
assert "No active reminder" in bot.replied[0]
def test_cancel_nonexistent(self):
_clear()
bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind cancel ffffff")))
assert "No active reminder" in bot.replied[0]
def test_cancel_no_id(self):
_clear()
bot = _FakeBot()
asyncio.run(cmd_remind(bot, _msg("!remind cancel")))
assert "Usage:" in bot.replied[0]
# ---------------------------------------------------------------------------
# cmd_remind: target routing
# ---------------------------------------------------------------------------
class TestCmdRemindTarget:
def test_channel_target(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind 9999s task", target="#ops"))
entry = next(iter(_reminders.values()))
assert entry[1] == "#ops"
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_pm_uses_nick(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _pm("!remind 9999s task"))
entry = next(iter(_reminders.values()))
assert entry[1] == "alice" # nick, not "botname"
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
# ---------------------------------------------------------------------------
# Calendar helpers: _parse_date
# ---------------------------------------------------------------------------
class TestParseDate:
def test_valid_iso_date(self):
assert _parse_date("2027-02-15") == date(2027, 2, 15)
def test_invalid_format_no_dashes(self):
assert _parse_date("20270215") is None
def test_invalid_month(self):
assert _parse_date("2027-13-01") is None
def test_invalid_day(self):
assert _parse_date("2027-02-30") is None
def test_leap_day_valid(self):
assert _parse_date("2028-02-29") == date(2028, 2, 29)
def test_leap_day_invalid_year(self):
assert _parse_date("2027-02-29") is None
def test_empty_string(self):
assert _parse_date("") is None
def test_garbage(self):
assert _parse_date("not-a-date") is None
# ---------------------------------------------------------------------------
# Calendar helpers: _parse_time
# ---------------------------------------------------------------------------
class TestParseTime:
def test_valid_noon(self):
assert _parse_time("12:00") == (12, 0)
def test_valid_midnight(self):
assert _parse_time("00:00") == (0, 0)
def test_valid_end_of_day(self):
assert _parse_time("23:59") == (23, 59)
def test_invalid_hour(self):
assert _parse_time("24:00") is None
def test_invalid_minute(self):
assert _parse_time("12:60") is None
def test_no_colon(self):
assert _parse_time("1200") is None
def test_single_digit(self):
assert _parse_time("9:00") is None
def test_empty(self):
assert _parse_time("") is None
# ---------------------------------------------------------------------------
# Calendar helpers: _next_yearly
# ---------------------------------------------------------------------------
class TestNextYearly:
def test_future_this_year(self):
tz = ZoneInfo("UTC")
now = datetime.now(tz)
# Pick a month in the future
future_month = now.month + 1 if now.month < 12 else 1
future_year = now.year if now.month < 12 else now.year + 1
result = _next_yearly(future_month, 15, 12, 0, tz)
assert result.year == future_year
assert result.month == future_month
assert result.day == 15
def test_past_this_year_rolls_to_next(self):
tz = ZoneInfo("UTC")
now = datetime.now(tz)
# Pick January 1 -- almost always in the past
result = _next_yearly(1, 1, 0, 0, tz)
assert result.year == now.year + 1
assert result.month == 1
assert result.day == 1
def test_leap_day_clamped_in_non_leap_year(self):
tz = ZoneInfo("UTC")
# 2027 is not a leap year
result = _next_yearly(2, 29, 12, 0, tz)
# Should clamp to 28 if the computed year is non-leap
if result.year % 4 != 0:
assert result.day == 28
else:
assert result.day == 29
def test_timezone_aware(self):
tz = ZoneInfo("Europe/Brussels")
result = _next_yearly(6, 15, 14, 30, tz)
assert result.tzinfo == tz
assert result.hour == 14
assert result.minute == 30
# ---------------------------------------------------------------------------
# Calendar helpers: _get_tz
# ---------------------------------------------------------------------------
class TestGetTz:
def test_default_utc(self):
bot = _FakeBot()
bot.config = {"bot": {}}
assert _get_tz(bot) == ZoneInfo("UTC")
def test_configured_timezone(self):
bot = _FakeBot(tz="Europe/Brussels")
assert _get_tz(bot) == ZoneInfo("Europe/Brussels")
def test_invalid_timezone_falls_back(self):
bot = _FakeBot()
bot.config = {"bot": {"timezone": "Not/A/Timezone"}}
assert _get_tz(bot) == ZoneInfo("UTC")
def test_missing_bot_section(self):
bot = _FakeBot()
bot.config = {}
assert _get_tz(bot) == ZoneInfo("UTC")
# ---------------------------------------------------------------------------
# cmd_remind at: calendar one-shot
# ---------------------------------------------------------------------------
class TestCmdRemindAt:
def _extract_rid(self, reply: str) -> str:
return reply.split("#")[1].split(" ")[0]
def test_valid_future_date(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} deploy release"))
asyncio.run(inner())
assert len(bot.replied) == 1
assert "set (at" in bot.replied[0]
assert "deploy release" not in bot.replied[0] # label not in confirmation
def test_past_date_rejected(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind at 2020-01-01 old stuff"))
asyncio.run(inner())
assert "past" in bot.replied[0].lower()
def test_default_time_noon(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} no time given"))
asyncio.run(inner())
assert "12:00" in bot.replied[0]
def test_with_explicit_time(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd_and_cleanup(bot, _msg(f"!remind at {future} 14:30 deploy"))
asyncio.run(inner())
assert "14:30" in bot.replied[0]
def test_stores_in_state(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd(bot, _msg(f"!remind at {future} persist me"))
rid = self._extract_rid(bot.replied[0])
raw = bot.state.get("remind", rid)
assert raw is not None
data = json.loads(raw)
assert data["type"] == "at"
assert data["nick"] == "alice"
assert data["label"] == "persist me"
assert rid in _calendar
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_invalid_date_format(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind at not-a-date some text"))
asyncio.run(inner())
assert "Invalid date" in bot.replied[0]
def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind at"))
asyncio.run(inner())
assert "Usage:" in bot.replied[0]
# ---------------------------------------------------------------------------
# cmd_remind yearly: recurring
# ---------------------------------------------------------------------------
class TestCmdRemindYearly:
def _extract_rid(self, reply: str) -> str:
return reply.split("#")[1].split(" ")[0]
def test_valid_creation(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 06-15 happy anniversary"))
asyncio.run(inner())
assert len(bot.replied) == 1
assert "yearly 06-15" in bot.replied[0]
def test_invalid_date(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 13-01 bad month"))
asyncio.run(inner())
assert "Invalid date" in bot.replied[0]
def test_invalid_day(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 04-31 too many days"))
asyncio.run(inner())
assert "Invalid date" in bot.replied[0]
def test_stores_in_state(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd(bot, _msg("!remind yearly 02-14 valentines"))
rid = self._extract_rid(bot.replied[0])
raw = bot.state.get("remind", rid)
assert raw is not None
data = json.loads(raw)
assert data["type"] == "yearly"
assert data["month_day"] == "02-14"
assert data["nick"] == "alice"
assert rid in _calendar
for e in _reminders.values():
e[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_with_explicit_time(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 12-25 09:00 merry christmas"))
asyncio.run(inner())
assert "yearly 12-25" in bot.replied[0]
def test_no_args_shows_usage(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly"))
asyncio.run(inner())
assert "Usage:" in bot.replied[0]
def test_leap_day_allowed(self):
_clear()
bot = _FakeBot()
async def inner():
await _run_cmd_and_cleanup(bot, _msg("!remind yearly 02-29 leap day birthday"))
asyncio.run(inner())
assert "yearly 02-29" in bot.replied[0]
# ---------------------------------------------------------------------------
# Calendar persistence
# ---------------------------------------------------------------------------
class TestCalendarPersistence:
def _extract_rid(self, reply: str) -> str:
return reply.split("#")[1].split(" ")[0]
def test_save_and_load_roundtrip(self):
bot = _FakeBot()
data = {
"type": "at",
"target": "#test",
"nick": "alice",
"label": "test label",
"fire_iso": "2099-06-15T12:00:00+00:00",
"month_day": None,
"time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "abc123", data)
raw = bot.state.get("remind", "abc123")
assert raw is not None
loaded = json.loads(raw)
assert loaded == data
def test_delete_removes(self):
bot = _FakeBot()
_save(bot, "abc123", {"type": "at"})
_delete_saved(bot, "abc123")
assert bot.state.get("remind", "abc123") is None
def test_cancel_deletes_from_state(self):
_clear()
bot = _FakeBot()
future = (datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%d")
async def inner():
await _run_cmd(bot, _msg(f"!remind at {future} cancel me"))
rid = self._extract_rid(bot.replied[0])
assert bot.state.get("remind", rid) is not None
bot.replied.clear()
await cmd_remind(bot, _msg(f"!remind cancel {rid}"))
await asyncio.sleep(0)
assert bot.state.get("remind", rid) is None
asyncio.run(inner())
def test_at_fire_deletes_from_state(self):
_clear()
bot = _FakeBot()
async def inner():
rid = "fire01"
fire_dt = datetime.now(timezone.utc) + timedelta(seconds=0)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "fire now", "fire_iso": fire_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, rid, data)
_calendar.add(rid)
task = asyncio.create_task(
_schedule_at(bot, rid, "#ch", "alice", "fire now", fire_dt, "12:00:00 UTC"),
)
_reminders[rid] = (task, "#ch", "alice", "fire now", "12:00:00 UTC", False)
_by_user[("#ch", "alice")] = [rid]
await task
assert bot.state.get("remind", rid) is None
asyncio.run(inner())
# ---------------------------------------------------------------------------
# Restore
# ---------------------------------------------------------------------------
class TestRestore:
def test_restores_at_from_state(self):
_clear()
bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "future task", "fire_iso": fire_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "rest01", data)
async def inner():
_restore(bot)
assert "rest01" in _reminders
assert "rest01" in _calendar
entry = _reminders["rest01"]
assert not entry[0].done()
entry[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_restores_yearly_from_state(self):
_clear()
bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(days=180)
data = {
"type": "yearly", "target": "#ch", "nick": "bob",
"label": "anniversary", "fire_iso": fire_dt.isoformat(),
"month_day": "06-15", "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "rest02", data)
async def inner():
_restore(bot)
assert "rest02" in _reminders
assert "rest02" in _calendar
entry = _reminders["rest02"]
assert not entry[0].done()
entry[0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_skips_active_rids(self):
_clear()
bot = _FakeBot()
fire_dt = datetime.now(timezone.utc) + timedelta(hours=1)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "active", "fire_iso": fire_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "skip01", data)
async def inner():
# Pre-populate with an active task
dummy = asyncio.create_task(asyncio.sleep(9999))
_reminders["skip01"] = (dummy, "#ch", "alice", "active", "12:00:00 UTC", False)
_restore(bot)
# Should still be the dummy task, not replaced
assert _reminders["skip01"][0] is dummy
dummy.cancel()
await asyncio.sleep(0)
asyncio.run(inner())
def test_past_at_cleaned_up(self):
_clear()
bot = _FakeBot()
past_dt = datetime.now(timezone.utc) - timedelta(hours=1)
data = {
"type": "at", "target": "#ch", "nick": "alice",
"label": "expired", "fire_iso": past_dt.isoformat(),
"month_day": None, "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "past01", data)
async def inner():
_restore(bot)
# Past at-reminder should be deleted from state, not scheduled
assert "past01" not in _reminders
assert bot.state.get("remind", "past01") is None
asyncio.run(inner())
def test_past_yearly_recalculated(self):
_clear()
bot = _FakeBot()
past_dt = datetime.now(timezone.utc) - timedelta(days=30)
data = {
"type": "yearly", "target": "#ch", "nick": "alice",
"label": "birthday", "fire_iso": past_dt.isoformat(),
"month_day": "01-01", "time_str": "12:00",
"created": "12:00:00 UTC",
}
_save(bot, "yearly01", data)
async def inner():
_restore(bot)
assert "yearly01" in _reminders
# fire_iso should have been updated to a future date
raw = bot.state.get("remind", "yearly01")
updated = json.loads(raw)
new_fire = datetime.fromisoformat(updated["fire_iso"])
assert new_fire > datetime.now(timezone.utc)
_reminders["yearly01"][0].cancel()
await asyncio.sleep(0)
asyncio.run(inner())