fischer-agentkit/tests/unit/tools/test_calendar_tool.py

482 lines
16 KiB
Python

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