"""Integration flow tests for calendar API (Layer 3). Tests multi-step API flows via TestClient: event lifecycle, recurrence, tags, event types, invitations, and authz isolation. """ from __future__ import annotations import asyncio from pathlib import Path from typing import Any import aiosqlite import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from agentkit.calendar.db import init_calendar_db from agentkit.calendar.service import CalendarService from agentkit.server.auth.dependencies import require_authenticated from agentkit.server.auth.models import init_auth_db from agentkit.server.routes import calendar as calendar_routes # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_user(user_id: str, username: str = "user") -> dict[str, Any]: return {"user_id": user_id, "username": username, "role": "member"} async def _seed_user(auth_db_path: Path, user_id: str, username: str, email: str) -> None: 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (user_id, username, email, "hash", "member", 1, 0, 0, "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"), ) await db.commit() @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)) asyncio.run(_seed_user(path, "user-a", "alice", "alice@example.com")) asyncio.run(_seed_user(path, "user-b", "bob", "bob@example.com")) return path def _make_app(calendar_db_path: Path, auth_db_path: Path, user: dict[str, Any]) -> FastAPI: service = CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) app = FastAPI() app.state.calendar_service = service app.state.auth_db_path = str(auth_db_path) app.include_router(calendar_routes.router, prefix="/api/v1") app.dependency_overrides[require_authenticated] = lambda: user return app @pytest.fixture def client_a(calendar_db_path: Path, auth_db_path: Path) -> TestClient: """Client authenticated as user-a.""" return TestClient(_make_app(calendar_db_path, auth_db_path, _make_user("user-a", "alice"))) @pytest.fixture def client_b(calendar_db_path: Path, auth_db_path: Path) -> TestClient: """Client authenticated as user-b (shares same DB for authz isolation tests).""" return TestClient(_make_app(calendar_db_path, auth_db_path, _make_user("user-b", "bob"))) # --------------------------------------------------------------------------- # 1. Event lifecycle: create -> query -> update -> delete # --------------------------------------------------------------------------- def test_event_lifecycle_create_query_update_delete(client_a: TestClient) -> None: # Create resp = client_a.post("/api/v1/calendar/events", json={ "title": "Sprint Planning", "start_time": "2026-07-01T10:00:00+00:00", "end_time": "2026-07-01T11:00:00+00:00", "description": "Quarterly sprint", }) assert resp.status_code == 200 eid = resp.json()["event"]["id"] assert resp.json()["event"]["title"] == "Sprint Planning" # Query single resp = client_a.get(f"/api/v1/calendar/events/{eid}") assert resp.status_code == 200 assert resp.json()["event"]["title"] == "Sprint Planning" # Update resp = client_a.patch(f"/api/v1/calendar/events/{eid}", json={"title": "Sprint Review"}) assert resp.status_code == 200 assert resp.json()["event"]["title"] == "Sprint Review" # Delete resp = client_a.delete(f"/api/v1/calendar/events/{eid}") assert resp.status_code == 200 assert resp.json()["deleted"] is True # Verify gone resp = client_a.get(f"/api/v1/calendar/events/{eid}") assert resp.status_code == 404 # --------------------------------------------------------------------------- # 2. Recurring event: RRULE expansion in list # --------------------------------------------------------------------------- def test_recurring_event_expanded_in_list(client_a: TestClient) -> None: resp = client_a.post("/api/v1/calendar/events", json={ "title": "Weekly Standup", "start_time": "2026-07-06T09:00:00+00:00", # Monday "end_time": "2026-07-06T09:30:00+00:00", "rrule": "FREQ=WEEKLY;BYDAY=MO;COUNT=4", }) assert resp.status_code == 200 eid = resp.json()["event"]["id"] # List over a 4-week range — should see 4 occurrences # Note: %2B is URL-encoded "+" for timezone offset (+00:00) resp = client_a.get( "/api/v1/calendar/events?start=2026-07-01T00:00:00%2B00:00&end=2026-07-29T00:00:00%2B00:00" ) assert resp.status_code == 200 occurrences = [e for e in resp.json()["events"] if e["id"] == eid] assert len(occurrences) == 4, f"Expected 4 occurrences, got {len(occurrences)}" # --------------------------------------------------------------------------- # 3. Tag management: create tag -> tag event -> filter by tag # --------------------------------------------------------------------------- def test_tag_create_tag_event_filter(client_a: TestClient) -> None: # Create tag resp = client_a.post("/api/v1/calendar/tags", json={"name": "important", "color": "#FF0000"}) assert resp.status_code == 200 tag_id = resp.json()["tag"]["id"] # Create event with tag resp = client_a.post("/api/v1/calendar/events", json={ "title": "Tagged Event", "start_time": "2026-07-15T14:00:00+00:00", "end_time": "2026-07-15T15:00:00+00:00", "tag_ids": [tag_id], }) assert resp.status_code == 200 eid = resp.json()["event"]["id"] # List with tag filter resp = client_a.get(f"/api/v1/calendar/events?tag_id={tag_id}") assert resp.status_code == 200 events = resp.json()["events"] assert len(events) == 1 assert events[0]["id"] == eid # List without filter — should also include resp = client_a.get("/api/v1/calendar/events") assert resp.status_code == 200 assert any(e["id"] == eid for e in resp.json()["events"]) # --------------------------------------------------------------------------- # 4. Event type: create type -> create event with type # --------------------------------------------------------------------------- def test_event_type_create_and_use(client_a: TestClient) -> None: # Create event type resp = client_a.post("/api/v1/calendar/event-types", json={"name": "Meeting", "color": "#4A90D9"}) assert resp.status_code == 200 type_id = resp.json()["event_type"]["id"] # Create event with type resp = client_a.post("/api/v1/calendar/events", json={ "title": "Team Sync", "start_time": "2026-07-10T10:00:00+00:00", "end_time": "2026-07-10T11:00:00+00:00", "event_type_id": type_id, }) assert resp.status_code == 200 assert resp.json()["event"]["event_type_id"] == type_id # List event types resp = client_a.get("/api/v1/calendar/event-types") assert resp.status_code == 200 assert any(t["id"] == type_id for t in resp.json()["event_types"]) # --------------------------------------------------------------------------- # 5. Invitation flow: create event -> invite -> respond -> list # --------------------------------------------------------------------------- def test_invitation_flow(client_a: TestClient, client_b: TestClient) -> None: # Create event as user-a (alice) resp = client_a.post("/api/v1/calendar/events", json={ "title": "Project Kickoff", "start_time": "2026-08-01T10:00:00+00:00", "end_time": "2026-08-01T11:00:00+00:00", }) assert resp.status_code == 200 eid = resp.json()["event"]["id"] # Alice invites bob@example.com resp = client_a.post(f"/api/v1/calendar/events/{eid}/invitations", json={ "invitee_email": "bob@example.com", }) assert resp.status_code == 200 inv_id = resp.json()["invitation"]["id"] assert resp.json()["invitation"]["status"] == "pending" # Bob (invitee) lists his invitations — should see the invite resp = client_b.get("/api/v1/calendar/invitations") assert resp.status_code == 200 invitations = resp.json()["invitations"] assert len(invitations) == 1 assert invitations[0]["id"] == inv_id # Bob responds to the invitation (accept) resp = client_b.post( f"/api/v1/calendar/invitations/{inv_id}/respond", json={"status": "accepted"} ) assert resp.status_code == 200 assert resp.json()["status"] == "accepted" # --------------------------------------------------------------------------- # 6. Authz isolation: user A's events invisible to user B # --------------------------------------------------------------------------- def test_authz_isolation_user_a_invisible_to_user_b( client_a: TestClient, client_b: TestClient ) -> None: # User A creates an event resp = client_a.post("/api/v1/calendar/events", json={ "title": "Alice's Private Event", "start_time": "2026-07-20T10:00:00+00:00", "end_time": "2026-07-20T11:00:00+00:00", }) assert resp.status_code == 200 eid = resp.json()["event"]["id"] # User B lists events — should NOT see Alice's event resp = client_b.get("/api/v1/calendar/events") assert resp.status_code == 200 assert all(e["id"] != eid for e in resp.json()["events"]), \ "User B should not see User A's event" # User B tries to get Alice's event directly -> 403 (exists but not owned) resp = client_b.get(f"/api/v1/calendar/events/{eid}") assert resp.status_code == 403 # User B tries to delete Alice's event -> 403 resp = client_b.delete(f"/api/v1/calendar/events/{eid}") assert resp.status_code == 403 # --------------------------------------------------------------------------- # 7. ICS export # --------------------------------------------------------------------------- def test_ics_export(client_a: TestClient) -> None: # Create an event client_a.post("/api/v1/calendar/events", json={ "title": "Export Test", "start_time": "2026-09-01T10:00:00+00:00", "end_time": "2026-09-01T11:00:00+00:00", }) # Export to ICS resp = client_a.get("/api/v1/calendar/export-ics") assert resp.status_code == 200 assert resp.headers["content-type"].startswith("text/calendar") content = resp.content.decode("utf-8") assert "BEGIN:VCALENDAR" in content assert "END:VCALENDAR" in content assert "Export Test" in content