"""Calendar plugin A1 API: calendars/events CRUD + range expansion (SPEC §A). Exercises the real HTTP surface: default-calendar bootstrap, event create/list with recurrence expansion, edit, delete, read-only rejection, and per-user isolation. Requires the calendar plugin installed + migrated. """ from __future__ import annotations import uuid from collections.abc import AsyncIterator import httpx import pytest_asyncio from httpx import ASGITransport from sqlalchemy import delete, text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import get_settings from app.identity.passwords import hash_password from app.identity.sessions import create_session from app.main import create_app from app.models import User CAL = "/api/plugins/calendar" class _Ctx: user_id: uuid.UUID sm: async_sessionmaker # type: ignore[type-arg] client: httpx.AsyncClient client_b: httpx.AsyncClient @pytest_asyncio.fixture async def ctx() -> AsyncIterator[_Ctx]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) c = _Ctx() c.sm = sm ids: list[uuid.UUID] = [] async with sm() as s: for _ in range(2): handle = f"cal{uuid.uuid4().hex[:8]}@test.local" u = User(handle=handle, password_hash=hash_password("pw")) s.add(u) await s.flush() ids.append(u.id) await s.commit() c.user_id = ids[0] transport = ASGITransport(app=create_app()) async def client(uid: uuid.UUID) -> httpx.AsyncClient: token = await create_session(uid, "pytest") return httpx.AsyncClient( transport=transport, base_url="http://testserver", cookies={"session": token, "csrf": "csrf"}, headers={"X-CSRF": "csrf"}, ) c.client = await client(ids[0]) c.client_b = await client(ids[1]) try: yield c finally: await c.client.aclose() await c.client_b.aclose() async with sm() as s: # cascade drops plg_calendar_* rows (user_id FK-less but scoped) await s.execute( text("DELETE FROM plg_calendar_calendars WHERE user_id = ANY(:ids)"), {"ids": [str(i) for i in ids]}, ) await s.execute(delete(User).where(User.id.in_(ids))) await s.commit() await engine.dispose() async def _events( client: httpx.AsyncClient, start: str = "2026-06-01T00:00:00Z", end: str = "2026-06-02T00:00:00Z", ) -> list[dict[str, object]]: r = await client.get(f"{CAL}/events", params={"start": start, "end": end}) return r.json()["events"] async def test_default_calendar_and_event_crud(ctx: _Ctx) -> None: # First calendars call bootstraps a default "Personal" calendar. cals = (await ctx.client.get(f"{CAL}/calendars")).json() assert [c["name"] for c in cals] == ["Personal"] cal_id = cals[0]["id"] # Create a timed event and read it back in a range. r = await ctx.client.post( f"{CAL}/events", json={ "calendar_id": cal_id, "summary": "Standup", "dtstart": "2026-06-01T09:00:00Z", "dtend": "2026-06-01T09:30:00Z", }, ) assert r.status_code == 200 event_id = r.json()["id"] listing = await _events(ctx.client) assert len(listing) == 1 assert listing[0]["summary"] == "Standup" assert listing[0]["color"] == "#3b82f6" # Edit, then delete. patched = await ctx.client.patch(f"{CAL}/events/{event_id}", json={"summary": "Daily standup"}) assert patched.status_code == 200 again = await _events(ctx.client) assert again[0]["summary"] == "Daily standup" assert (await ctx.client.delete(f"{CAL}/events/{event_id}")).status_code == 200 empty = await _events(ctx.client) assert empty == [] async def test_recurring_event_expands_in_range(ctx: _Ctx) -> None: cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"] await ctx.client.post( f"{CAL}/events", json={ "calendar_id": cal_id, "summary": "Weekly sync", "dtstart": "2026-06-01T14:00:00Z", # Monday "dtend": "2026-06-01T15:00:00Z", "rrule": "FREQ=WEEKLY;BYDAY=MO", }, ) # A 3-week window contains 3 Monday occurrences of one recurring event. listing = await _events(ctx.client, end="2026-06-22T00:00:00Z") assert len(listing) == 3 assert all(i["recurring"] for i in listing) assert [i["start"][:10] for i in listing] == ["2026-06-01", "2026-06-08", "2026-06-15"] async def test_read_only_calendar_rejects_writes(ctx: _Ctx) -> None: cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"] # Simulate an ICS subscription: flip the calendar read-only directly. async with ctx.sm() as s: await s.execute( text("UPDATE plg_calendar_calendars SET read_only = true WHERE id = :id"), {"id": cal_id}, ) await s.commit() r = await ctx.client.post( f"{CAL}/events", json={"calendar_id": cal_id, "summary": "nope", "dtstart": "2026-06-01T09:00:00Z"}, ) assert r.status_code == 403 body = r.json() detail = body.get("error", {}).get("message") or body.get("detail") assert detail == "read_only_calendar" async def test_events_are_per_user_isolated(ctx: _Ctx) -> None: cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"] await ctx.client.post( f"{CAL}/events", json={"calendar_id": cal_id, "summary": "Private", "dtstart": "2026-06-05T10:00:00Z"}, ) # User B has their own default calendar and sees none of A's events. b_events = await _events(ctx.client_b, end="2026-06-30T00:00:00Z") assert b_events == []