stage-8: cron scheduler with APScheduler

Scheduler class, cron/jobs.json, Discord /cron commands, CLI cron subcommand, job lifecycle management. 88 new tests (281 total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 16:12:56 +00:00
parent 09d3de003a
commit 24a4d87f8c
8 changed files with 1640 additions and 1 deletions

View File

@@ -656,3 +656,235 @@ class TestLogsSlashCommand:
msg = interaction.response.send_message.call_args
assert "no log file" in msg.args[0].lower()
# --- /cron slash commands ---
class TestCronList:
@pytest.mark.asyncio
async def test_cron_list_shows_table(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.list_jobs.return_value = [
{
"name": "daily-summary",
"cron": "30 6 * * *",
"channel": "work",
"model": "sonnet",
"enabled": True,
"last_status": "ok",
"next_run": "2025-01-15T06:30:00+00:00",
}
]
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "list")
assert cmd is not None
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
text = msg.args[0]
assert "daily-summary" in text
assert "30 6 * * *" in text
assert "```" in text
@pytest.mark.asyncio
async def test_cron_list_empty(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.list_jobs.return_value = []
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "list")
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "no scheduled jobs" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_list_no_scheduler(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "list")
interaction = _mock_interaction()
await cmd.callback(interaction)
msg = interaction.response.send_message.call_args
assert "not available" in msg.args[0].lower()
class TestCronRun:
@pytest.mark.asyncio
async def test_cron_run_defers_and_runs(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.run_job = AsyncMock(return_value="Job output here")
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "run")
assert cmd is not None
interaction = _mock_interaction()
interaction.followup = AsyncMock()
await cmd.callback(interaction, name="my-job")
interaction.response.defer.assert_awaited_once()
mock_scheduler.run_job.assert_awaited_once_with("my-job")
interaction.followup.send.assert_awaited_once_with("Job output here")
@pytest.mark.asyncio
async def test_cron_run_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.run_job = AsyncMock(side_effect=KeyError("Job 'nope' not found"))
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "run")
interaction = _mock_interaction()
interaction.followup = AsyncMock()
await cmd.callback(interaction, name="nope")
interaction.response.defer.assert_awaited_once()
msg = interaction.followup.send.call_args
assert "not found" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_run_no_scheduler(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "run")
interaction = _mock_interaction()
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "not available" in msg.args[0].lower()
class TestCronAdd:
@pytest.mark.asyncio
async def test_cron_add_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "add")
assert cmd is not None
# Non-admin user
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test", expression="0 * * * *", model=None)
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_add_rejects_non_admin(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "add")
interaction = _mock_interaction(user_id="888")
await cmd.callback(interaction, name="test", expression="0 * * * *", model=None)
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
class TestCronRemove:
@pytest.mark.asyncio
async def test_cron_remove_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
assert cmd is not None
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_remove_success(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.remove_job.return_value = True
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
interaction = _mock_interaction(user_id="111") # owner
await cmd.callback(interaction, name="my-job")
msg = interaction.response.send_message.call_args
assert "removed" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_remove_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.remove_job.return_value = False
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "remove")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="ghost")
msg = interaction.response.send_message.call_args
assert "not found" in msg.args[0].lower()
class TestCronEnable:
@pytest.mark.asyncio
async def test_cron_enable_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
assert cmd is not None
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_enable_success(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.enable_job.return_value = True
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="my-job")
msg = interaction.response.send_message.call_args
assert "enabled" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_enable_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.enable_job.return_value = False
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "enable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="ghost")
msg = interaction.response.send_message.call_args
assert "not found" in msg.args[0].lower()
class TestCronDisable:
@pytest.mark.asyncio
async def test_cron_disable_admin_only(self, owned_bot):
cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
assert cmd is not None
interaction = _mock_interaction(user_id="999")
await cmd.callback(interaction, name="test")
msg = interaction.response.send_message.call_args
assert "admin only" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_disable_success(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.disable_job.return_value = True
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="my-job")
msg = interaction.response.send_message.call_args
assert "disabled" in msg.args[0].lower()
@pytest.mark.asyncio
async def test_cron_disable_not_found(self, owned_bot):
mock_scheduler = MagicMock()
mock_scheduler.disable_job.return_value = False
owned_bot.scheduler = mock_scheduler
cmd = _find_subcommand(owned_bot.tree, "cron", "disable")
interaction = _mock_interaction(user_id="111")
await cmd.callback(interaction, name="ghost")
msg = interaction.response.send_message.call_args
assert "not found" in msg.args[0].lower()