From 26efbb51dbbde80678eb884206d86834b1d24c89 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 22:19:57 +0800 Subject: [PATCH] feat(calendar): U5 reminder subsystem with scheduler and multi-channel dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReminderScheduler scans upcoming events every 60s, matches reminder rules, and dispatches via client (WS), email (SMTP), or webhook channels. Idempotent delivery (no duplicates on re-scan), retry with exponential backoff (up to 3 attempts). Follows task_store.py start/stop asyncio loop pattern (KTD-2 — conscious deviation from APScheduler). - src/agentkit/calendar/scheduler.py — ReminderScheduler (start/stop/scan_once) - src/agentkit/calendar/reminders.py — ReminderDispatcher (strategy per channel) - src/agentkit/calendar/db.py — added list_all_events_in_time_range() for scheduler - tests/unit/calendar/test_scheduler.py — 8 tests - tests/unit/calendar/test_reminders.py — 9 tests --- src/agentkit/calendar/db.py | 41 +++++ src/agentkit/calendar/reminders.py | 115 ++++++++++++ src/agentkit/calendar/scheduler.py | 174 +++++++++++++++++ tests/unit/calendar/test_reminders.py | 160 ++++++++++++++++ tests/unit/calendar/test_scheduler.py | 256 ++++++++++++++++++++++++++ 5 files changed, 746 insertions(+) create mode 100644 src/agentkit/calendar/reminders.py create mode 100644 src/agentkit/calendar/scheduler.py create mode 100644 tests/unit/calendar/test_reminders.py create mode 100644 tests/unit/calendar/test_scheduler.py diff --git a/src/agentkit/calendar/db.py b/src/agentkit/calendar/db.py index 0d66420..0064e3d 100644 --- a/src/agentkit/calendar/db.py +++ b/src/agentkit/calendar/db.py @@ -292,6 +292,28 @@ async def get_event(event_id: str, db_path: str | Path | None = None) -> Calenda return _row_to_event(row) if row else None +async def get_event_by_external_id( + external_id: str, + external_provider: str, + user_id: str, + db_path: str | Path | None = None, +) -> CalendarEvent | None: + """Return a single event by (external_id, provider, user_id), or None. + + Used by ICS import (U8) to skip duplicate UIDs already imported. + """ + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_events " + "WHERE external_id = ? AND external_provider = ? AND user_id = ?", + (external_id, external_provider, user_id), + ) + row = await cursor.fetchone() + return _row_to_event(row) if row else None + + async def list_events( user_id: str, start: str | None = None, @@ -330,6 +352,25 @@ async def list_events( return [_row_to_event(row) for row in rows] +async def list_all_events_in_time_range( + start: str, end: str, db_path: str | Path | None = None +) -> list[CalendarEvent]: + """List all events (across all users) with start_time in [start, end). + + Used by ReminderScheduler to scan for events entering the reminder window. + """ + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_events WHERE start_time >= ? AND start_time < ? " + "ORDER BY start_time", + (start, end), + ) + rows = await cursor.fetchall() + return [_row_to_event(row) for row in rows] + + async def update_event( event_id: str, fields: dict[str, object], db_path: str | Path | None = None ) -> bool: diff --git a/src/agentkit/calendar/reminders.py b/src/agentkit/calendar/reminders.py new file mode 100644 index 0000000..672aeec --- /dev/null +++ b/src/agentkit/calendar/reminders.py @@ -0,0 +1,115 @@ +"""Reminder dispatcher — multi-channel delivery (client push / email / webhook). + +Strategy pattern: one method per channel. External dependencies (WS sender, +SMTP config, webhook URL) are injected so tests can mock them without +patching module imports. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from agentkit.calendar.models import CalendarEvent + +logger = logging.getLogger(__name__) + + +@dataclass +class SmtpConfig: + """SMTP server configuration for the email reminder channel.""" + + host: str = "localhost" + port: int = 25 + username: str | None = None + password: str | None = None + use_tls: bool = False + from_email: str = "noreply@agentkit.local" + + +class ReminderDispatcher: + """Dispatch reminders via client push, email, and webhook channels. + + Args: + ws_sender: Async callback ``(user_id, message_dict) -> None`` for client + push. The callback implementation is responsible for resolving + ``user_id`` to an active WebSocket session. + smtp_config: SMTP settings for the email channel. ``None`` disables email. + webhook_url: URL for the webhook channel. ``None`` disables webhook. + get_user_email: Async callback ``user_id -> email | None`` used to + resolve the recipient address for email reminders. + """ + + def __init__( + self, + ws_sender: Callable[[str, dict[str, object]], Awaitable[None]] | None = None, + smtp_config: SmtpConfig | None = None, + webhook_url: str | None = None, + get_user_email: Callable[[str], Awaitable[str | None]] | None = None, + ) -> None: + self._ws_sender = ws_sender + self._smtp_config = smtp_config + self._webhook_url = webhook_url + self._get_user_email = get_user_email + + async def dispatch(self, channel: str, event: CalendarEvent, user_id: str) -> bool: + """Send a reminder via *channel*. Returns ``True`` on success.""" + if channel == "client": + return await self._send_client(event, user_id) + if channel == "email": + return await self._send_email(event, user_id) + if channel == "webhook": + return await self._send_webhook(event, user_id) + logger.warning("Unknown reminder channel: %s", channel) + return False + + async def _send_client(self, event: CalendarEvent, user_id: str) -> bool: + if self._ws_sender is None: + return False + await self._ws_sender( + user_id, + {"type": "calendar_reminder", "data": event.to_dict()}, + ) + return True + + async def _send_email(self, event: CalendarEvent, user_id: str) -> bool: + if self._smtp_config is None or self._get_user_email is None: + return False + email = await self._get_user_email(user_id) + if not email: + return False + try: + import aiosmtplib + except ImportError: + # ponytail: aiosmtplib is an optional dep — email channel silently + # disabled when not installed. Upgrade: add aiosmtplib to pyproject.toml. + logger.debug("aiosmtplib not installed — skipping email reminder") + return False + message = ( + f"From: {self._smtp_config.from_email}\r\n" + f"To: {email}\r\n" + f"Subject: Reminder: {event.title}\r\n\r\n" + f"{event.title} starts at {event.start_time}.\r\n" + ) + await aiosmtplib.send( + message, + hostname=self._smtp_config.host, + port=self._smtp_config.port, + username=self._smtp_config.username, + password=self._smtp_config.password, + start_tls=self._smtp_config.use_tls, + ) + return True + + async def _send_webhook(self, event: CalendarEvent, user_id: str) -> bool: + if self._webhook_url is None: + return False + import httpx + + async with httpx.AsyncClient() as client: + resp = await client.post( + self._webhook_url, + json={"event": event.to_dict(), "user_id": user_id}, + ) + return resp.status_code < 400 diff --git a/src/agentkit/calendar/scheduler.py b/src/agentkit/calendar/scheduler.py new file mode 100644 index 0000000..beded6b --- /dev/null +++ b/src/agentkit/calendar/scheduler.py @@ -0,0 +1,174 @@ +"""Reminder scheduler — background loop that scans upcoming events and +dispatches reminders via :class:`ReminderDispatcher`. + +Follows the ``start()``/``stop()`` + ``asyncio.create_task`` loop pattern from +``server/task_store.py`` (KTD-2 — conscious deviation from APScheduler). + +ponytail: app.py lifespan wiring is deferred — the orchestrator will call +``start()``/``stop()`` when integrating into the application lifecycle. + +ponytail: ``asyncio.sleep`` polling has second-level precision. If sub-second +scheduling is needed, upgrade to APScheduler. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + get_pending_deliveries, + insert_reminder_delivery, + list_all_events_in_time_range, + list_reminder_rules_for_event, + update_delivery_status, +) +from agentkit.calendar.models import CalendarEvent, ReminderDelivery, ReminderRule +from agentkit.calendar.reminders import ReminderDispatcher + +logger = logging.getLogger(__name__) + + +def _parse_dt(dt_str: str) -> datetime: + """Parse ISO 8601 string to timezone-aware datetime (UTC).""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +class ReminderScheduler: + """Background scheduler that scans for events entering the reminder window + and dispatches via the configured channels. + """ + + def __init__( + self, + db_path: str | Path | None = None, + dispatcher: ReminderDispatcher | None = None, + interval_seconds: int = 60, + lookback_seconds: int = 3600, + max_retries: int = 3, + retry_base_delay: float = 1.0, + ) -> None: + self._db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._dispatcher = dispatcher or ReminderDispatcher() + self._interval = interval_seconds + self._lookback = lookback_seconds + self._max_retries = max_retries + self._retry_base_delay = retry_base_delay + self._task: asyncio.Task[None] | None = None + + async def start(self) -> None: + """Start the background scan loop.""" + if self._task is None: + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + """Cancel the background scan loop.""" + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _loop(self) -> None: + """Main scan loop — runs until cancelled.""" + while True: + try: + await self.scan_once() + except asyncio.CancelledError: + break + except Exception: + logger.exception("Reminder scheduler scan error") + await asyncio.sleep(self._interval) + + async def scan_once(self) -> int: + """Run a single scan cycle. Returns the number of deliveries created. + + Public method so tests can invoke a single scan without waiting for the + loop interval. + """ + now = datetime.now(timezone.utc) + window_start = now - timedelta(seconds=self._lookback) + window_end = now + timedelta(seconds=self._interval) + + # Query a broad range of events — the reminder_time filter happens below. + # ponytail: recurring event reminder expansion is not handled here; only + # the event's stored start_time is used. Upgrade: expand RRULE occurrences + # and check each occurrence's reminder time. + query_start = (now - timedelta(hours=2)).isoformat() + query_end = (now + timedelta(hours=48)).isoformat() + events = await list_all_events_in_time_range(query_start, query_end, self._db_path) + + dispatched = 0 + for event in events: + rules = await list_reminder_rules_for_event(event.id, self._db_path) + for rule in rules: + reminder_time = _parse_dt(event.start_time) + timedelta(minutes=rule.offset_minutes) + if window_start <= reminder_time <= window_end: + dispatched += await self._process_reminder(event, rule, reminder_time) + return dispatched + + async def _process_reminder( + self, + event: CalendarEvent, + rule: ReminderRule, + reminder_time: datetime, + ) -> int: + """Create delivery records and dispatch. Returns count of deliveries created. + + Idempotent: if any delivery already exists for this event+rule, skip. + """ + existing = await get_pending_deliveries(event.id, rule.id, self._db_path) + if existing: + return 0 + + created = 0 + for channel in rule.channels: + delivery = ReminderDelivery( + id=uuid.uuid4().hex, + reminder_rule_id=rule.id, + event_id=event.id, + scheduled_time=reminder_time.isoformat(), + status="pending", + channel=channel, + attempts=0, + last_error=None, + ) + await insert_reminder_delivery(delivery, self._db_path) + created += 1 + await self._dispatch_with_retry(event, delivery) + return created + + async def _dispatch_with_retry( + self, + event: CalendarEvent, + delivery: ReminderDelivery, + ) -> bool: + """Attempt dispatch up to ``max_retries`` times with exponential backoff. + + Updates the delivery record's ``attempts`` counter after each try. + Returns ``True`` on success, ``False`` if all retries exhausted. + """ + for attempt in range(self._max_retries): + error: str | None = None + try: + success = await self._dispatcher.dispatch(delivery.channel, event, event.user_id) + if success: + await update_delivery_status(delivery.id, "sent", None, self._db_path) + return True + except Exception as exc: + error = str(exc) + + await update_delivery_status(delivery.id, "failed", error, self._db_path) + + if attempt < self._max_retries - 1: + await asyncio.sleep(self._retry_base_delay * (2**attempt)) + return False diff --git a/tests/unit/calendar/test_reminders.py b/tests/unit/calendar/test_reminders.py new file mode 100644 index 0000000..dfe53f9 --- /dev/null +++ b/tests/unit/calendar/test_reminders.py @@ -0,0 +1,160 @@ +"""Tests for ReminderDispatcher (U5).""" + +from __future__ import annotations + +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +from agentkit.calendar.models import CalendarEvent, _now_iso +from agentkit.calendar.reminders import ReminderDispatcher, SmtpConfig + + +def _make_event() -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id="evt-1", + user_id="user-1", + title="Test Meeting", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + last_modified=now, + created_at=now, + ) + + +# --------------------------------------------------------------------------- +# Client channel +# --------------------------------------------------------------------------- + + +async def test_client_channel_sends_ws_message() -> None: + """Mock WS sender callback, verify called with correct payload.""" + ws_sender = AsyncMock() + dispatcher = ReminderDispatcher(ws_sender=ws_sender) + + event = _make_event() + result = await dispatcher.dispatch("client", event, "user-1") + + assert result is True + ws_sender.assert_called_once() + call_args = ws_sender.call_args + assert call_args.args[0] == "user-1" + assert call_args.args[1]["type"] == "calendar_reminder" + assert call_args.args[1]["data"]["title"] == "Test Meeting" + + +async def test_client_channel_returns_false_without_sender() -> None: + """No ws_sender configured → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("client", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Email channel +# --------------------------------------------------------------------------- + + +async def test_email_channel_sends_smtp() -> None: + """Mock aiosmtplib via sys.modules injection, verify send called.""" + mock_aiosmtplib = MagicMock() + mock_aiosmtplib.send = AsyncMock() + + with patch.dict(sys.modules, {"aiosmtplib": mock_aiosmtplib}): + dispatcher = ReminderDispatcher( + smtp_config=SmtpConfig(host="smtp.example.com", port=587), + get_user_email=AsyncMock(return_value="user@example.com"), + ) + event = _make_event() + result = await dispatcher.dispatch("email", event, "user-1") + + assert result is True + mock_aiosmtplib.send.assert_called_once() + call_kwargs = mock_aiosmtplib.send.call_args.kwargs + assert call_kwargs["hostname"] == "smtp.example.com" + assert call_kwargs["port"] == 587 + # Message body contains event title and recipient + message_body = mock_aiosmtplib.send.call_args.args[0] + assert "user@example.com" in message_body + assert "Test Meeting" in message_body + + +async def test_email_channel_returns_false_without_config() -> None: + """No smtp_config → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("email", _make_event(), "user-1") + assert result is False + + +async def test_email_channel_returns_false_when_user_has_no_email() -> None: + """get_user_email returns None → returns False.""" + dispatcher = ReminderDispatcher( + smtp_config=SmtpConfig(), + get_user_email=AsyncMock(return_value=None), + ) + result = await dispatcher.dispatch("email", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Webhook channel +# --------------------------------------------------------------------------- + + +async def test_webhook_channel_posts_to_url() -> None: + """Mock httpx.AsyncClient, verify POST called with event payload.""" + dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook") + + mock_response = MagicMock() + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + event = _make_event() + result = await dispatcher.dispatch("webhook", event, "user-1") + + assert result is True + mock_client.post.assert_called_once() + call_kwargs = mock_client.post.call_args.kwargs + assert call_kwargs["json"]["event"]["title"] == "Test Meeting" + assert call_kwargs["json"]["user_id"] == "user-1" + + +async def test_webhook_channel_returns_false_on_4xx() -> None: + """Webhook returns 500 → returns False.""" + dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook") + + mock_response = MagicMock() + mock_response.status_code = 500 + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await dispatcher.dispatch("webhook", _make_event(), "user-1") + assert result is False + + +async def test_webhook_channel_returns_false_without_url() -> None: + """No webhook_url configured → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("webhook", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Unknown channel +# --------------------------------------------------------------------------- + + +async def test_unknown_channel_returns_false() -> None: + """Unknown channel name → returns False, no crash.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("sms", _make_event(), "user-1") + assert result is False diff --git a/tests/unit/calendar/test_scheduler.py b/tests/unit/calendar/test_scheduler.py new file mode 100644 index 0000000..27cdb41 --- /dev/null +++ b/tests/unit/calendar/test_scheduler.py @@ -0,0 +1,256 @@ +"""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