171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
"""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 == []
|