fischer-agentkit/tests/unit/calendar/test_db.py

306 lines
9.4 KiB
Python

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