"""Tests for CalendarService (U2).""" from __future__ import annotations import asyncio import uuid from pathlib import Path import aiosqlite import pytest from agentkit.calendar.db import ( get_event_tags, init_calendar_db, insert_reminder_rule, list_reminder_rules_for_event, ) from agentkit.calendar.models import ReminderRule from agentkit.calendar.service import CalendarService from agentkit.server.auth.models import init_auth_db # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def calendar_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: path = tmp_path / "test_auth.db" asyncio.run(init_auth_db(path)) return path @pytest.fixture def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService: return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) async def _seed_user( auth_db_path: Path, user_id: str | None = None, username: str = "testuser", email: str = "test@example.com", ) -> str: """Insert a user into the auth DB and return its id.""" uid = user_id or uuid.uuid4().hex async with aiosqlite.connect(str(auth_db_path)) as db: await db.execute( "INSERT INTO users (id, username, email, password_hash, role, is_active, " "is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( uid, username, email, "fake-hash", "member", 1, 0, 0, "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00", ), ) await db.commit() return uid # --------------------------------------------------------------------------- # Event creation with type and tags # --------------------------------------------------------------------------- async def test_create_event_with_type_and_tags( service: CalendarService, calendar_db_path: Path ) -> None: """Create event with type_id and 2 tags, verify all linked.""" user_id = "user-1" # Create event type et = await service.create_event_type(user_id, "Meeting", color="#FF0000") # Create tags tag1 = await service.create_tag(user_id, "urgent") tag2 = await service.create_tag(user_id, "work") # Create event with type and tags event = await service.create_event( user_id=user_id, 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, tag_ids=[tag1.id, tag2.id], ) # Verify event was created fetched = await service.get_event(event.id) assert fetched is not None assert fetched.title == "Sprint Planning" assert fetched.event_type_id == et.id # Verify tags are linked tags = await get_event_tags(event.id, calendar_db_path) tag_names = {t.name for t in tags} assert tag_names == {"urgent", "work"} async def test_create_event_broadcasts_via_notify_callback( calendar_db_path: Path, auth_db_path: Path ) -> None: """create_event must invoke notify_callback with calendar_event_created payload. Regression guard: without the broadcast, the frontend calendar view does not refresh after an agent creates an event via the calendar tool, even though the event was successfully persisted. """ received: list[tuple[str, dict[str, object]]] = [] async def _capture(user_id: str, message: dict[str, object]) -> None: received.append((user_id, message)) svc = CalendarService( db_path=calendar_db_path, auth_db_path=auth_db_path, notify_callback=_capture, ) event = await svc.create_event( user_id="user-broadcast", title="Refresh Test", start_time="2026-07-02T09:00:00+00:00", end_time="2026-07-02T10:00:00+00:00", ) assert len(received) == 1, "notify_callback must fire exactly once" uid, msg = received[0] assert uid == "user-broadcast" assert msg["type"] == "calendar_event_created" assert msg["data"]["event"]["id"] == event.id # type: ignore[index] async def test_create_event_without_callback_does_not_raise( service: CalendarService, ) -> None: """Default CalendarService (no callback) must still create events.""" event = await service.create_event( user_id="user-1", title="No Callback", start_time="2026-07-03T09:00:00+00:00", end_time="2026-07-03T10:00:00+00:00", ) assert event.title == "No Callback" # --------------------------------------------------------------------------- # Date range filtering # --------------------------------------------------------------------------- async def test_list_events_filters_by_date_range(service: CalendarService) -> None: """3 events across days, filter returns correct subset.""" user_id = "user-1" for i, day in enumerate([1, 15, 28]): await service.create_event( user_id=user_id, title=f"Event Day {day}", start_time=f"2026-07-{day:02d}T10:00:00+00:00", end_time=f"2026-07-{day:02d}T11:00:00+00:00", ) # Range covering only day 15 events = await service.list_events( user_id=user_id, start="2026-07-10T00:00:00+00:00", end="2026-07-20T00:00:00+00:00", ) assert len(events) == 1 assert events[0].title == "Event Day 15" # --------------------------------------------------------------------------- # Type and tag filter combination (G2) # --------------------------------------------------------------------------- async def test_list_events_filters_by_type_and_tag( service: CalendarService, calendar_db_path: Path ) -> None: """Filter by both type_id and tag_id returns only matching events.""" user_id = "user-1" et_meeting = await service.create_event_type(user_id, "Meeting") et_personal = await service.create_event_type(user_id, "Personal") tag_work = await service.create_tag(user_id, "work") tag_family = await service.create_tag(user_id, "family") # Event 1: Meeting + work e1 = await service.create_event( user_id=user_id, title="Standup", start_time="2026-07-01T09:00:00+00:00", end_time="2026-07-01T09:30:00+00:00", event_type_id=et_meeting.id, tag_ids=[tag_work.id], ) # Event 2: Personal + family await service.create_event( user_id=user_id, title="Birthday", start_time="2026-07-02T18:00:00+00:00", end_time="2026-07-02T21:00:00+00:00", event_type_id=et_personal.id, tag_ids=[tag_family.id], ) # Event 3: Meeting + family (cross combination) await service.create_event( user_id=user_id, title="Team Dinner", start_time="2026-07-03T19:00:00+00:00", end_time="2026-07-03T21:00:00+00:00", event_type_id=et_meeting.id, tag_ids=[tag_family.id], ) # Filter by type=Meeting AND tag=work → only Event 1 events = await service.list_events( user_id=user_id, event_type_id=et_meeting.id, tag_id=tag_work.id, ) assert len(events) == 1 assert events[0].id == e1.id # --------------------------------------------------------------------------- # Tag-only filter (G2) # --------------------------------------------------------------------------- async def test_list_events_filters_by_tag_only( service: CalendarService, calendar_db_path: Path ) -> None: """5 events, 2 with tag X, filter tag_id=X returns only 2.""" user_id = "user-1" tag_x = await service.create_tag(user_id, "X") tagged_ids: list[str] = [] for i in range(5): tag_ids = [tag_x.id] if i < 2 else None event = await service.create_event( user_id=user_id, title=f"Event {i}", start_time=f"2026-07-{i + 1:02d}T10:00:00+00:00", end_time=f"2026-07-{i + 1:02d}T11:00:00+00:00", tag_ids=tag_ids, ) if i < 2: tagged_ids.append(event.id) events = await service.list_events(user_id=user_id, tag_id=tag_x.id) assert len(events) == 2 returned_ids = {e.id for e in events} assert returned_ids == set(tagged_ids) # --------------------------------------------------------------------------- # Recurring event expansion # --------------------------------------------------------------------------- async def test_list_events_expands_recurring(service: CalendarService) -> None: """Event with FREQ=DAILY;COUNT=3, list range covering 2 days → 2 occurrences.""" user_id = "user-1" await service.create_event( user_id=user_id, title="Daily Standup", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T10:15:00+00:00", rrule="FREQ=DAILY;COUNT=3", ) # Range covers Jul 1 and Jul 2 (half-open [Jul 1, Jul 3)) events = await service.list_events( user_id=user_id, start="2026-07-01T00:00:00+00:00", end="2026-07-03T00:00:00+00:00", ) assert len(events) == 2 # First occurrence on Jul 1 assert events[0].start_time.startswith("2026-07-01") assert events[0].end_time.startswith("2026-07-01") # Second occurrence on Jul 2 assert events[1].start_time.startswith("2026-07-02") assert events[1].end_time.startswith("2026-07-02") # Duration preserved (15 minutes) assert "T10:00:00" in events[0].start_time assert "T10:15:00" in events[0].end_time assert "T10:00:00" in events[1].start_time assert "T10:15:00" in events[1].end_time # --------------------------------------------------------------------------- # Partial update # --------------------------------------------------------------------------- async def test_update_event_partial_fields(service: CalendarService) -> None: """PATCH only title, other fields unchanged.""" event = await service.create_event( user_id="user-1", title="Original Title", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", description="Original description", location="Room A", ) original = await service.get_event(event.id) assert original is not None updated = await service.update_event(event.id, {"title": "New Title"}) assert updated is True refreshed = await service.get_event(event.id) assert refreshed is not None assert refreshed.title == "New Title" # Other fields unchanged assert refreshed.description == "Original description" assert refreshed.location == "Room A" assert refreshed.start_time == original.start_time assert refreshed.end_time == original.end_time # last_modified should be updated assert refreshed.last_modified != original.last_modified # --------------------------------------------------------------------------- # Delete cascade # --------------------------------------------------------------------------- async def test_delete_event_cascades_reminders_and_tags( service: CalendarService, calendar_db_path: Path ) -> None: """Delete event, verify reminder rules and junction rows removed.""" user_id = "user-1" tag = await service.create_tag(user_id, "work") event = await service.create_event( user_id=user_id, title="To Delete", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", tag_ids=[tag.id], ) # Add a reminder rule directly to the event rule = ReminderRule( id=uuid.uuid4().hex, event_id=event.id, offset_minutes=-30, channels=["email"], ) await insert_reminder_rule(rule, calendar_db_path) # Verify rule and tag exist rules_before = await list_reminder_rules_for_event(event.id, calendar_db_path) assert len(rules_before) == 1 tags_before = await get_event_tags(event.id, calendar_db_path) assert len(tags_before) == 1 # Delete the event deleted = await service.delete_event(event.id) assert deleted is True # Verify event is gone assert await service.get_event(event.id) is None # Verify reminder rules are gone (cascade) rules_after = await list_reminder_rules_for_event(event.id, calendar_db_path) assert len(rules_after) == 0 # Verify junction rows are gone tags_after = await get_event_tags(event.id, calendar_db_path) assert len(tags_after) == 0 # Tag itself should still exist (only the junction is removed) all_tags = await service.list_tags(user_id) assert len(all_tags) == 1 # --------------------------------------------------------------------------- # Invitation flow # --------------------------------------------------------------------------- async def test_create_invitation_and_respond(service: CalendarService) -> None: """Invite, respond 'accepted', verify status + responded_at set.""" event = await service.create_event( user_id="user-1", title="Team Meeting", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", ) invitation = await service.create_invitation( event_id=event.id, inviter_user_id="user-1", invitee_email="alice@example.com", ) assert invitation.status == "pending" assert invitation.responded_at is None updated = await service.respond_to_invitation(invitation.id, "accepted") assert updated is True invitations = await service.list_invitations("alice@example.com") assert len(invitations) == 1 assert invitations[0].status == "accepted" assert invitations[0].responded_at is not None # --------------------------------------------------------------------------- # User search (G5) # --------------------------------------------------------------------------- async def test_search_users_by_username(service: CalendarService, auth_db_path: Path) -> None: """Seed users in auth DB, search returns matches.""" await _seed_user(auth_db_path, username="alice", email="alice@example.com") await _seed_user(auth_db_path, username="bob", email="bob@example.com") await _seed_user(auth_db_path, username="charlie", email="charlie@example.com") results = await service.search_users("ali") assert len(results) == 1 assert results[0]["username"] == "alice" assert results[0]["email"] == "alice@example.com" # Must NOT return user_id or password fields assert "id" not in results[0] assert "password_hash" not in results[0] async def test_search_users_no_match_returns_empty( service: CalendarService, auth_db_path: Path ) -> None: """Search 'zzz' returns [].""" await _seed_user(auth_db_path, username="alice", email="alice@example.com") results = await service.search_users("zzz") assert results == [] async def test_search_users_returns_max_10(service: CalendarService, auth_db_path: Path) -> None: """Seed 15 matching users, verify only 10 returned.""" for i in range(15): await _seed_user( auth_db_path, username=f"user{i:02d}", email=f"user{i:02d}@example.com", ) results = await service.search_users("user") assert len(results) == 10 # --------------------------------------------------------------------------- # Event type color persistence # --------------------------------------------------------------------------- async def test_event_type_default_color_persistence(service: CalendarService) -> None: """Create type with color, verify roundtrip.""" et = await service.create_event_type("user-1", "Important", color="#00FF00") assert et.color == "#00FF00" types = await service.list_event_types("user-1") assert len(types) == 1 assert types[0].color == "#00FF00" assert types[0].name == "Important"