Build reusable bot framework
This commit is contained in:
220
tests/unit/test_reminders.py
Normal file
220
tests/unit/test_reminders.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.reminders.commands import _errorMessage, handleReminder, validateReminder
|
||||
from modules.reminders.service import _nextRun, normalizeRecurrence
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
{"action": "list"},
|
||||
{"action": "cancel", "reminder_id": "reminder-1"},
|
||||
{"action": "set_timezone", "timezone": "America/Chicago"},
|
||||
{
|
||||
"action": "create",
|
||||
"message": "call home",
|
||||
"run_at": "2999-03-08T09:00:00-05:00",
|
||||
"recurrence": {"frequency": "weekly", "interval": 2},
|
||||
},
|
||||
{"needs_clarification": "What time should I use?"},
|
||||
],
|
||||
)
|
||||
def test_reminder_validator_accepts_supported_commands(command):
|
||||
assert validateReminder(command) == []
|
||||
|
||||
|
||||
def test_reminder_validator_reports_all_invalid_create_fields():
|
||||
errors = validateReminder(
|
||||
{
|
||||
"action": "create",
|
||||
"message": " ",
|
||||
"run_at": "2026-03-08T09:00:00",
|
||||
"recurrence": {"frequency": "hourly"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "create requires a reminder message" in errors
|
||||
assert "run_at must include a timezone offset" in errors
|
||||
assert "recurrence frequency must be daily or weekly" in errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(None, None),
|
||||
("once", None),
|
||||
("daily", {"frequency": "daily", "interval": 1}),
|
||||
(
|
||||
{"frequency": "WEEKLY", "interval": "3"},
|
||||
{"frequency": "weekly", "interval": 3},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_recurrence_normalization(value, expected):
|
||||
assert normalizeRecurrence(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"hourly",
|
||||
{"frequency": "daily", "interval": 0},
|
||||
{"frequency": "weekly", "interval": 366},
|
||||
{"frequency": "daily", "interval": "many"},
|
||||
],
|
||||
)
|
||||
def test_recurrence_normalization_rejects_invalid_values(value):
|
||||
with pytest.raises(ValueError):
|
||||
normalizeRecurrence(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("scheduled_for", "expected"),
|
||||
[
|
||||
# America/Chicago enters daylight time on March 8, 2026.
|
||||
(
|
||||
datetime(2026, 3, 7, 15, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 3, 8, 14, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
# It returns to standard time on November 1, 2026.
|
||||
(
|
||||
datetime(2026, 10, 31, 14, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 11, 1, 15, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_next_run_preserves_local_wall_clock_across_dst(scheduled_for, expected):
|
||||
result = _nextRun(
|
||||
scheduled_for,
|
||||
{"frequency": "daily", "interval": 1},
|
||||
"America/Chicago",
|
||||
now=scheduled_for,
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
assert result.astimezone(__import__("zoneinfo").ZoneInfo("America/Chicago")).hour == 9
|
||||
|
||||
|
||||
def test_next_run_skips_missed_intervals_after_downtime():
|
||||
scheduled_for = datetime(2026, 3, 7, 15, 0, tzinfo=timezone.utc)
|
||||
|
||||
result = _nextRun(
|
||||
scheduled_for,
|
||||
{"frequency": "daily", "interval": 1},
|
||||
"America/Chicago",
|
||||
now=datetime(2026, 3, 9, 14, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert result == datetime(2026, 3, 10, 14, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _context(response, status):
|
||||
api = SimpleNamespace(
|
||||
request=AsyncMock(return_value=(response, status)),
|
||||
timezone="UTC",
|
||||
)
|
||||
return SimpleNamespace(api=api, timezone="UTC", reply=AsyncMock())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_handler_reports_success_and_api_error():
|
||||
context = _context(
|
||||
{
|
||||
"message": "call home",
|
||||
"next_run_at": "2099-01-01T12:00:00+00:00",
|
||||
"recurrence": {"frequency": "daily"},
|
||||
},
|
||||
201,
|
||||
)
|
||||
parsed = {
|
||||
"action": "create",
|
||||
"message": "call home",
|
||||
"run_at": "2099-01-01T12:00:00Z",
|
||||
"recurrence": {"frequency": "daily"},
|
||||
}
|
||||
await handleReminder(context, parsed)
|
||||
assert "recurring" in context.reply.await_args.args[0]
|
||||
context.api.request.assert_awaited_once_with(
|
||||
"post",
|
||||
"/api/reminders",
|
||||
{
|
||||
"message": "call home",
|
||||
"run_at": "2099-01-01T12:00:00Z",
|
||||
"recurrence": {"frequency": "daily"},
|
||||
},
|
||||
)
|
||||
|
||||
context = _context({"error": "database unavailable"}, 503)
|
||||
await handleReminder(context, parsed)
|
||||
assert "database unavailable" in context.reply.await_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_handler_formats_results_and_empty_state():
|
||||
context = _context(
|
||||
{
|
||||
"reminders": [
|
||||
{
|
||||
"id": "reminder-one",
|
||||
"next_run_at": "2099-01-01T12:00:00+00:00",
|
||||
"message": "call home",
|
||||
}
|
||||
]
|
||||
},
|
||||
200,
|
||||
)
|
||||
await handleReminder(context, {"action": "list"})
|
||||
reply = context.reply.await_args.args[0]
|
||||
assert "Active reminders" in reply and "reminder-one" in reply
|
||||
|
||||
context = _context({"reminders": []}, 200)
|
||||
await handleReminder(context, {"action": "list"})
|
||||
assert context.reply.await_args.args[0] == "You have no active reminders."
|
||||
|
||||
context = _context({"error": "offline"}, 503)
|
||||
await handleReminder(context, {"action": "list"})
|
||||
assert "offline" in context.reply.await_args.args[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_and_timezone_handlers_update_context():
|
||||
context = _context({"message": "call home"}, 200)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "cancel", "reminder_id": "reminder-one"},
|
||||
)
|
||||
assert context.reply.await_args.args[0] == "Cancelled reminder: call home"
|
||||
context.api.request.assert_awaited_once_with(
|
||||
"delete", "/api/reminders/reminder-one"
|
||||
)
|
||||
|
||||
context = _context({"error": "not found"}, 404)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "cancel", "reminder_id": "missing"},
|
||||
)
|
||||
assert "not found" in context.reply.await_args.args[0]
|
||||
|
||||
context = _context({"timezone": "America/Chicago"}, 200)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "set_timezone", "timezone": "America/Chicago"},
|
||||
)
|
||||
assert context.timezone == "America/Chicago"
|
||||
assert context.api.timezone == "America/Chicago"
|
||||
|
||||
context = _context({}, 400)
|
||||
await handleReminder(
|
||||
context,
|
||||
{"action": "set_timezone", "timezone": "bad"},
|
||||
)
|
||||
assert "couldn't update" in context.reply.await_args.args[0]
|
||||
|
||||
|
||||
def test_error_message_handles_structured_and_unstructured_results():
|
||||
assert _errorMessage({"error": "detail"}, "fallback") == "fallback detail"
|
||||
assert _errorMessage("not an object", "fallback") == "fallback"
|
||||
Reference in New Issue
Block a user