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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user