221 lines
6.6 KiB
Python
221 lines
6.6 KiB
Python
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"
|