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

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"