488 lines
16 KiB
Python
488 lines
16 KiB
Python
"""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"
|