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"