From 2ea799f6c4097c799478eb3c560e3882b0683471 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 21:30:39 +0800 Subject: [PATCH] feat(calendar): U1 backend data model, storage & RRULE expansion Add calendar subsystem foundation mirroring documents/ pattern: - models.py: 8 dataclasses (CalendarEvent with is_invited, EventType, Tag, EventTag, ReminderRule, ReminderDelivery, ExternalCalendarConfig, Invitation) - db.py: aiosqlite bare-connection CRUD for all 8 tables with WAL mode - recurrence.py: RRULE expansion via dateutil.rrule (RFC 5545) - 16 unit tests covering DB CRUD and RRULE edge cases (DST, UNTIL, range) - Add python-dateutil>=2.9 to pyproject.toml --- pyproject.toml | 2 + src/agentkit/calendar/__init__.py | 7 + src/agentkit/calendar/db.py | 798 +++++++++++++++++++++++++ src/agentkit/calendar/models.py | 221 +++++++ src/agentkit/calendar/recurrence.py | 67 +++ tests/unit/calendar/__init__.py | 0 tests/unit/calendar/test_db.py | 305 ++++++++++ tests/unit/calendar/test_recurrence.py | 75 +++ 8 files changed, 1475 insertions(+) create mode 100644 src/agentkit/calendar/__init__.py create mode 100644 src/agentkit/calendar/db.py create mode 100644 src/agentkit/calendar/models.py create mode 100644 src/agentkit/calendar/recurrence.py create mode 100644 tests/unit/calendar/__init__.py create mode 100644 tests/unit/calendar/test_db.py create mode 100644 tests/unit/calendar/test_recurrence.py diff --git a/pyproject.toml b/pyproject.toml index 738885a..56f2e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ "pyjwt>=2.8", "bcrypt>=4.0", "aiosqlite>=0.20", + # Calendar & schedule (RRULE expansion) + "python-dateutil>=2.9", # Document processing (U1-U9) "python-docx>=1.1", "openpyxl>=3.1", diff --git a/src/agentkit/calendar/__init__.py b/src/agentkit/calendar/__init__.py new file mode 100644 index 0000000..da8354b --- /dev/null +++ b/src/agentkit/calendar/__init__.py @@ -0,0 +1,7 @@ +"""Calendar & schedule subsystem. + +Mirrors the ``documents/`` structure: ``models.py`` (dataclass DTOs), +``db.py`` (aiosqlite persistence), ``service.py`` (business logic), +``recurrence.py`` (RRULE expansion), ``scheduler.py`` (reminder loop), +``extraction.py`` (post-processing), and ``sync/`` (external calendars). +""" diff --git a/src/agentkit/calendar/db.py b/src/agentkit/calendar/db.py new file mode 100644 index 0000000..0d66420 --- /dev/null +++ b/src/agentkit/calendar/db.py @@ -0,0 +1,798 @@ +"""SQLite persistence for calendar data. + +Follows the aiosqlite bare-connection pattern from ``documents/db.py``: +no SQLAlchemy session injection, just ``async with aiosqlite.connect(...)``. +All timestamps are ISO 8601 UTC (see KTD-11). +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Mapping +from pathlib import Path + +import aiosqlite + +from agentkit.calendar.models import ( + CalendarEvent, + EventType, + ExternalCalendarConfig, + Invitation, + ReminderDelivery, + ReminderRule, + Tag, +) + +logger = logging.getLogger(__name__) + +_PROJECT_ROOT = Path(__file__).parents[3] +DEFAULT_CALENDAR_DB_PATH = Path( + os.environ.get("AGENTKIT_CALENDAR_DB", _PROJECT_ROOT / "data" / "calendar.db") +) + +_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS calendar_event_types ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#4A90D9', + is_default INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_event_types_user + ON calendar_event_types(user_id); + +CREATE TABLE IF NOT EXISTS calendar_tags ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tags_user + ON calendar_tags(user_id); + +CREATE TABLE IF NOT EXISTS calendar_events ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + is_all_day INTEGER NOT NULL DEFAULT 0, + location TEXT NOT NULL DEFAULT '', + event_type_id TEXT, + rrule TEXT, + source TEXT NOT NULL DEFAULT 'manual', + is_invited INTEGER NOT NULL DEFAULT 0, + conversation_id TEXT, + external_id TEXT, + external_provider TEXT, + last_modified TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id) +); +CREATE INDEX IF NOT EXISTS idx_events_user + ON calendar_events(user_id); +CREATE INDEX IF NOT EXISTS idx_events_start_time + ON calendar_events(start_time); +CREATE INDEX IF NOT EXISTS idx_events_external + ON calendar_events(external_id, external_provider); + +CREATE TABLE IF NOT EXISTS calendar_event_tags ( + event_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (event_id, tag_id), + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES calendar_tags(id) +); + +CREATE TABLE IF NOT EXISTS calendar_reminder_rules ( + id TEXT PRIMARY KEY, + event_id TEXT, + event_type_id TEXT, + offset_minutes INTEGER NOT NULL DEFAULT -15, + channels TEXT NOT NULL DEFAULT '["client"]', + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE, + FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id) +); + +CREATE TABLE IF NOT EXISTS calendar_reminder_deliveries ( + id TEXT PRIMARY KEY, + reminder_rule_id TEXT NOT NULL, + event_id TEXT NOT NULL, + scheduled_time TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + channel TEXT NOT NULL DEFAULT 'client', + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + FOREIGN KEY (reminder_rule_id) REFERENCES calendar_reminder_rules(id), + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_deliveries_status + ON calendar_reminder_deliveries(status, scheduled_time); + +CREATE TABLE IF NOT EXISTS calendar_external_configs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + credentials TEXT NOT NULL DEFAULT '', + sync_frequency INTEGER NOT NULL DEFAULT 30, + sync_scope TEXT NOT NULL DEFAULT '[]', + last_sync TEXT, + sync_token TEXT +); +CREATE INDEX IF NOT EXISTS idx_external_configs_user + ON calendar_external_configs(user_id); + +CREATE TABLE IF NOT EXISTS calendar_invitations ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + inviter_user_id TEXT NOT NULL, + invitee_email TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + responded_at TEXT, + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_invitations_email + ON calendar_invitations(invitee_email, status); +""" + + +async def init_calendar_db(db_path: str | Path | None = None) -> Path: + """Create all calendar tables if they do not exist. Idempotent.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + path.parent.mkdir(parents=True, exist_ok=True) + + async with aiosqlite.connect(str(path)) as db: + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.executescript(_SCHEMA_SQL) + await db.commit() + + logger.info(f"Calendar DB initialized at {path}") + return path + + +# --------------------------------------------------------------------------- +# Row → dataclass converters +# --------------------------------------------------------------------------- + + +def _row_to_event_type(row: aiosqlite.Row | Mapping[str, object]) -> EventType: + return EventType( + id=row["id"], + user_id=row["user_id"], + name=row["name"], + color=row["color"], + is_default=bool(row["is_default"]), + ) + + +def _row_to_tag(row: aiosqlite.Row | Mapping[str, object]) -> Tag: + return Tag(id=row["id"], user_id=row["user_id"], name=row["name"]) + + +def _row_to_event(row: aiosqlite.Row | Mapping[str, object]) -> CalendarEvent: + return CalendarEvent( + id=row["id"], + user_id=row["user_id"], + title=row["title"], + description=row["description"], + start_time=row["start_time"], + end_time=row["end_time"], + is_all_day=bool(row["is_all_day"]), + location=row["location"], + event_type_id=row["event_type_id"], + rrule=row["rrule"], + source=row["source"], + is_invited=bool(row["is_invited"]), + conversation_id=row["conversation_id"], + external_id=row["external_id"], + external_provider=row["external_provider"], + last_modified=row["last_modified"], + created_at=row["created_at"], + ) + + +def _row_to_reminder_rule(row: aiosqlite.Row | Mapping[str, object]) -> ReminderRule: + return ReminderRule( + id=row["id"], + event_id=row["event_id"], + event_type_id=row["event_type_id"], + offset_minutes=row["offset_minutes"], + channels=json.loads(row["channels"]), + ) + + +def _row_to_reminder_delivery(row: aiosqlite.Row | Mapping[str, object]) -> ReminderDelivery: + return ReminderDelivery( + id=row["id"], + reminder_rule_id=row["reminder_rule_id"], + event_id=row["event_id"], + scheduled_time=row["scheduled_time"], + status=row["status"], + channel=row["channel"], + attempts=row["attempts"], + last_error=row["last_error"], + ) + + +def _row_to_external_config(row: aiosqlite.Row | Mapping[str, object]) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=row["id"], + user_id=row["user_id"], + provider=row["provider"], + credentials=row["credentials"], + sync_frequency=row["sync_frequency"], + sync_scope=json.loads(row["sync_scope"]), + last_sync=row["last_sync"], + sync_token=row["sync_token"], + ) + + +def _row_to_invitation(row: aiosqlite.Row | Mapping[str, object]) -> Invitation: + return Invitation( + id=row["id"], + event_id=row["event_id"], + inviter_user_id=row["inviter_user_id"], + invitee_email=row["invitee_email"], + status=row["status"], + responded_at=row["responded_at"], + ) + + +# --------------------------------------------------------------------------- +# Event CRUD +# --------------------------------------------------------------------------- + + +async def insert_event(event: CalendarEvent, db_path: str | Path | None = None) -> None: + """Insert a calendar event.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_events (id, user_id, title, description, " + "start_time, end_time, is_all_day, location, event_type_id, rrule, " + "source, is_invited, conversation_id, external_id, external_provider, " + "last_modified, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + event.id, + event.user_id, + event.title, + event.description, + event.start_time, + event.end_time, + int(event.is_all_day), + event.location, + event.event_type_id, + event.rrule, + event.source, + int(event.is_invited), + event.conversation_id, + event.external_id, + event.external_provider, + event.last_modified, + event.created_at, + ), + ) + await db.commit() + + +async def get_event(event_id: str, db_path: str | Path | None = None) -> CalendarEvent | None: + """Return a single event by id, or None.""" + 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 id = ?", (event_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, + end: str | None = None, + event_type_id: str | None = None, + tag_id: str | None = None, + db_path: str | Path | None = None, +) -> list[CalendarEvent]: + """List events for a user with optional filters.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + query = "SELECT DISTINCT e.* FROM calendar_events e" + params: list[str] = [] + conditions: list[str] = ["e.user_id = ?"] + params.append(user_id) + + if start is not None: + conditions.append("e.start_time >= ?") + params.append(start) + if end is not None: + conditions.append("e.start_time < ?") + params.append(end) + if event_type_id is not None: + conditions.append("e.event_type_id = ?") + params.append(event_type_id) + if tag_id is not None: + query += " JOIN calendar_event_tags et ON et.event_id = e.id" + conditions.append("et.tag_id = ?") + params.append(tag_id) + + query += " WHERE " + " AND ".join(conditions) + " ORDER BY e.start_time" + + async with aiosqlite.connect(str(path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(query, tuple(params)) + 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: + """Update specific fields of an event. Returns True if a row was updated.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + # Map dataclass field names to column names, handle bool → int + column_map = { + "title": "title", + "description": "description", + "start_time": "start_time", + "end_time": "end_time", + "is_all_day": "is_all_day", + "location": "location", + "event_type_id": "event_type_id", + "rrule": "rrule", + "source": "source", + "is_invited": "is_invited", + "conversation_id": "conversation_id", + "external_id": "external_id", + "external_provider": "external_provider", + "last_modified": "last_modified", + } + set_clauses: list[str] = [] + params: list[object] = [] + for field_name, value in fields.items(): + col = column_map.get(field_name) + if col is None: + continue + if field_name in ("is_all_day", "is_invited"): + value = int(bool(value)) + set_clauses.append(f"{col} = ?") + params.append(value) + + if not set_clauses: + return False + + params.append(event_id) + sql = f"UPDATE calendar_events SET {', '.join(set_clauses)} WHERE id = ?" + + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute(sql, tuple(params)) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_event(event_id: str, db_path: str | Path | None = None) -> bool: + """Delete an event and its dependent rows. Returns True if deleted.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA foreign_keys = ON") + # Manual cascade for event_tags (no ON DELETE on junction FK in some SQLite versions) + await db.execute("DELETE FROM calendar_event_tags WHERE event_id = ?", (event_id,)) + cursor = await db.execute("DELETE FROM calendar_events WHERE id = ?", (event_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Event Type CRUD +# --------------------------------------------------------------------------- + + +async def insert_event_type(et: EventType, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "INSERT INTO calendar_event_types (id, user_id, name, color, is_default) " + "VALUES (?, ?, ?, ?, ?)", + (et.id, et.user_id, et.name, et.color, int(et.is_default)), + ) + await db.commit() + + +async def list_event_types(user_id: str, db_path: str | Path | None = None) -> list[EventType]: + 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_event_types WHERE user_id = ? ORDER BY name", + (user_id,), + ) + rows = await cursor.fetchall() + return [_row_to_event_type(row) for row in rows] + + +async def update_event_type( + type_id: str, fields: dict[str, object], db_path: str | Path | None = None +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + set_clauses: list[str] = [] + params: list[object] = [] + for field_name, value in fields.items(): + if field_name == "name": + set_clauses.append("name = ?") + params.append(value) + elif field_name == "color": + set_clauses.append("color = ?") + params.append(value) + elif field_name == "is_default": + set_clauses.append("is_default = ?") + params.append(int(bool(value))) + if not set_clauses: + return False + params.append(type_id) + sql = f"UPDATE calendar_event_types SET {', '.join(set_clauses)} WHERE id = ?" + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute(sql, tuple(params)) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_event_type(type_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute("DELETE FROM calendar_event_types WHERE id = ?", (type_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Tag CRUD +# --------------------------------------------------------------------------- + + +async def insert_tag(tag: Tag, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "INSERT INTO calendar_tags (id, user_id, name) VALUES (?, ?, ?)", + (tag.id, tag.user_id, tag.name), + ) + await db.commit() + + +async def list_tags(user_id: str, db_path: str | Path | None = None) -> list[Tag]: + 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_tags WHERE user_id = ? ORDER BY name", + (user_id,), + ) + rows = await cursor.fetchall() + return [_row_to_tag(row) for row in rows] + + +async def delete_tag(tag_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("DELETE FROM calendar_event_tags WHERE tag_id = ?", (tag_id,)) + cursor = await db.execute("DELETE FROM calendar_tags WHERE id = ?", (tag_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Event-Tag junction +# --------------------------------------------------------------------------- + + +async def add_tag_to_event(event_id: str, tag_id: str, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "INSERT OR IGNORE INTO calendar_event_tags (event_id, tag_id) VALUES (?, ?)", + (event_id, tag_id), + ) + await db.commit() + + +async def remove_tag_from_event( + event_id: str, tag_id: str, db_path: str | Path | None = None +) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "DELETE FROM calendar_event_tags WHERE event_id = ? AND tag_id = ?", + (event_id, tag_id), + ) + await db.commit() + + +async def get_event_tags(event_id: str, db_path: str | Path | None = None) -> list[Tag]: + 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 t.* FROM calendar_tags t " + "JOIN calendar_event_tags et ON et.tag_id = t.id " + "WHERE et.event_id = ?", + (event_id,), + ) + rows = await cursor.fetchall() + return [_row_to_tag(row) for row in rows] + + +# --------------------------------------------------------------------------- +# Reminder Rule CRUD +# --------------------------------------------------------------------------- + + +async def insert_reminder_rule(rule: ReminderRule, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "INSERT INTO calendar_reminder_rules " + "(id, event_id, event_type_id, offset_minutes, channels) " + "VALUES (?, ?, ?, ?, ?)", + ( + rule.id, + rule.event_id, + rule.event_type_id, + rule.offset_minutes, + json.dumps(rule.channels), + ), + ) + await db.commit() + + +async def list_reminder_rules_for_event( + event_id: str, db_path: str | Path | None = None +) -> list[ReminderRule]: + 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_reminder_rules WHERE event_id = ?", + (event_id,), + ) + rows = await cursor.fetchall() + return [_row_to_reminder_rule(row) for row in rows] + + +async def list_reminder_rules_for_type( + event_type_id: str, db_path: str | Path | None = None +) -> list[ReminderRule]: + 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_reminder_rules WHERE event_type_id = ?", + (event_type_id,), + ) + rows = await cursor.fetchall() + return [_row_to_reminder_rule(row) for row in rows] + + +async def delete_reminder_rule(rule_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute("DELETE FROM calendar_reminder_rules WHERE id = ?", (rule_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Reminder Delivery CRUD +# --------------------------------------------------------------------------- + + +async def insert_reminder_delivery( + delivery: ReminderDelivery, db_path: str | Path | None = None +) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "INSERT INTO calendar_reminder_deliveries " + "(id, reminder_rule_id, event_id, scheduled_time, status, channel, " + "attempts, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + delivery.id, + delivery.reminder_rule_id, + delivery.event_id, + delivery.scheduled_time, + delivery.status, + delivery.channel, + delivery.attempts, + delivery.last_error, + ), + ) + await db.commit() + + +async def get_pending_deliveries( + event_id: str, reminder_rule_id: str, db_path: str | Path | None = None +) -> list[ReminderDelivery]: + """Check idempotency — return existing deliveries for an event+rule.""" + 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_reminder_deliveries " + "WHERE event_id = ? AND reminder_rule_id = ?", + (event_id, reminder_rule_id), + ) + rows = await cursor.fetchall() + return [_row_to_reminder_delivery(row) for row in rows] + + +async def update_delivery_status( + delivery_id: str, + status: str, + last_error: str | None = None, + db_path: str | Path | None = None, +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute( + "UPDATE calendar_reminder_deliveries " + "SET status = ?, attempts = attempts + 1, last_error = ? " + "WHERE id = ?", + (status, last_error, delivery_id), + ) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# External Calendar Config CRUD +# --------------------------------------------------------------------------- + + +async def insert_external_config( + config: ExternalCalendarConfig, db_path: str | Path | None = None +) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "INSERT INTO calendar_external_configs " + "(id, user_id, provider, credentials, sync_frequency, sync_scope, " + "last_sync, sync_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + config.id, + config.user_id, + config.provider, + config.credentials, + config.sync_frequency, + json.dumps(config.sync_scope), + config.last_sync, + config.sync_token, + ), + ) + await db.commit() + + +async def list_external_configs( + user_id: str, db_path: str | Path | None = None +) -> list[ExternalCalendarConfig]: + 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_external_configs WHERE user_id = ?", + (user_id,), + ) + rows = await cursor.fetchall() + return [_row_to_external_config(row) for row in rows] + + +async def update_external_config( + config_id: str, fields: dict[str, object], db_path: str | Path | None = None +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + set_clauses: list[str] = [] + params: list[object] = [] + for field_name, value in fields.items(): + if field_name == "credentials": + set_clauses.append("credentials = ?") + params.append(value) + elif field_name == "sync_frequency": + set_clauses.append("sync_frequency = ?") + params.append(value) + elif field_name == "sync_scope": + set_clauses.append("sync_scope = ?") + params.append(json.dumps(value)) + elif field_name == "last_sync": + set_clauses.append("last_sync = ?") + params.append(value) + elif field_name == "sync_token": + set_clauses.append("sync_token = ?") + params.append(value) + if not set_clauses: + return False + params.append(config_id) + sql = f"UPDATE calendar_external_configs SET {', '.join(set_clauses)} WHERE id = ?" + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute(sql, tuple(params)) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_external_config(config_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute( + "DELETE FROM calendar_external_configs WHERE id = ?", (config_id,) + ) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Invitation CRUD +# --------------------------------------------------------------------------- + + +async def insert_invitation(invitation: Invitation, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute( + "INSERT INTO calendar_invitations " + "(id, event_id, inviter_user_id, invitee_email, status, responded_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + invitation.id, + invitation.event_id, + invitation.inviter_user_id, + invitation.invitee_email, + invitation.status, + invitation.responded_at, + ), + ) + await db.commit() + + +async def get_invitation( + invitation_id: str, db_path: str | Path | None = None +) -> Invitation | None: + 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_invitations WHERE id = ?", (invitation_id,) + ) + row = await cursor.fetchone() + return _row_to_invitation(row) if row else None + + +async def list_invitations( + invitee_email: str, db_path: str | Path | None = None +) -> list[Invitation]: + 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_invitations WHERE invitee_email = ? ORDER BY responded_at DESC", + (invitee_email,), + ) + rows = await cursor.fetchall() + return [_row_to_invitation(row) for row in rows] + + +async def update_invitation_status( + invitation_id: str, + status: str, + responded_at: str, + db_path: str | Path | None = None, +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + cursor = await db.execute( + "UPDATE calendar_invitations SET status = ?, responded_at = ? WHERE id = ?", + (status, responded_at, invitation_id), + ) + await db.commit() + return cursor.rowcount > 0 diff --git a/src/agentkit/calendar/models.py b/src/agentkit/calendar/models.py new file mode 100644 index 0000000..ef2abeb --- /dev/null +++ b/src/agentkit/calendar/models.py @@ -0,0 +1,221 @@ +"""Data models for the calendar subsystem. + +All dataclasses are DTOs carried between CalendarService, Agent tools, +REST routes, and the frontend. They mirror the ``calendar_*`` DB rows. +All timestamps are ISO 8601 UTC (see KTD-11). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +def _now_iso() -> str: + """Return current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class EventType: + """User-defined event type (e.g. "会议", "截止", "个人").""" + + id: str + user_id: str + name: str + color: str = "#4A90D9" + is_default: bool = False + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "color": self.color, + "is_default": self.is_default, + } + + +@dataclass +class Tag: + """User-defined tag for events.""" + + id: str + user_id: str + name: str + + def to_dict(self) -> dict[str, object]: + return {"id": self.id, "user_id": self.user_id, "name": self.name} + + +@dataclass +class CalendarEvent: + """A calendar event. + + Attributes: + source: "manual" | "agent" | "post_extract" — origin traceability (R15). + is_invited: True if this event arrived via invitation (R33 — special styling). + rrule: RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10". + external_id: ID in external calendar (for sync). + external_provider: "caldav" | "outlook" | None. + last_modified: ISO 8601 UTC, for conflict resolution (last-write-wins). + """ + + id: str + user_id: str + title: str + description: str = "" + start_time: str = "" # ISO 8601 UTC (KTD-11) + end_time: str = "" # ISO 8601 UTC + is_all_day: bool = False + location: str = "" + event_type_id: str | None = None + rrule: str | None = None + source: str = "manual" # "manual" | "agent" | "post_extract" + is_invited: bool = False + conversation_id: str | None = None + external_id: str | None = None + external_provider: str | None = None + last_modified: str = "" + created_at: str = "" + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "title": self.title, + "description": self.description, + "start_time": self.start_time, + "end_time": self.end_time, + "is_all_day": self.is_all_day, + "location": self.location, + "event_type_id": self.event_type_id, + "rrule": self.rrule, + "source": self.source, + "is_invited": self.is_invited, + "conversation_id": self.conversation_id, + "external_id": self.external_id, + "external_provider": self.external_provider, + "last_modified": self.last_modified, + "created_at": self.created_at, + } + + +@dataclass +class EventTag: + """Many-to-many junction between events and tags.""" + + event_id: str + tag_id: str + + def to_dict(self) -> dict[str, object]: + return {"event_id": self.event_id, "tag_id": self.tag_id} + + +@dataclass +class ReminderRule: + """Reminder rule — per-event or per-event-type default. + + Attributes: + event_id: FK to events (nullable for type-level defaults). + event_type_id: FK to event_types (for default reminders). + offset_minutes: -15 = 15 min before, -1440 = 1 day before. + channels: ["client", "email", "webhook"]. + """ + + id: str + event_id: str | None = None + event_type_id: str | None = None + offset_minutes: int = -15 + channels: list[str] = field(default_factory=lambda: ["client"]) + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "event_id": self.event_id, + "event_type_id": self.event_type_id, + "offset_minutes": self.offset_minutes, + "channels": self.channels, + } + + +@dataclass +class ReminderDelivery: + """Tracks delivery status of a reminder instance.""" + + id: str + reminder_rule_id: str + event_id: str + scheduled_time: str # ISO 8601 UTC + status: str = "pending" # "pending" | "sent" | "failed" | "read" + channel: str = "client" + attempts: int = 0 + last_error: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "reminder_rule_id": self.reminder_rule_id, + "event_id": self.event_id, + "scheduled_time": self.scheduled_time, + "status": self.status, + "channel": self.channel, + "attempts": self.attempts, + "last_error": self.last_error, + } + + +@dataclass +class ExternalCalendarConfig: + """Configuration for an external calendar sync provider. + + Attributes: + provider: "caldav" | "outlook". + credentials: encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token). + sync_frequency: sync interval in minutes. + sync_scope: event type IDs to sync, empty = all. + sync_token: delta token for incremental sync. + """ + + id: str + user_id: str + provider: str # "caldav" | "outlook" + credentials: str = "" # encrypted JSON + sync_frequency: int = 30 # minutes + sync_scope: list[str] = field(default_factory=list) + last_sync: str | None = None + sync_token: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "provider": self.provider, + "credentials": "***", # Never expose credentials + "sync_frequency": self.sync_frequency, + "sync_scope": self.sync_scope, + "last_sync": self.last_sync, + "sync_token": self.sync_token, + } + + +@dataclass +class Invitation: + """Event invitation from one user to another.""" + + id: str + event_id: str + inviter_user_id: str + invitee_email: str + status: str = "pending" # "pending" | "accepted" | "declined" | "tentative" + responded_at: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "event_id": self.event_id, + "inviter_user_id": self.inviter_user_id, + "invitee_email": self.invitee_email, + "status": self.status, + "responded_at": self.responded_at, + } diff --git a/src/agentkit/calendar/recurrence.py b/src/agentkit/calendar/recurrence.py new file mode 100644 index 0000000..98d6bb6 --- /dev/null +++ b/src/agentkit/calendar/recurrence.py @@ -0,0 +1,67 @@ +"""RRULE recurrence expansion wrapper. + +Uses ``dateutil.rrule`` for RFC 5545 compliant recurrence rule expansion. +All times are UTC (see KTD-11). +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from dateutil.rrule import rrulestr + + +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 + + +def expand_rrule( + rrule_str: str | None, + dtstart: str, + range_start: str | None = None, + range_end: str | None = None, +) -> list[str]: + """Expand a recurrence rule into individual occurrence start times. + + Args: + rrule_str: RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=4"). + If None or empty, returns ``[dtstart]``. + dtstart: ISO 8601 start time of the first occurrence (UTC). + range_start: Optional ISO 8601 lower bound (inclusive) for filtering. + range_end: Optional ISO 8601 upper bound (exclusive) for filtering. + + Returns: + List of ISO 8601 UTC strings for each occurrence's start time + within the given range. If no range is specified, returns all + occurrences (bounded by COUNT or UNTIL in the RRULE). + """ + if not rrule_str: + return [dtstart] + + start_dt = _parse_dt(dtstart) + + # rrulestr expects the RRULE to have a DTSTART context. + # We prepend DTSTART to ensure the rule starts from the event's start time. + full_rule = f"DTSTART:{start_dt.strftime('%Y%m%dT%H%M%SZ')}\nRRULE:{rrule_str}" + rule = rrulestr(full_rule) + + if range_start is not None and range_end is not None: + rs = _parse_dt(range_start) + re_ = _parse_dt(range_end) + # Half-open interval [start, end) — standard date-range convention + occurrences = [dt for dt in rule if rs <= dt < re_] + elif range_start is not None: + rs = _parse_dt(range_start) + occurrences = [dt for dt in rule if dt >= rs] + elif range_end is not None: + re_ = _parse_dt(range_end) + occurrences = [dt for dt in rule if dt < re_] + else: + occurrences = list(rule) + + # Convert back to ISO 8601 UTC strings + return [dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") for dt in occurrences] diff --git a/tests/unit/calendar/__init__.py b/tests/unit/calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/calendar/test_db.py b/tests/unit/calendar/test_db.py new file mode 100644 index 0000000..ebd4664 --- /dev/null +++ b/tests/unit/calendar/test_db.py @@ -0,0 +1,305 @@ +"""Tests for calendar DB CRUD (U1).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from agentkit.calendar.db import ( + add_tag_to_event, + delete_event, + delete_event_type, + get_event, + get_event_tags, + init_calendar_db, + insert_event, + insert_event_type, + insert_external_config, + insert_invitation, + insert_reminder_rule, + insert_tag, + list_event_types, + list_events, + list_external_configs, + list_invitations, + list_reminder_rules_for_event, + list_reminder_rules_for_type, + list_tags, + update_event, + update_event_type, + update_invitation_status, +) +from agentkit.calendar.models import ( + CalendarEvent, + EventType, + ExternalCalendarConfig, + Invitation, + ReminderRule, + Tag, + _now_iso, +) + + +@pytest.fixture +def db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +def _make_event( + event_id: str = "evt-1", + user_id: str = "user-1", + title: str = "Test Event", + start: str = "2026-07-01T10:00:00+00:00", + end: str = "2026-07-01T11:00:00+00:00", + **kwargs, +) -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id=event_id, + user_id=user_id, + title=title, + start_time=start, + end_time=end, + last_modified=now, + created_at=now, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# init_calendar_db +# --------------------------------------------------------------------------- + + +def test_init_calendar_db_creates_all_tables(db_path: Path) -> None: + """init_calendar_db creates all 8 tables.""" + import aiosqlite + + async def check(): + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = {row[0] for row in await cursor.fetchall()} + return tables + + tables = asyncio.run(check()) + expected = { + "calendar_events", + "calendar_event_types", + "calendar_tags", + "calendar_event_tags", + "calendar_reminder_rules", + "calendar_reminder_deliveries", + "calendar_external_configs", + "calendar_invitations", + } + assert expected.issubset(tables), f"Missing tables: {expected - tables}" + + +# --------------------------------------------------------------------------- +# Event CRUD +# --------------------------------------------------------------------------- + + +def test_insert_and_get_event_roundtrip(db_path: Path) -> None: + """Insert event, fetch by id, all fields preserved including is_invited.""" + event = _make_event(is_invited=True, source="agent", conversation_id="conv-1") + asyncio.run(insert_event(event, db_path)) + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is not None + assert fetched.id == "evt-1" + assert fetched.title == "Test Event" + assert fetched.is_invited is True + assert fetched.source == "agent" + assert fetched.conversation_id == "conv-1" + + +def test_list_events_by_user_filtered_by_date_range(db_path: Path) -> None: + """Insert 3 events, query range covering 2.""" + for i, day in enumerate([1, 15, 28]): + evt = _make_event( + event_id=f"evt-{i}", + start=f"2026-07-{day:02d}T10:00:00+00:00", + end=f"2026-07-{day:02d}T11:00:00+00:00", + ) + asyncio.run(insert_event(evt, db_path)) + + events = asyncio.run( + list_events( + "user-1", + start="2026-07-10T00:00:00+00:00", + end="2026-07-20T00:00:00+00:00", + db_path=db_path, + ) + ) + assert len(events) == 1 + assert events[0].id == "evt-1" + + +def test_update_event_modifies_fields(db_path: Path) -> None: + """Insert, update title, verify.""" + asyncio.run(insert_event(_make_event(), db_path)) + updated = asyncio.run(update_event("evt-1", {"title": "Updated"}, db_path)) + assert updated is True + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is not None + assert fetched.title == "Updated" + + +def test_delete_event_removes_record(db_path: Path) -> None: + """Insert, delete, verify gone.""" + asyncio.run(insert_event(_make_event(), db_path)) + deleted = asyncio.run(delete_event("evt-1", db_path)) + assert deleted is True + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is None + + +# --------------------------------------------------------------------------- +# Event Type CRUD +# --------------------------------------------------------------------------- + + +def test_event_type_crud(db_path: Path) -> None: + """Create/list/update/delete event types.""" + et = EventType(id="type-1", user_id="user-1", name="会议", color="#FF0000") + asyncio.run(insert_event_type(et, db_path)) + + types = asyncio.run(list_event_types("user-1", db_path)) + assert len(types) == 1 + assert types[0].name == "会议" + assert types[0].color == "#FF0000" + + asyncio.run(update_event_type("type-1", {"name": "Meeting"}, db_path)) + types = asyncio.run(list_event_types("user-1", db_path)) + assert types[0].name == "Meeting" + + deleted = asyncio.run(delete_event_type("type-1", db_path)) + assert deleted is True + types = asyncio.run(list_event_types("user-1", db_path)) + assert len(types) == 0 + + +# --------------------------------------------------------------------------- +# Tag CRUD + many-to-many +# --------------------------------------------------------------------------- + + +def test_tag_many_to_many(db_path: Path) -> None: + """Event with 3 tags, query by tag returns event.""" + asyncio.run(insert_event(_make_event(), db_path)) + + for i in range(3): + tag = Tag(id=f"tag-{i}", user_id="user-1", name=f"Tag{i}") + asyncio.run(insert_tag(tag, db_path)) + asyncio.run(add_tag_to_event("evt-1", f"tag-{i}", db_path)) + + # Get tags for event + tags = asyncio.run(get_event_tags("evt-1", db_path)) + assert len(tags) == 3 + + # List events filtered by tag + events = asyncio.run(list_events("user-1", tag_id="tag-1", db_path=db_path)) + assert len(events) == 1 + assert events[0].id == "evt-1" + + # List all tags + all_tags = asyncio.run(list_tags("user-1", db_path)) + assert len(all_tags) == 3 + + +# --------------------------------------------------------------------------- +# Reminder Rule CRUD +# --------------------------------------------------------------------------- + + +def test_reminder_rule_crud(db_path: Path) -> None: + """Create rule for event, create default rule for type.""" + asyncio.run(insert_event(_make_event(), db_path)) + asyncio.run( + insert_event_type(EventType(id="type-1", user_id="user-1", name="Meeting"), db_path) + ) + + # Event-level rule + rule1 = ReminderRule( + id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client", "email"] + ) + asyncio.run(insert_reminder_rule(rule1, db_path)) + + # Type-level default rule + rule2 = ReminderRule( + id="rule-2", event_type_id="type-1", offset_minutes=-1440, channels=["email"] + ) + asyncio.run(insert_reminder_rule(rule2, db_path)) + + event_rules = asyncio.run(list_reminder_rules_for_event("evt-1", db_path)) + assert len(event_rules) == 1 + assert event_rules[0].offset_minutes == -15 + assert event_rules[0].channels == ["client", "email"] + + type_rules = asyncio.run(list_reminder_rules_for_type("type-1", db_path)) + assert len(type_rules) == 1 + assert type_rules[0].offset_minutes == -1440 + + +# --------------------------------------------------------------------------- +# External Config +# --------------------------------------------------------------------------- + + +def test_external_config_stores_encrypted_credentials(db_path: Path) -> None: + """Insert config, verify credentials field is opaque (stored as-is).""" + config = ExternalCalendarConfig( + id="cfg-1", + user_id="user-1", + provider="caldav", + credentials='{"url":"https://caldav.icloud.com","user":"alice","pass":"xxx"}', + sync_frequency=15, + sync_scope=["type-1", "type-2"], + ) + asyncio.run(insert_external_config(config, db_path)) + + configs = asyncio.run(list_external_configs("user-1", db_path)) + assert len(configs) == 1 + assert configs[0].provider == "caldav" + assert configs[0].sync_frequency == 15 + assert configs[0].sync_scope == ["type-1", "type-2"] + # Credentials stored as-is (encryption happens at service layer) + assert "caldav.icloud.com" in configs[0].credentials + + +# --------------------------------------------------------------------------- +# Invitation CRUD +# --------------------------------------------------------------------------- + + +def test_invitation_crud(db_path: Path) -> None: + """Create invitation, list by email, update status.""" + asyncio.run(insert_event(_make_event(), db_path)) + + inv = Invitation( + id="inv-1", + event_id="evt-1", + inviter_user_id="user-1", + invitee_email="alice@example.com", + ) + asyncio.run(insert_invitation(inv, db_path)) + + invs = asyncio.run(list_invitations("alice@example.com", db_path)) + assert len(invs) == 1 + assert invs[0].status == "pending" + + now = _now_iso() + asyncio.run(update_invitation_status("inv-1", "accepted", now, db_path)) + + invs = asyncio.run(list_invitations("alice@example.com", db_path)) + assert invs[0].status == "accepted" + assert invs[0].responded_at == now diff --git a/tests/unit/calendar/test_recurrence.py b/tests/unit/calendar/test_recurrence.py new file mode 100644 index 0000000..99024ab --- /dev/null +++ b/tests/unit/calendar/test_recurrence.py @@ -0,0 +1,75 @@ +"""Tests for RRULE recurrence expansion (U1).""" + +from __future__ import annotations + +from agentkit.calendar.recurrence import expand_rrule + + +def test_expand_rrule_weekly_count() -> None: + """FREQ=WEEKLY;BYDAY=MO;COUNT=4 from Monday → 4 occurrences.""" + result = expand_rrule( + "FREQ=WEEKLY;BYDAY=MO;COUNT=4", + "2026-07-06T10:00:00+00:00", # Monday + ) + assert len(result) == 4 + # All should be Mondays + for occ in result: + # 2026-07-06 is Monday, 07-13, 07-20, 07-27 + assert "T10:00:00+00:00" in occ + + +def test_expand_rrule_daily_range_filter() -> None: + """FREQ=DAILY starting Jan 1, range Jan 3–Jan 5 → 3 occurrences.""" + result = expand_rrule( + "FREQ=DAILY", + "2026-01-01T00:00:00+00:00", + range_start="2026-01-03T00:00:00+00:00", + range_end="2026-01-06T00:00:00+00:00", + ) + assert len(result) == 3 # Jan 3, 4, 5 + + +def test_expand_rrule_until_clause() -> None: + """FREQ=DAILY;UNTIL=20260131 → occurrences stop at Jan 31.""" + result = expand_rrule( + "FREQ=DAILY;UNTIL=20260131T235959Z", + "2026-01-29T00:00:00+00:00", + ) + assert len(result) == 3 # Jan 29, 30, 31 + + +def test_expand_rrule_no_rrule_returns_single() -> None: + """rrule=None → returns [start_time] only.""" + result = expand_rrule(None, "2026-07-01T10:00:00+00:00") + assert result == ["2026-07-01T10:00:00+00:00"] + + result = expand_rrule("", "2026-07-01T10:00:00+00:00") + assert result == ["2026-07-01T10:00:00+00:00"] + + +def test_expand_rrule_all_day_event() -> None: + """All-day event with RRULE, verify date expansion (no time component issues).""" + result = expand_rrule( + "FREQ=DAILY;COUNT=3", + "2026-07-01T00:00:00+00:00", + ) + assert len(result) == 3 + assert result[0].startswith("2026-07-01") + assert result[1].startswith("2026-07-02") + assert result[2].startswith("2026-07-03") + + +def test_expand_rrule_dst_boundary() -> None: + """Event crossing DST transition, verify UTC consistency. + + March 8, 2026 is US DST spring-forward. A daily event starting + March 7 should produce correct UTC occurrences across the boundary. + """ + result = expand_rrule( + "FREQ=DAILY;COUNT=3", + "2026-03-07T10:00:00+00:00", + ) + assert len(result) == 3 + # All should be at 10:00 UTC regardless of DST + for occ in result: + assert "T10:00:00+00:00" in occ