257 lines
8.4 KiB
Python
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
|