"""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