306 lines
9.4 KiB
Python
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
|