diff --git a/plugins/cron.py b/plugins/cron.py new file mode 100644 index 0000000..f0cbc98 --- /dev/null +++ b/plugins/cron.py @@ -0,0 +1,288 @@ +"""Plugin: scheduled command execution on a timer.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import re +import time + +from derp.irc import Message +from derp.plugin import command, event + +# -- Constants --------------------------------------------------------------- + +_DURATION_RE = re.compile(r"(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") +_MIN_INTERVAL = 60 +_MAX_INTERVAL = 604800 # 7 days +_MAX_JOBS = 20 + +# -- Module-level tracking --------------------------------------------------- + +_jobs: dict[str, dict] = {} +_tasks: dict[str, asyncio.Task] = {} + + +# -- Pure helpers ------------------------------------------------------------ + +def _make_id(channel: str, cmd: str) -> str: + """Generate a short hex ID from channel + command + timestamp.""" + raw = f"{channel}:{cmd}:{time.monotonic()}".encode() + return hashlib.sha256(raw).hexdigest()[:6] + + +def _parse_duration(spec: str) -> int | None: + """Parse a duration like '5m', '1h30m', '2d', '90s', or raw seconds.""" + try: + secs = int(spec) + return secs if secs > 0 else None + except ValueError: + pass + m = _DURATION_RE.match(spec.lower()) + if not m or not any(m.groups()): + return None + days = int(m.group(1) or 0) + hours = int(m.group(2) or 0) + mins = int(m.group(3) or 0) + secs = int(m.group(4) or 0) + total = days * 86400 + hours * 3600 + mins * 60 + secs + return total if total > 0 else None + + +def _format_duration(secs: int) -> str: + """Format seconds into compact duration.""" + parts = [] + if secs >= 86400: + parts.append(f"{secs // 86400}d") + secs %= 86400 + if secs >= 3600: + parts.append(f"{secs // 3600}h") + secs %= 3600 + if secs >= 60: + parts.append(f"{secs // 60}m") + secs %= 60 + if secs or not parts: + parts.append(f"{secs}s") + return "".join(parts) + + +# -- State helpers ----------------------------------------------------------- + +def _state_key(channel: str, cron_id: str) -> str: + """Build composite state key.""" + return f"{channel}:{cron_id}" + + +def _save(bot, key: str, data: dict) -> None: + """Persist cron job to bot.state.""" + bot.state.set("cron", key, json.dumps(data)) + + +def _load(bot, key: str) -> dict | None: + """Load cron job from bot.state.""" + raw = bot.state.get("cron", key) + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def _delete(bot, key: str) -> None: + """Remove cron job from bot.state.""" + bot.state.delete("cron", key) + + +# -- Cron loop --------------------------------------------------------------- + +async def _cron_loop(bot, key: str) -> None: + """Repeating loop: sleep, then dispatch the stored command.""" + try: + while True: + data = _jobs.get(key) + if not data: + return + await asyncio.sleep(data["interval"]) + # Synthesize a message for command dispatch + msg = Message( + raw="", prefix=data["prefix"], + nick=data["nick"], command="PRIVMSG", + params=[data["channel"], data["command"]], tags={}, + ) + bot._dispatch_command(msg) + except asyncio.CancelledError: + pass + + +def _start_job(bot, key: str) -> None: + """Create and track a cron task.""" + existing = _tasks.get(key) + if existing and not existing.done(): + return + task = asyncio.create_task(_cron_loop(bot, key)) + _tasks[key] = task + + +def _stop_job(key: str) -> None: + """Cancel and remove a cron task.""" + task = _tasks.pop(key, None) + if task and not task.done(): + task.cancel() + _jobs.pop(key, None) + + +# -- Restore on connect ----------------------------------------------------- + +def _restore(bot) -> None: + """Rebuild cron tasks from persisted state.""" + for key in bot.state.keys("cron"): + existing = _tasks.get(key) + if existing and not existing.done(): + continue + data = _load(bot, key) + if data is None: + continue + _jobs[key] = data + _start_job(bot, key) + + +@event("001") +async def on_connect(bot, message): + """Restore cron jobs on connect.""" + _restore(bot) + + +# -- Command handler --------------------------------------------------------- + +@command("cron", help="Cron: !cron add|del|list", admin=True) +async def cmd_cron(bot, message): + """Scheduled command execution on a timer. + + Usage: + !cron add <#channel> Schedule a command + !cron del Remove a job + !cron list List jobs + """ + parts = message.text.split(None, 4) + if len(parts) < 2: + await bot.reply(message, "Usage: !cron [args]") + return + + sub = parts[1].lower() + + # -- list ---------------------------------------------------------------- + if sub == "list": + if not message.is_channel: + await bot.reply(message, "Use this command in a channel") + return + channel = message.target + prefix = f"{channel}:" + entries = [] + for key in bot.state.keys("cron"): + if key.startswith(prefix): + data = _load(bot, key) + if data: + cron_id = data["id"] + interval = _format_duration(data["interval"]) + cmd = data["command"] + entries.append(f"#{cron_id} every {interval}: {cmd}") + if not entries: + await bot.reply(message, "No cron jobs in this channel") + return + for entry in entries: + await bot.reply(message, entry) + return + + # -- del ----------------------------------------------------------------- + if sub == "del": + if len(parts) < 3: + await bot.reply(message, "Usage: !cron del ") + return + cron_id = parts[2].lstrip("#") + # Find matching key across all channels + found_key = None + for key in bot.state.keys("cron"): + data = _load(bot, key) + if data and data["id"] == cron_id: + found_key = key + break + if not found_key: + await bot.reply(message, f"No cron job #{cron_id}") + return + _stop_job(found_key) + _delete(bot, found_key) + await bot.reply(message, f"Removed cron #{cron_id}") + return + + # -- add ----------------------------------------------------------------- + if sub == "add": + if not message.is_channel: + await bot.reply(message, "Use this command in a channel") + return + if len(parts) < 5: + await bot.reply( + message, + "Usage: !cron add <#channel> ", + ) + return + + interval_spec = parts[2] + target_channel = parts[3] + cmd_text = parts[4] + + if not target_channel.startswith(("#", "&")): + await bot.reply(message, "Target must be a channel (e.g. #ops)") + return + + interval = _parse_duration(interval_spec) + if interval is None: + await bot.reply(message, "Invalid interval (use: 5m, 1h, 2d)") + return + if interval < _MIN_INTERVAL: + await bot.reply( + message, + f"Minimum interval is {_format_duration(_MIN_INTERVAL)}", + ) + return + if interval > _MAX_INTERVAL: + await bot.reply( + message, + f"Maximum interval is {_format_duration(_MAX_INTERVAL)}", + ) + return + + # Check per-channel limit + ch_prefix = f"{target_channel}:" + count = sum( + 1 for k in bot.state.keys("cron") if k.startswith(ch_prefix) + ) + if count >= _MAX_JOBS: + await bot.reply(message, f"Job limit reached ({_MAX_JOBS})") + return + + cron_id = _make_id(target_channel, cmd_text) + key = _state_key(target_channel, cron_id) + + data = { + "id": cron_id, + "channel": target_channel, + "command": cmd_text, + "interval": interval, + "prefix": message.prefix, + "nick": message.nick, + "added_by": message.nick, + } + _save(bot, key, data) + _jobs[key] = data + _start_job(bot, key) + + fmt_interval = _format_duration(interval) + await bot.reply( + message, + f"Cron #{cron_id}: {cmd_text} every {fmt_interval} in {target_channel}", + ) + return + + await bot.reply(message, "Usage: !cron [args]") diff --git a/tests/test_cron.py b/tests/test_cron.py new file mode 100644 index 0000000..4b80ec0 --- /dev/null +++ b/tests/test_cron.py @@ -0,0 +1,639 @@ +"""Tests for the cron plugin.""" + +import asyncio +import importlib.util +import json +import sys +from pathlib import Path + +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.cron", Path(__file__).resolve().parent.parent / "plugins" / "cron.py", +) +_mod = importlib.util.module_from_spec(_spec) +sys.modules[_spec.name] = _mod +_spec.loader.exec_module(_mod) + +from plugins.cron import ( # noqa: E402 + _MAX_JOBS, + _delete, + _format_duration, + _jobs, + _load, + _make_id, + _parse_duration, + _restore, + _save, + _start_job, + _state_key, + _stop_job, + _tasks, + cmd_cron, + on_connect, +) + +# -- Helpers ----------------------------------------------------------------- + +class _FakeState: + """In-memory stand-in for bot.state.""" + + 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, *, admin: bool = True): + self.sent: list[tuple[str, str]] = [] + self.replied: list[str] = [] + self.dispatched: list[Message] = [] + self.state = _FakeState() + self._admin = admin + self.prefix = "!" + + 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 _is_admin(self, message) -> bool: + return self._admin + + def _dispatch_command(self, msg: Message) -> None: + self.dispatched.append(msg) + + +def _msg(text: str, nick: str = "admin", 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 = "admin") -> Message: + """Create a private PRIVMSG.""" + return Message( + raw="", prefix=f"{nick}!~{nick}@host", nick=nick, + command="PRIVMSG", params=["botname", text], tags={}, + ) + + +def _clear() -> None: + """Reset module-level state between tests.""" + for task in _tasks.values(): + if task and not task.done(): + task.cancel() + _tasks.clear() + _jobs.clear() + + +# --------------------------------------------------------------------------- +# TestParseDuration +# --------------------------------------------------------------------------- + +class TestParseDuration: + def test_minutes(self): + assert _parse_duration("5m") == 300 + + def test_hours(self): + assert _parse_duration("1h") == 3600 + + def test_combined(self): + assert _parse_duration("1h30m") == 5400 + + def test_days(self): + assert _parse_duration("2d") == 172800 + + def test_seconds(self): + assert _parse_duration("90s") == 90 + + def test_raw_int(self): + assert _parse_duration("300") == 300 + + def test_zero(self): + assert _parse_duration("0") is None + + def test_negative(self): + assert _parse_duration("-5") is None + + def test_invalid(self): + assert _parse_duration("abc") is None + + def test_empty(self): + assert _parse_duration("") is None + + def test_full_combo(self): + assert _parse_duration("1d2h3m4s") == 86400 + 7200 + 180 + 4 + + +# --------------------------------------------------------------------------- +# TestFormatDuration +# --------------------------------------------------------------------------- + +class TestFormatDuration: + def test_seconds(self): + assert _format_duration(45) == "45s" + + def test_minutes(self): + assert _format_duration(300) == "5m" + + def test_hours(self): + assert _format_duration(3600) == "1h" + + def test_combined(self): + assert _format_duration(5400) == "1h30m" + + def test_days(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" + + +# --------------------------------------------------------------------------- +# TestMakeId +# --------------------------------------------------------------------------- + +class TestMakeId: + def test_returns_hex(self): + result = _make_id("#test", "!ping") + assert len(result) == 6 + int(result, 16) # should not raise + + def test_unique(self): + ids = {_make_id("#test", f"!cmd{i}") for i in range(10)} + assert len(ids) == 10 + + +# --------------------------------------------------------------------------- +# TestStateHelpers +# --------------------------------------------------------------------------- + +class TestStateHelpers: + def test_save_and_load(self): + bot = _FakeBot() + data = {"id": "abc123", "channel": "#test"} + _save(bot, "#test:abc123", data) + loaded = _load(bot, "#test:abc123") + assert loaded == data + + def test_load_missing(self): + bot = _FakeBot() + assert _load(bot, "nonexistent") is None + + def test_delete(self): + bot = _FakeBot() + _save(bot, "#test:del", {"id": "del"}) + _delete(bot, "#test:del") + assert _load(bot, "#test:del") is None + + def test_state_key(self): + assert _state_key("#ops", "abc123") == "#ops:abc123" + + def test_load_invalid_json(self): + bot = _FakeBot() + bot.state.set("cron", "bad", "not json{{{") + assert _load(bot, "bad") is None + + +# --------------------------------------------------------------------------- +# TestCmdCronAdd +# --------------------------------------------------------------------------- + +class TestCmdCronAdd: + def test_add_success(self): + _clear() + bot = _FakeBot() + + async def inner(): + await cmd_cron(bot, _msg("!cron add 5m #ops !rss check news")) + await asyncio.sleep(0) + assert len(bot.replied) == 1 + assert "Cron #" in bot.replied[0] + assert "!rss check news" in bot.replied[0] + assert "5m" in bot.replied[0] + assert "#ops" in bot.replied[0] + # Verify state persisted + keys = bot.state.keys("cron") + assert len(keys) == 1 + data = json.loads(bot.state.get("cron", keys[0])) + assert data["command"] == "!rss check news" + assert data["interval"] == 300 + assert data["channel"] == "#ops" + # Verify task started + assert len(_tasks) == 1 + _clear() + await asyncio.sleep(0) + + asyncio.run(inner()) + + def test_add_requires_channel(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _pm("!cron add 5m #ops !ping"))) + assert "Use this command in a channel" in bot.replied[0] + + def test_add_missing_args(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron add 5m"))) + assert "Usage:" in bot.replied[0] + + def test_add_invalid_interval(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron add abc #ops !ping"))) + assert "Invalid interval" in bot.replied[0] + + def test_add_interval_too_short(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron add 30s #ops !ping"))) + assert "Minimum interval" in bot.replied[0] + + def test_add_interval_too_long(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron add 30d #ops !ping"))) + assert "Maximum interval" in bot.replied[0] + + def test_add_bad_target(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron add 5m alice !ping"))) + assert "Target must be a channel" in bot.replied[0] + + def test_add_job_limit(self): + _clear() + bot = _FakeBot() + for i in range(_MAX_JOBS): + _save(bot, f"#ops:job{i}", {"id": f"job{i}", "channel": "#ops"}) + + asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping"))) + assert "limit reached" in bot.replied[0] + + def test_add_admin_required(self): + _clear() + bot = _FakeBot(admin=False) + asyncio.run(cmd_cron(bot, _msg("!cron add 5m #ops !ping"))) + # The @command(admin=True) decorator handles this via bot._dispatch_command, + # but since we call cmd_cron directly, the check is at the decorator level. + # In direct call tests, admin check is already handled by the framework. + # This test just verifies the command runs without error for non-admin. + # Framework-level denial is tested in integration tests. + + +# --------------------------------------------------------------------------- +# TestCmdCronDel +# --------------------------------------------------------------------------- + +class TestCmdCronDel: + def test_del_success(self): + _clear() + bot = _FakeBot() + + async def inner(): + await cmd_cron(bot, _msg("!cron add 5m #test !ping")) + await asyncio.sleep(0) + # Extract ID from reply + reply = bot.replied[0] + cron_id = reply.split("#")[1].split(":")[0] + bot.replied.clear() + await cmd_cron(bot, _msg(f"!cron del {cron_id}")) + assert "Removed" in bot.replied[0] + assert cron_id in bot.replied[0] + assert len(bot.state.keys("cron")) == 0 + _clear() + await asyncio.sleep(0) + + asyncio.run(inner()) + + def test_del_nonexistent(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron del nosuch"))) + assert "No cron job" in bot.replied[0] + + def test_del_missing_id(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron del"))) + assert "Usage:" in bot.replied[0] + + def test_del_with_hash_prefix(self): + _clear() + bot = _FakeBot() + + async def inner(): + await cmd_cron(bot, _msg("!cron add 5m #test !ping")) + await asyncio.sleep(0) + reply = bot.replied[0] + cron_id = reply.split("#")[1].split(":")[0] + bot.replied.clear() + await cmd_cron(bot, _msg(f"!cron del #{cron_id}")) + assert "Removed" in bot.replied[0] + _clear() + await asyncio.sleep(0) + + asyncio.run(inner()) + + +# --------------------------------------------------------------------------- +# TestCmdCronList +# --------------------------------------------------------------------------- + +class TestCmdCronList: + def test_list_empty(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron list"))) + assert "No cron jobs" in bot.replied[0] + + def test_list_populated(self): + _clear() + bot = _FakeBot() + _save(bot, "#test:abc123", { + "id": "abc123", "channel": "#test", + "command": "!rss check news", "interval": 300, + }) + asyncio.run(cmd_cron(bot, _msg("!cron list"))) + assert "#abc123" in bot.replied[0] + assert "5m" in bot.replied[0] + assert "!rss check news" in bot.replied[0] + + def test_list_requires_channel(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _pm("!cron list"))) + assert "Use this command in a channel" in bot.replied[0] + + def test_list_channel_isolation(self): + _clear() + bot = _FakeBot() + _save(bot, "#test:mine", { + "id": "mine", "channel": "#test", + "command": "!ping", "interval": 300, + }) + _save(bot, "#other:theirs", { + "id": "theirs", "channel": "#other", + "command": "!ping", "interval": 600, + }) + asyncio.run(cmd_cron(bot, _msg("!cron list"))) + assert "mine" in bot.replied[0] + assert len(bot.replied) == 1 # only the #test job + + +# --------------------------------------------------------------------------- +# TestCmdCronUsage +# --------------------------------------------------------------------------- + +class TestCmdCronUsage: + def test_no_args(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron"))) + assert "Usage:" in bot.replied[0] + + def test_unknown_subcommand(self): + _clear() + bot = _FakeBot() + asyncio.run(cmd_cron(bot, _msg("!cron foobar"))) + assert "Usage:" in bot.replied[0] + + +# --------------------------------------------------------------------------- +# TestRestore +# --------------------------------------------------------------------------- + +class TestRestore: + def test_restore_spawns_tasks(self): + _clear() + bot = _FakeBot() + data = { + "id": "abc123", "channel": "#test", + "command": "!ping", "interval": 300, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + _save(bot, "#test:abc123", data) + + async def inner(): + _restore(bot) + assert "#test:abc123" in _tasks + assert not _tasks["#test:abc123"].done() + _clear() + await asyncio.sleep(0) + + asyncio.run(inner()) + + def test_restore_skips_active(self): + _clear() + bot = _FakeBot() + data = { + "id": "active", "channel": "#test", + "command": "!ping", "interval": 300, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + _save(bot, "#test:active", data) + + async def inner(): + dummy = asyncio.create_task(asyncio.sleep(9999)) + _tasks["#test:active"] = dummy + _restore(bot) + assert _tasks["#test:active"] is dummy + dummy.cancel() + await asyncio.sleep(0) + + asyncio.run(inner()) + + def test_restore_replaces_done_task(self): + _clear() + bot = _FakeBot() + data = { + "id": "done", "channel": "#test", + "command": "!ping", "interval": 300, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + _save(bot, "#test:done", data) + + async def inner(): + done_task = asyncio.create_task(asyncio.sleep(0)) + await done_task + _tasks["#test:done"] = done_task + _restore(bot) + new_task = _tasks["#test:done"] + assert new_task is not done_task + assert not new_task.done() + _clear() + await asyncio.sleep(0) + + asyncio.run(inner()) + + def test_restore_skips_bad_json(self): + _clear() + bot = _FakeBot() + bot.state.set("cron", "#test:bad", "not json{{{") + + async def inner(): + _restore(bot) + assert "#test:bad" not in _tasks + + asyncio.run(inner()) + + def test_on_connect_calls_restore(self): + _clear() + bot = _FakeBot() + data = { + "id": "conn", "channel": "#test", + "command": "!ping", "interval": 300, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + _save(bot, "#test:conn", data) + + async def inner(): + msg = _msg("", target="botname") + await on_connect(bot, msg) + assert "#test:conn" in _tasks + _clear() + await asyncio.sleep(0) + + asyncio.run(inner()) + + +# --------------------------------------------------------------------------- +# TestCronLoop +# --------------------------------------------------------------------------- + +class TestCronLoop: + def test_dispatches_command(self): + """Cron loop dispatches a synthetic message after interval.""" + _clear() + bot = _FakeBot() + + async def inner(): + data = { + "id": "loop1", "channel": "#test", + "command": "!ping", "interval": 0.05, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + key = "#test:loop1" + _jobs[key] = data + _start_job(bot, key) + await asyncio.sleep(0.15) + _stop_job(key) + await asyncio.sleep(0) + # Should have dispatched at least once + assert len(bot.dispatched) >= 1 + msg = bot.dispatched[0] + assert msg.nick == "admin" + assert msg.params[0] == "#test" + assert msg.params[1] == "!ping" + + asyncio.run(inner()) + + def test_loop_stops_on_job_removal(self): + """Cron loop exits when job is removed from _jobs.""" + _clear() + bot = _FakeBot() + + async def inner(): + data = { + "id": "loop2", "channel": "#test", + "command": "!ping", "interval": 0.05, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + key = "#test:loop2" + _jobs[key] = data + _start_job(bot, key) + await asyncio.sleep(0.02) + _jobs.pop(key, None) + await asyncio.sleep(0.1) + task = _tasks.get(key) + if task: + assert task.done() + + asyncio.run(inner()) + + +# --------------------------------------------------------------------------- +# TestJobManagement +# --------------------------------------------------------------------------- + +class TestJobManagement: + def test_start_and_stop(self): + _clear() + bot = _FakeBot() + data = { + "id": "mgmt", "channel": "#test", + "command": "!ping", "interval": 300, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + key = "#test:mgmt" + _jobs[key] = data + + async def inner(): + _start_job(bot, key) + assert key in _tasks + assert not _tasks[key].done() + _stop_job(key) + await asyncio.sleep(0) + assert key not in _tasks + assert key not in _jobs + + asyncio.run(inner()) + + def test_start_idempotent(self): + _clear() + bot = _FakeBot() + data = { + "id": "idem", "channel": "#test", + "command": "!ping", "interval": 300, + "prefix": "admin!~admin@host", "nick": "admin", + "added_by": "admin", + } + key = "#test:idem" + _jobs[key] = data + + async def inner(): + _start_job(bot, key) + first = _tasks[key] + _start_job(bot, key) + assert _tasks[key] is first + _stop_job(key) + await asyncio.sleep(0) + + asyncio.run(inner()) + + def test_stop_nonexistent(self): + _clear() + _stop_job("#test:nonexistent")