Skip to content

Commit c85c626

Browse files
feat(jmap): add event integration tests; fix UTC start encoding in ical_to_jscal
1 parent 98fa9cc commit c85c626

3 files changed

Lines changed: 180 additions & 6 deletions

File tree

caldav/jmap/convert/ical_to_jscal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ def _dtstart_to_jscal(dtstart_prop) -> tuple[str, str | None, bool]:
5454
return f"{dt.isoformat()}T00:00:00", None, True
5555

5656
if dt.tzinfo is not None and dt.utcoffset() == timedelta(0):
57-
# UTC (Z suffix)
58-
return dt.strftime("%Y-%m-%dT%H:%M:%SZ"), None, False
57+
# UTC — JSCalendar start is LocalDateTime; express via timeZone="Etc/UTC"
58+
return dt.strftime("%Y-%m-%dT%H:%M:%S"), "Etc/UTC", False
5959

6060
if dt.tzinfo is not None:
6161
# Timezone-aware — prefer the TZID parameter (IANA name) over tzinfo repr

tests/test_jmap_integration.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,20 @@
1212
Test credentials: user1 / x
1313
"""
1414

15+
import uuid
16+
from datetime import datetime, timedelta, timezone
17+
1518
import pytest
19+
import pytest_asyncio
1620

1721
try:
1822
from niquests.auth import HTTPBasicAuth
1923
except ImportError:
2024
from requests.auth import HTTPBasicAuth # type: ignore[no-redef]
2125

22-
from caldav.jmap import JMAPClient
26+
from caldav.jmap import AsyncJMAPClient, JMAPClient
2327
from caldav.jmap.constants import CALENDAR_CAPABILITY
28+
from caldav.jmap.error import JMAPMethodError
2429
from caldav.jmap.session import fetch_session
2530

2631
CYRUS_HOST = "localhost"
@@ -47,6 +52,25 @@ def _cyrus_reachable() -> bool:
4752
)
4853

4954

55+
def _minimal_ical(title: str = "Test Event", start: datetime | None = None) -> str:
56+
if start is None:
57+
start = datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc)
58+
end = start + timedelta(hours=1)
59+
uid = str(uuid.uuid4())
60+
return (
61+
"BEGIN:VCALENDAR\r\n"
62+
"VERSION:2.0\r\n"
63+
"PRODID:-//test//test//EN\r\n"
64+
"BEGIN:VEVENT\r\n"
65+
f"UID:{uid}\r\n"
66+
f"SUMMARY:{title}\r\n"
67+
f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\r\n"
68+
f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\r\n"
69+
"END:VEVENT\r\n"
70+
"END:VCALENDAR\r\n"
71+
)
72+
73+
5074
@pytest.fixture(scope="module")
5175
def client():
5276
return JMAPClient(url=JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD)
@@ -57,6 +81,47 @@ def session():
5781
return fetch_session(JMAP_URL, auth=HTTPBasicAuth(CYRUS_USERNAME, CYRUS_PASSWORD))
5882

5983

84+
@pytest.fixture(scope="module")
85+
def calendar_id(client):
86+
calendars = client.get_calendars()
87+
assert calendars, "Cyrus did not provision any calendars for user1"
88+
return calendars[0].id
89+
90+
91+
@pytest.fixture
92+
def created_event_id(client, calendar_id):
93+
event_id = client.create_event(calendar_id, _minimal_ical("Integration Test Event"))
94+
yield event_id
95+
try:
96+
client.delete_event(event_id)
97+
except Exception:
98+
pass
99+
100+
101+
@pytest_asyncio.fixture
102+
async def async_client():
103+
return AsyncJMAPClient(url=JMAP_URL, username=CYRUS_USERNAME, password=CYRUS_PASSWORD)
104+
105+
106+
@pytest_asyncio.fixture
107+
async def async_calendar_id(async_client):
108+
calendars = await async_client.get_calendars()
109+
assert calendars, "Cyrus did not provision any calendars for user1"
110+
return calendars[0].id
111+
112+
113+
@pytest_asyncio.fixture
114+
async def async_created_event_id(async_client, async_calendar_id):
115+
event_id = await async_client.create_event(
116+
async_calendar_id, _minimal_ical("Async Integration Test Event")
117+
)
118+
yield event_id
119+
try:
120+
await async_client.delete_event(event_id)
121+
except Exception:
122+
pass
123+
124+
60125
class TestJMAPSessionIntegration:
61126
def test_session_fetch_returns_api_url(self, session):
62127
assert session.api_url
@@ -80,3 +145,111 @@ def test_calendars_have_id_and_name(self, client):
80145
for cal in calendars:
81146
assert cal.id, f"Calendar missing id: {cal}"
82147
assert cal.name, f"Calendar has empty name: {cal}"
148+
149+
150+
class TestJMAPEventIntegration:
151+
def test_event_create_get(self, client, created_event_id):
152+
ical = client.get_event(created_event_id)
153+
assert "BEGIN:VCALENDAR" in ical
154+
assert "Integration Test Event" in ical
155+
156+
def test_event_update(self, client, created_event_id):
157+
client.update_event(created_event_id, _minimal_ical("Updated Title"))
158+
fetched = client.get_event(created_event_id)
159+
assert "Updated Title" in fetched
160+
161+
def test_event_delete(self, client, calendar_id):
162+
event_id = client.create_event(calendar_id, _minimal_ical("To Be Deleted"))
163+
client.delete_event(event_id)
164+
with pytest.raises(JMAPMethodError):
165+
client.get_event(event_id)
166+
167+
def test_event_query_time_range(self, client, calendar_id, created_event_id):
168+
results = client.search_events(
169+
calendar_id=calendar_id,
170+
start="2026-06-01T00:00:00",
171+
end="2026-06-02T00:00:00",
172+
)
173+
assert len(results) >= 1
174+
assert any("Integration Test Event" in r for r in results)
175+
176+
def test_event_sync(self, client, calendar_id):
177+
token_before = client.get_sync_token()
178+
event_id = client.create_event(calendar_id, _minimal_ical("Sync Test Event"))
179+
try:
180+
added, _modified, _deleted = client.get_objects_by_sync_token(token_before)
181+
assert any("Sync Test Event" in a for a in added)
182+
finally:
183+
client.delete_event(event_id)
184+
185+
def test_ical_roundtrip(self, client, calendar_id):
186+
start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc)
187+
event_id = client.create_event(calendar_id, _minimal_ical("Roundtrip Event", start=start))
188+
try:
189+
fetched = client.get_event(event_id)
190+
assert "Roundtrip Event" in fetched
191+
assert "20260715" in fetched
192+
finally:
193+
client.delete_event(event_id)
194+
195+
196+
class TestAsyncJMAPEventIntegration:
197+
@pytest.mark.asyncio
198+
async def test_event_create_get(self, async_client, async_created_event_id):
199+
ical = await async_client.get_event(async_created_event_id)
200+
assert "BEGIN:VCALENDAR" in ical
201+
assert "Async Integration Test Event" in ical
202+
203+
@pytest.mark.asyncio
204+
async def test_event_update(self, async_client, async_created_event_id):
205+
await async_client.update_event(
206+
async_created_event_id, _minimal_ical("Async Updated Title")
207+
)
208+
fetched = await async_client.get_event(async_created_event_id)
209+
assert "Async Updated Title" in fetched
210+
211+
@pytest.mark.asyncio
212+
async def test_event_delete(self, async_client, async_calendar_id):
213+
event_id = await async_client.create_event(
214+
async_calendar_id, _minimal_ical("Async To Be Deleted")
215+
)
216+
await async_client.delete_event(event_id)
217+
with pytest.raises(JMAPMethodError):
218+
await async_client.get_event(event_id)
219+
220+
@pytest.mark.asyncio
221+
async def test_event_query_time_range(
222+
self, async_client, async_calendar_id, async_created_event_id
223+
):
224+
results = await async_client.search_events(
225+
calendar_id=async_calendar_id,
226+
start="2026-06-01T00:00:00",
227+
end="2026-06-02T00:00:00",
228+
)
229+
assert len(results) >= 1
230+
assert any("Async Integration Test Event" in r for r in results)
231+
232+
@pytest.mark.asyncio
233+
async def test_event_sync(self, async_client, async_calendar_id):
234+
token_before = await async_client.get_sync_token()
235+
event_id = await async_client.create_event(
236+
async_calendar_id, _minimal_ical("Async Sync Test Event")
237+
)
238+
try:
239+
added, _modified, _deleted = await async_client.get_objects_by_sync_token(token_before)
240+
assert any("Async Sync Test Event" in a for a in added)
241+
finally:
242+
await async_client.delete_event(event_id)
243+
244+
@pytest.mark.asyncio
245+
async def test_ical_roundtrip(self, async_client, async_calendar_id):
246+
start = datetime(2026, 7, 15, 9, 0, 0, tzinfo=timezone.utc)
247+
event_id = await async_client.create_event(
248+
async_calendar_id, _minimal_ical("Async Roundtrip Event", start=start)
249+
)
250+
try:
251+
fetched = await async_client.get_event(event_id)
252+
assert "Async Roundtrip Event" in fetched
253+
assert "20260715" in fetched
254+
finally:
255+
await async_client.delete_event(event_id)

tests/test_jmap_unit.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,7 +1011,8 @@ def test_minimal_event(self):
10111011
result = ical_to_jscal(ical)
10121012
assert result["uid"] == "test-uid@example.com"
10131013
assert result["title"] == "Test Event"
1014-
assert result["start"] == "2024-06-15T10:00:00Z"
1014+
assert result["start"] == "2024-06-15T10:00:00"
1015+
assert result["timeZone"] == "Etc/UTC"
10151016
assert result["duration"] == "PT1H"
10161017

10171018
def test_all_day_event(self):
@@ -1036,8 +1037,8 @@ def test_timezone_aware_event(self):
10361037
def test_utc_event(self):
10371038
ical = _make_ical("DTSTART:20240615T100000Z\r\nDURATION:PT30M\r\nSUMMARY:UTC Event\r\n")
10381039
result = ical_to_jscal(ical)
1039-
assert result["start"].endswith("Z")
1040-
assert "timeZone" not in result
1040+
assert result["start"] == "2024-06-15T10:00:00"
1041+
assert result["timeZone"] == "Etc/UTC"
10411042

10421043
def test_duration_from_dtend(self):
10431044
ical = _make_ical(

0 commit comments

Comments
 (0)