"""Tests for ReminderScheduler (U5).""" from __future__ import annotations import asyncio import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import AsyncMock import pytest from agentkit.calendar.db import ( get_pending_deliveries, init_calendar_db, insert_event, insert_reminder_rule, list_reminder_rules_for_event, ) from agentkit.calendar.models import CalendarEvent, ReminderRule, _now_iso from agentkit.calendar.reminders import ReminderDispatcher from agentkit.calendar.scheduler import ReminderScheduler # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def db_path(tmp_path: Path) -> Path: path = tmp_path / "test_calendar.db" asyncio.run(init_calendar_db(path)) return path @pytest.fixture def auth_db_path(tmp_path: Path) -> Path: from agentkit.server.auth.models import init_auth_db path = tmp_path / "test_auth.db" asyncio.run(init_auth_db(path)) return path def _make_event( event_id: str, user_id: str, start_time: str, title: str = "Test Event", ) -> CalendarEvent: now = _now_iso() return CalendarEvent( id=event_id, user_id=user_id, title=title, start_time=start_time, end_time=start_time, last_modified=now, created_at=now, ) def _mock_dispatcher(return_value: bool = True) -> ReminderDispatcher: """Create a dispatcher with a mocked dispatch method.""" dispatcher = ReminderDispatcher() dispatcher.dispatch = AsyncMock(return_value=return_value) # type: ignore return dispatcher # --------------------------------------------------------------------------- # Scheduler scan logic # --------------------------------------------------------------------------- async def test_scheduler_finds_event_within_reminder_window(db_path: Path) -> None: """Event 10 min away, rule offset -15min → reminder_time 5 min ago → found.""" now = datetime.now(timezone.utc) event_start = (now + timedelta(minutes=10)).isoformat() event = _make_event("evt-1", "user-1", event_start, "Meeting") await insert_event(event, db_path) rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) await insert_reminder_rule(rule, db_path) dispatcher = _mock_dispatcher(return_value=True) scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) count = await scheduler.scan_once() assert count == 1 assert dispatcher.dispatch.call_count == 1 # type: ignore async def test_scheduler_skips_event_outside_window(db_path: Path) -> None: """Event 2 hours away, rule offset -15min → reminder_time 1hr45min away → not found.""" now = datetime.now(timezone.utc) event_start = (now + timedelta(hours=2)).isoformat() event = _make_event("evt-1", "user-1", event_start, "Meeting") await insert_event(event, db_path) rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) await insert_reminder_rule(rule, db_path) dispatcher = _mock_dispatcher(return_value=True) scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) count = await scheduler.scan_once() assert count == 0 assert dispatcher.dispatch.call_count == 0 # type: ignore async def test_idempotent_delivery_no_duplicate(db_path: Path) -> None: """Scheduler runs twice, only one delivery record created.""" now = datetime.now(timezone.utc) event_start = (now + timedelta(minutes=10)).isoformat() event = _make_event("evt-1", "user-1", event_start, "Meeting") await insert_event(event, db_path) rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) await insert_reminder_rule(rule, db_path) dispatcher = _mock_dispatcher(return_value=True) scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) count1 = await scheduler.scan_once() assert count1 == 1 count2 = await scheduler.scan_once() assert count2 == 0 deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path) assert len(deliveries) == 1 async def test_failed_delivery_retries_up_to_3_times(db_path: Path) -> None: """Mock channel to fail, verify 3 attempts and delivery status=failed.""" now = datetime.now(timezone.utc) event_start = (now + timedelta(minutes=10)).isoformat() event = _make_event("evt-1", "user-1", event_start, "Meeting") await insert_event(event, db_path) rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) await insert_reminder_rule(rule, db_path) dispatcher = _mock_dispatcher(return_value=False) scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher, retry_base_delay=0) await scheduler.scan_once() assert dispatcher.dispatch.call_count == 3 # type: ignore deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path) assert len(deliveries) == 1 assert deliveries[0].attempts == 3 assert deliveries[0].status == "failed" async def test_scheduler_dispatches_multiple_channels(db_path: Path) -> None: """Rule with 2 channels creates 2 delivery records.""" now = datetime.now(timezone.utc) event_start = (now + timedelta(minutes=10)).isoformat() event = _make_event("evt-1", "user-1", event_start, "Meeting") await insert_event(event, db_path) rule = ReminderRule( id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client", "email"], ) await insert_reminder_rule(rule, db_path) dispatcher = _mock_dispatcher(return_value=True) scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) count = await scheduler.scan_once() assert count == 2 assert dispatcher.dispatch.call_count == 2 # type: ignore # --------------------------------------------------------------------------- # Start/stop lifecycle # --------------------------------------------------------------------------- async def test_scheduler_start_stop_lifecycle(db_path: Path) -> None: """start() creates task, stop() cancels it.""" scheduler = ReminderScheduler(db_path=db_path, interval_seconds=1) assert scheduler._task is None await scheduler.start() assert scheduler._task is not None assert not scheduler._task.done() await scheduler.stop() assert scheduler._task is None async def test_scheduler_start_idempotent(db_path: Path) -> None: """Calling start() twice does not create a second task.""" scheduler = ReminderScheduler(db_path=db_path, interval_seconds=1) await scheduler.start() task1 = scheduler._task await scheduler.start() assert scheduler._task is task1 await scheduler.stop() # --------------------------------------------------------------------------- # Default reminders inherited from event type # --------------------------------------------------------------------------- async def test_default_reminders_inherited_from_event_type( db_path: Path, auth_db_path: Path ) -> None: """Create event with type that has default rules, verify rules cloned to event.""" from agentkit.calendar.service import CalendarService service = CalendarService(db_path=db_path, auth_db_path=auth_db_path) # Create event type et = await service.create_event_type("user-1", "Meeting") # Add a default reminder rule at the type level type_rule = ReminderRule( id=uuid.uuid4().hex, event_type_id=et.id, offset_minutes=-30, channels=["email"], ) await insert_reminder_rule(type_rule, db_path) # Create an event with this type event = await service.create_event( user_id="user-1", title="Sprint Planning", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", event_type_id=et.id, ) # Verify the type-level rule was cloned to the event event_rules = await list_reminder_rules_for_event(event.id, db_path) assert len(event_rules) == 1 assert event_rules[0].event_id == event.id assert event_rules[0].event_type_id is None assert event_rules[0].offset_minutes == -30 assert event_rules[0].channels == ["email"] # Cloned rule has a new ID assert event_rules[0].id != type_rule.id