"""Tests for CalendarTool — Agent tool wrapper for ReAct integration (U3).""" from __future__ import annotations import asyncio from pathlib import Path import pytest from agentkit.calendar.db import get_event_tags, init_calendar_db from agentkit.calendar.service import CalendarService from agentkit.tools.calendar_tool import CalendarTool # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def service(tmp_path: Path) -> CalendarService: """Provide a CalendarService backed by a temp DB.""" db_path = tmp_path / "test.db" asyncio.run(init_calendar_db(db_path)) return CalendarService(db_path=db_path) @pytest.fixture def tool(service: CalendarService) -> CalendarTool: return CalendarTool(calendar_service=service) # --------------------------------------------------------------------------- # create_event # --------------------------------------------------------------------------- async def test_create_event_action_returns_success( tool: CalendarTool, service: CalendarService ) -> None: """create_event returns success and persists the event with source='agent'.""" result = await tool.execute( action="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", description="Bi-weekly sprint planning", location="Room A", ) assert result["success"] is True event_dict = result["event"] assert event_dict["title"] == "Sprint Planning" assert event_dict["source"] == "agent" assert event_dict["user_id"] == "user-1" # Verify persisted in DB fetched = await service.get_event(event_dict["id"]) assert fetched is not None assert fetched.title == "Sprint Planning" assert fetched.source == "agent" async def test_create_event_with_recurrence_sets_rrule( tool: CalendarTool, service: CalendarService ) -> None: """rrule param is stored correctly on the event.""" rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=10" result = await tool.execute( action="create_event", user_id="user-1", title="Weekly Standup", start_time="2026-07-06T09:00:00+00:00", end_time="2026-07-06T09:30:00+00:00", rrule=rrule, ) assert result["success"] is True assert result["event"]["rrule"] == rrule fetched = await service.get_event(result["event"]["id"]) assert fetched is not None assert fetched.rrule == rrule async def test_query_events_returns_list(tool: CalendarTool) -> None: """create 2 events, query, verify both returned.""" await tool.execute( action="create_event", user_id="user-1", title="Event A", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", ) await tool.execute( action="create_event", user_id="user-1", title="Event B", start_time="2026-07-02T14:00:00+00:00", end_time="2026-07-02T15:00:00+00:00", ) result = await tool.execute( action="query_events", user_id="user-1", ) assert result["success"] is True events = result["events"] assert len(events) == 2 titles = {e["title"] for e in events} assert titles == {"Event A", "Event B"} async def test_update_event_action_modifies_fields( tool: CalendarTool, service: CalendarService ) -> None: """create then update title; verify the field is modified.""" create_result = await tool.execute( action="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", ) assert create_result["success"] is True event_id = create_result["event"]["id"] update_result = await tool.execute( action="update_event", user_id="user-1", event_id=event_id, title="Updated Title", ) assert update_result["success"] is True fetched = await service.get_event(event_id) assert fetched is not None assert fetched.title == "Updated Title" async def test_delete_event_action_removes_record( tool: CalendarTool, service: CalendarService ) -> None: """create then delete; verify the record is gone.""" create_result = await tool.execute( action="create_event", user_id="user-1", title="To Be Deleted", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", ) assert create_result["success"] is True event_id = create_result["event"]["id"] delete_result = await tool.execute( action="delete_event", user_id="user-1", event_id=event_id, ) assert delete_result["success"] is True fetched = await service.get_event(event_id) assert fetched is None # --------------------------------------------------------------------------- # error paths # --------------------------------------------------------------------------- async def test_invalid_action_returns_error(tool: CalendarTool) -> None: """Unknown action returns success=False with error message.""" result = await tool.execute( action="frobnicate", user_id="user-1", ) assert result["success"] is False assert "Unknown action" in result["error"] async def test_missing_required_field_returns_error(tool: CalendarTool) -> None: """create_event without title returns success=False.""" result = await tool.execute( action="create_event", user_id="user-1", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", ) assert result["success"] is False assert "Missing required field" in result["error"] assert "title" in result["error"] # --------------------------------------------------------------------------- # conversation_id # --------------------------------------------------------------------------- async def test_created_event_has_conversation_id( tool: CalendarTool, service: CalendarService ) -> None: """conversation_id is set from context when provided.""" result = await tool.execute( action="create_event", user_id="user-1", title="Chat-Initiated Event", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", conversation_id="conv-abc-123", ) assert result["success"] is True assert result["event"]["conversation_id"] == "conv-abc-123" fetched = await service.get_event(result["event"]["id"]) assert fetched is not None assert fetched.conversation_id == "conv-abc-123" # --------------------------------------------------------------------------- # event_type_name resolution # --------------------------------------------------------------------------- async def test_create_event_with_event_type_name( tool: CalendarTool, service: CalendarService ) -> None: """create event with event_type_name='Meeting' creates the type and links it.""" result = await tool.execute( action="create_event", user_id="user-1", title="Strategy Sync", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", event_type_name="Meeting", ) assert result["success"] is True event_type_id = result["event"]["event_type_id"] assert event_type_id is not None # Verify the event type was created types = await service.list_event_types("user-1") meeting_types = [t for t in types if t.name == "Meeting"] assert len(meeting_types) == 1 assert meeting_types[0].id == event_type_id # Verify the event references the type fetched = await service.get_event(result["event"]["id"]) assert fetched is not None assert fetched.event_type_id == event_type_id async def test_create_event_reuses_existing_event_type( tool: CalendarTool, service: CalendarService ) -> None: """If event_type_name matches an existing type, it is reused (not duplicated).""" # Pre-create the type existing = await service.create_event_type("user-1", "Meeting") result = await tool.execute( action="create_event", user_id="user-1", title="Second Meeting", start_time="2026-07-02T10:00:00+00:00", end_time="2026-07-02T11:00:00+00:00", event_type_name="Meeting", ) assert result["success"] is True assert result["event"]["event_type_id"] == existing.id # No duplicate type created types = await service.list_event_types("user-1") meeting_types = [t for t in types if t.name == "Meeting"] assert len(meeting_types) == 1 # --------------------------------------------------------------------------- # tag_names resolution # --------------------------------------------------------------------------- async def test_create_event_with_tag_names( tool: CalendarTool, service: CalendarService, tmp_path: Path ) -> None: """create event with tag_names=['urgent', 'work'] creates tags and links them.""" result = await tool.execute( action="create_event", user_id="user-1", title="Urgent Work Task", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", tag_names=["urgent", "work"], ) assert result["success"] is True event_id = result["event"]["id"] # Verify tags were created tags = await service.list_tags("user-1") tag_names = {t.name for t in tags} assert "urgent" in tag_names assert "work" in tag_names # Verify tags are linked to the event linked_tags = await get_event_tags(event_id, service.db_path) linked_names = {t.name for t in linked_tags} assert linked_names == {"urgent", "work"} async def test_create_event_reuses_existing_tags( tool: CalendarTool, service: CalendarService ) -> None: """If a tag name matches an existing tag, it is reused (not duplicated).""" # Pre-create a tag existing = await service.create_tag("user-1", "urgent") result = await tool.execute( action="create_event", user_id="user-1", title="Tagged Event", start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", tag_names=["urgent", "new-tag"], ) assert result["success"] is True # 'urgent' should not be duplicated, 'new-tag' should be created tags = await service.list_tags("user-1") urgent_tags = [t for t in tags if t.name == "urgent"] assert len(urgent_tags) == 1 assert urgent_tags[0].id == existing.id new_tags = [t for t in tags if t.name == "new-tag"] assert len(new_tags) == 1 # --------------------------------------------------------------------------- # tool registration / schema # --------------------------------------------------------------------------- def test_tool_name_and_schema(tool: CalendarTool) -> None: """Tool has correct name and input_schema.""" assert tool.name == "calendar" schema = tool.input_schema assert schema["type"] == "object" assert "action" in schema["properties"] assert "start_time" in schema["properties"] assert schema["properties"]["action"]["enum"] == [ "create_event", "query_events", "update_event", "delete_event", ] assert "action" in schema["required"] # user_id is no longer in the schema — it's resolved internally via # _resolve_user_id (caller-provided or default_user_id fallback). # --------------------------------------------------------------------------- # Chinese relative date/time parsing (_resolve_datetime) # --------------------------------------------------------------------------- from datetime import datetime as _dt from agentkit.tools.calendar_tool import _resolve_datetime def _parse_iso(value: str | None) -> _dt | None: """Helper: parse _resolve_datetime output back to datetime.""" if value is None: return None return _dt.fromisoformat(value) def test_resolve_iso8601_passthrough() -> None: """ISO 8601 strings are returned unchanged.""" iso = "2026-07-08T15:00:00+00:00" assert _resolve_datetime(iso) == iso def test_resolve_none_for_empty() -> None: """None/empty returns None.""" assert _resolve_datetime(None) is None assert _resolve_datetime("") is None assert _resolve_datetime(" ") is None def test_resolve_chinese_next_week_weekday() -> None: """'下周三' resolves to next week's Wednesday.""" now = _dt.now().astimezone() result = _parse_iso(_resolve_datetime("下周三")) assert result is not None # Should be Wednesday (weekday=2) assert result.weekday() == 2 # Should be in the future, specifically next week delta = (result.date() - now.date()).days assert delta >= 7 # at least 7 days out (next week) assert delta <= 13 # at most 13 days out def test_resolve_chinese_next_week_with_time() -> None: """'下周三下午3点' resolves to next Wednesday at 15:00.""" result = _parse_iso(_resolve_datetime("下周三下午3点")) assert result is not None assert result.weekday() == 2 assert result.hour == 15 assert result.minute == 0 def test_resolve_chinese_tomorrow() -> None: """'明天' resolves to tomorrow.""" now = _dt.now().astimezone() result = _parse_iso(_resolve_datetime("明天")) assert result is not None delta = (result.date() - now.date()).days assert delta == 1 def test_resolve_chinese_day_after_tomorrow() -> None: """'后天' resolves to day after tomorrow.""" now = _dt.now().astimezone() result = _parse_iso(_resolve_datetime("后天")) assert result is not None delta = (result.date() - now.date()).days assert delta == 2 def test_resolve_chinese_n_days_later() -> None: """'3天后' resolves to 3 days from now.""" now = _dt.now().astimezone() result = _parse_iso(_resolve_datetime("3天后")) assert result is not None delta = (result.date() - now.date()).days assert delta == 3 def test_resolve_chinese_n_hours_later() -> None: """'2小时后' resolves to ~2 hours from now.""" now = _dt.now().astimezone() result = _parse_iso(_resolve_datetime("2小时后")) assert result is not None delta_seconds = (result - now).total_seconds() assert 7000 <= delta_seconds <= 7400 # ~2 hours (allow small tolerance) def test_resolve_chinese_pm_time() -> None: """'下周三 15:00' (Arabic) resolves to 15:00.""" result = _parse_iso(_resolve_datetime("下周三 15:00")) assert result is not None assert result.weekday() == 2 assert result.hour == 15 def test_resolve_chinese_this_week() -> None: """'这周五' resolves to this week's Friday.""" now = _dt.now().astimezone() result = _parse_iso(_resolve_datetime("这周五")) assert result is not None assert result.weekday() == 4 # Friday # Should be this week or next if already passed delta = (result.date() - now.date()).days assert 0 <= delta <= 7 def test_resolve_chinese_unparseable_returns_none() -> None: """Chinese text with no recognizable date pattern returns None (not a wrong date).""" assert _resolve_datetime("某个时候") is None assert _resolve_datetime("时间待定") is None assert _resolve_datetime("尽快") is None def test_resolve_chinese_does_not_fall_through_to_dateutil() -> None: """Chinese+number mix must NOT fall through to dateutil fuzzy (would produce wrong date). Before the fix, '下周三 3pm' would be parsed by dateutil fuzzy as today's date at 15:00 (silently dropping the Chinese). Now it returns the correct next-Wednesday date. """ now = _dt.now().astimezone() result = _parse_iso(_resolve_datetime("下周三 3pm")) assert result is not None assert result.weekday() == 2 # Wednesday, not today's weekday # The date should NOT be today (the old bug produced today's date) assert result.date() != now.date()