Skip to content

Commit 4bc6e29

Browse files
Félix Díez AriasFélix Díez Arias
authored andcommitted
feat(schedule-command): add unit tests for schedule handler
16 tests covering all subcommands (list, add, remove, pause, resume), argument parsing edge cases, error handling, and the scheduler-not- available fallback path. 100% coverage on the handler module.
1 parent dc34913 commit 4bc6e29

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Tests for the /schedule command handler."""
2+
3+
import tempfile
4+
from pathlib import Path
5+
from unittest.mock import AsyncMock, MagicMock, patch
6+
7+
import pytest
8+
9+
from src.bot.handlers.schedule import schedule_command
10+
11+
12+
@pytest.fixture
13+
def tmp_dir():
14+
with tempfile.TemporaryDirectory() as d:
15+
yield Path(d)
16+
17+
18+
@pytest.fixture
19+
def mock_scheduler():
20+
scheduler = AsyncMock()
21+
scheduler.list_jobs = AsyncMock(return_value=[])
22+
scheduler.add_job = AsyncMock(return_value="job-123")
23+
scheduler.remove_job = AsyncMock(return_value=True)
24+
scheduler.pause_job = AsyncMock(return_value=True)
25+
scheduler.resume_job = AsyncMock(return_value=True)
26+
return scheduler
27+
28+
29+
@pytest.fixture
30+
def update():
31+
update = MagicMock()
32+
update.message = MagicMock()
33+
update.message.reply_text = AsyncMock()
34+
update.message.reply_html = AsyncMock()
35+
update.effective_chat = MagicMock()
36+
update.effective_chat.id = 12345
37+
update.effective_user = MagicMock()
38+
update.effective_user.id = 67890
39+
return update
40+
41+
42+
@pytest.fixture
43+
def context(mock_scheduler, tmp_dir):
44+
context = MagicMock()
45+
context.bot_data = {
46+
"scheduler": mock_scheduler,
47+
"settings": MagicMock(approved_directory=tmp_dir),
48+
}
49+
context.user_data = {"current_directory": tmp_dir}
50+
context.args = []
51+
return context
52+
53+
54+
async def test_no_scheduler_shows_error(update, context):
55+
"""When scheduler is not injected, show error message."""
56+
context.bot_data["scheduler"] = None
57+
await schedule_command(update, context)
58+
update.message.reply_text.assert_called_once()
59+
assert "not enabled" in update.message.reply_text.call_args[0][0]
60+
61+
62+
async def test_no_args_shows_usage(update, context):
63+
"""No subcommand shows usage info."""
64+
context.args = []
65+
await schedule_command(update, context)
66+
update.message.reply_html.assert_called_once()
67+
assert "Usage" in update.message.reply_html.call_args[0][0]
68+
69+
70+
async def test_unknown_subcommand_shows_usage(update, context):
71+
"""Unknown subcommand shows usage info."""
72+
context.args = ["unknown"]
73+
await schedule_command(update, context)
74+
update.message.reply_html.assert_called_once()
75+
assert "Usage" in update.message.reply_html.call_args[0][0]
76+
77+
78+
async def test_list_empty(update, context, mock_scheduler):
79+
"""List with no jobs shows empty message."""
80+
context.args = ["list"]
81+
await schedule_command(update, context)
82+
mock_scheduler.list_jobs.assert_called_once_with(include_paused=True)
83+
update.message.reply_text.assert_called_once_with("No scheduled jobs.")
84+
85+
86+
async def test_list_with_jobs(update, context, mock_scheduler):
87+
"""List with jobs formats them correctly."""
88+
mock_scheduler.list_jobs.return_value = [
89+
{
90+
"job_id": "abc-123",
91+
"job_name": "daily-report",
92+
"cron_expression": "0 9 * * *",
93+
"is_active": 1,
94+
},
95+
{
96+
"job_id": "def-456",
97+
"job_name": "weekly-backup",
98+
"cron_expression": "0 0 * * 0",
99+
"is_active": 0,
100+
},
101+
]
102+
context.args = ["list"]
103+
await schedule_command(update, context)
104+
html = update.message.reply_html.call_args[0][0]
105+
assert "daily-report" in html
106+
assert "active" in html
107+
assert "weekly-backup" in html
108+
assert "paused" in html
109+
assert "abc-123" in html
110+
111+
112+
async def test_add_success(update, context, mock_scheduler, tmp_dir):
113+
"""Add creates a job with correct parameters."""
114+
context.args = ["add", "my-job", "0", "9", "*", "*", "1-5", "Run", "status", "check"]
115+
await schedule_command(update, context)
116+
117+
mock_scheduler.add_job.assert_called_once_with(
118+
job_name="my-job",
119+
cron_expression="0 9 * * 1-5",
120+
prompt="Run status check",
121+
target_chat_ids=[12345],
122+
working_directory=tmp_dir,
123+
created_by=67890,
124+
)
125+
html = update.message.reply_html.call_args[0][0]
126+
assert "my-job" in html
127+
assert "job-123" in html
128+
129+
130+
async def test_add_too_few_args(update, context):
131+
"""Add with insufficient args shows usage."""
132+
context.args = ["add", "my-job", "0", "9"]
133+
await schedule_command(update, context)
134+
update.message.reply_html.assert_called_once()
135+
assert "Usage" in update.message.reply_html.call_args[0][0]
136+
137+
138+
async def test_add_failure(update, context, mock_scheduler):
139+
"""Add handles scheduler errors gracefully."""
140+
mock_scheduler.add_job.side_effect = ValueError("Invalid cron expression")
141+
context.args = ["add", "bad-job", "x", "y", "z", "a", "b", "Do", "something"]
142+
await schedule_command(update, context)
143+
update.message.reply_text.assert_called_once()
144+
assert "Failed" in update.message.reply_text.call_args[0][0]
145+
146+
147+
async def test_remove(update, context, mock_scheduler):
148+
"""Remove calls scheduler.remove_job."""
149+
context.args = ["remove", "job-123"]
150+
await schedule_command(update, context)
151+
mock_scheduler.remove_job.assert_called_once_with("job-123")
152+
html = update.message.reply_html.call_args[0][0]
153+
assert "job-123" in html
154+
assert "removed" in html
155+
156+
157+
async def test_remove_no_id(update, context):
158+
"""Remove without job_id shows usage."""
159+
context.args = ["remove"]
160+
await schedule_command(update, context)
161+
update.message.reply_text.assert_called_once()
162+
assert "Usage" in update.message.reply_text.call_args[0][0]
163+
164+
165+
async def test_pause_success(update, context, mock_scheduler):
166+
"""Pause calls scheduler.pause_job."""
167+
context.args = ["pause", "job-123"]
168+
await schedule_command(update, context)
169+
mock_scheduler.pause_job.assert_called_once_with("job-123")
170+
html = update.message.reply_html.call_args[0][0]
171+
assert "paused" in html
172+
173+
174+
async def test_pause_not_found(update, context, mock_scheduler):
175+
"""Pause with unknown job_id shows not found."""
176+
mock_scheduler.pause_job.return_value = False
177+
context.args = ["pause", "unknown-id"]
178+
await schedule_command(update, context)
179+
update.message.reply_text.assert_called_once()
180+
assert "not found" in update.message.reply_text.call_args[0][0]
181+
182+
183+
async def test_pause_no_id(update, context):
184+
"""Pause without job_id shows usage."""
185+
context.args = ["pause"]
186+
await schedule_command(update, context)
187+
update.message.reply_text.assert_called_once()
188+
assert "Usage" in update.message.reply_text.call_args[0][0]
189+
190+
191+
async def test_resume_success(update, context, mock_scheduler):
192+
"""Resume calls scheduler.resume_job."""
193+
context.args = ["resume", "job-123"]
194+
await schedule_command(update, context)
195+
mock_scheduler.resume_job.assert_called_once_with("job-123")
196+
html = update.message.reply_html.call_args[0][0]
197+
assert "resumed" in html
198+
199+
200+
async def test_resume_not_found(update, context, mock_scheduler):
201+
"""Resume with unknown job_id shows failure message."""
202+
mock_scheduler.resume_job.return_value = False
203+
context.args = ["resume", "unknown-id"]
204+
await schedule_command(update, context)
205+
update.message.reply_text.assert_called_once()
206+
assert "not found" in update.message.reply_text.call_args[0][0]
207+
208+
209+
async def test_resume_no_id(update, context):
210+
"""Resume without job_id shows usage."""
211+
context.args = ["resume"]
212+
await schedule_command(update, context)
213+
update.message.reply_text.assert_called_once()
214+
assert "Usage" in update.message.reply_text.call_args[0][0]

0 commit comments

Comments
 (0)