fischer-agentkit/tests/unit/calendar/test_scheduler.py

257 lines
8.4 KiB
Python

"""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, status="failed")
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