11"""
2- Integration tests for the caldav.jmap package against a live Cyrus IMAP server.
3-
4- These tests require the Cyrus Docker container to be running:
2+ Integration tests for the caldav.jmap package against live JMAP servers.
53
4+ Cyrus (port 8802):
65 docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d
76
8- If the server is not reachable on port 8802 the entire module is skipped
9- automatically — no failure, no noise.
7+ Stalwart (port 8806):
8+ docker-compose -f tests/docker-test-servers/stalwart/docker-compose.yml up -d
9+ ./tests/docker-test-servers/stalwart/setup_stalwart.sh
1010
11- Cyrus JMAP endpoint: http://localhost:8802/.well-known/jmap
12- Test credentials: user1 / x
11+ Each server's test classes are skipped automatically when that server is not
12+ reachable — no failure, no noise.
1313"""
1414
15+ import socket
1516import uuid
1617from datetime import datetime , timedelta , timezone
1718
3132
3233CYRUS_HOST = "localhost"
3334CYRUS_PORT = 8802
34- JMAP_URL = f"http://{ CYRUS_HOST } :{ CYRUS_PORT } /.well-known/jmap"
35+ CYRUS_JMAP_URL = f"http://{ CYRUS_HOST } :{ CYRUS_PORT } /.well-known/jmap"
3536CYRUS_USERNAME = "user1"
3637CYRUS_PASSWORD = "x"
3738
39+ STALWART_HOST = "localhost"
40+ STALWART_PORT = 8809
41+ STALWART_JMAP_URL = f"http://{ STALWART_HOST } :{ STALWART_PORT } /.well-known/jmap"
42+ STALWART_USERNAME = "testuser"
43+ STALWART_PASSWORD = "testpass"
3844
39- def _cyrus_reachable () -> bool :
40- import socket
4145
46+ def _reachable (host : str , port : int ) -> bool :
4247 try :
43- with socket .create_connection ((CYRUS_HOST , CYRUS_PORT ), timeout = 2 ):
48+ with socket .create_connection ((host , port ), timeout = 2 ):
4449 return True
4550 except OSError :
4651 return False
4752
4853
54+ _cyrus_up = _reachable (CYRUS_HOST , CYRUS_PORT )
55+ _stalwart_up = _reachable (STALWART_HOST , STALWART_PORT )
56+
57+ # Backward-compatible alias used by the module-level pytestmark below.
58+ # The mark only gates tests that don't carry their own skipif marker.
4959pytestmark = pytest .mark .skipif (
50- not _cyrus_reachable () ,
60+ not _cyrus_up ,
5161 reason = f"Cyrus Docker not reachable on { CYRUS_HOST } :{ CYRUS_PORT } — "
5262 "start it with: docker-compose -f tests/docker-test-servers/cyrus/docker-compose.yml up -d" ,
5363)
@@ -74,12 +84,12 @@ def _minimal_ical(title: str = "Test Event", start: datetime | None = None) -> s
7484
7585@pytest .fixture (scope = "module" )
7686def client ():
77- return JMAPClient (url = JMAP_URL , username = CYRUS_USERNAME , password = CYRUS_PASSWORD )
87+ return JMAPClient (url = CYRUS_JMAP_URL , username = CYRUS_USERNAME , password = CYRUS_PASSWORD )
7888
7989
8090@pytest .fixture (scope = "module" )
8191def session ():
82- return fetch_session (JMAP_URL , auth = HTTPBasicAuth (CYRUS_USERNAME , CYRUS_PASSWORD ))
92+ return fetch_session (CYRUS_JMAP_URL , auth = HTTPBasicAuth (CYRUS_USERNAME , CYRUS_PASSWORD ))
8393
8494
8595@pytest .fixture (scope = "module" )
@@ -101,7 +111,7 @@ def created_event_id(client, calendar_id):
101111
102112@pytest_asyncio .fixture
103113async def async_client ():
104- return AsyncJMAPClient (url = JMAP_URL , username = CYRUS_USERNAME , password = CYRUS_PASSWORD )
114+ return AsyncJMAPClient (url = CYRUS_JMAP_URL , username = CYRUS_USERNAME , password = CYRUS_PASSWORD )
105115
106116
107117@pytest_asyncio .fixture
@@ -123,6 +133,37 @@ async def async_created_event_id(async_client, async_calendar_id):
123133 pass
124134
125135
136+ _stalwart_skip = pytest .mark .skipif (
137+ not _stalwart_up ,
138+ reason = f"Stalwart Docker not reachable on { STALWART_HOST } :{ STALWART_PORT } — "
139+ "start it with: cd tests/docker-test-servers/stalwart && ./start.sh" ,
140+ )
141+
142+
143+ @pytest .fixture (scope = "module" )
144+ def stalwart_client ():
145+ return JMAPClient (url = STALWART_JMAP_URL , username = STALWART_USERNAME , password = STALWART_PASSWORD )
146+
147+
148+ @pytest .fixture (scope = "module" )
149+ def stalwart_calendar_id (stalwart_client ):
150+ calendars = stalwart_client .get_calendars ()
151+ assert calendars , "Stalwart did not return any calendars for user1"
152+ return calendars [0 ].id
153+
154+
155+ @pytest .fixture
156+ def stalwart_event_id (stalwart_client , stalwart_calendar_id ):
157+ event_id = stalwart_client .create_event (
158+ stalwart_calendar_id , _minimal_ical ("Stalwart Test Event" )
159+ )
160+ yield event_id
161+ try :
162+ stalwart_client .delete_event (event_id )
163+ except Exception :
164+ pass
165+
166+
126167class TestJMAPSessionIntegration :
127168 def test_session_fetch_returns_api_url (self , session ):
128169 assert session .api_url
@@ -256,3 +297,60 @@ async def test_ical_roundtrip(self, async_client, async_calendar_id):
256297 assert "20260715" in fetched
257298 finally :
258299 await async_client .delete_event (event_id )
300+
301+
302+ @_stalwart_skip
303+ class TestStalwartJMAPCalendarListIntegration :
304+ def test_list_calendars_returns_list (self , stalwart_client ):
305+ calendars = stalwart_client .get_calendars ()
306+ assert isinstance (calendars , list )
307+
308+ def test_calendars_have_id_and_name (self , stalwart_client ):
309+ calendars = stalwart_client .get_calendars ()
310+ assert len (calendars ) >= 1 , "Expected at least one calendar on Stalwart for user1"
311+ for cal in calendars :
312+ assert cal .id , f"Calendar missing id: { cal } "
313+ assert cal .name , f"Calendar has empty name: { cal } "
314+
315+
316+ @_stalwart_skip
317+ class TestStalwartJMAPEventIntegration :
318+ def test_event_create_get (self , stalwart_client , stalwart_event_id ):
319+ obj = stalwart_client .get_event (stalwart_event_id )
320+ ical = jscal_to_ical (obj .get_data ())
321+ assert "BEGIN:VCALENDAR" in ical
322+ assert "Stalwart Test Event" in ical
323+
324+ def test_event_update (self , stalwart_client , stalwart_event_id ):
325+ stalwart_client .update_event (stalwart_event_id , _minimal_ical ("Stalwart Updated Title" ))
326+ obj = stalwart_client .get_event (stalwart_event_id )
327+ assert "Stalwart Updated Title" in jscal_to_ical (obj .get_data ())
328+
329+ def test_event_delete (self , stalwart_client , stalwart_calendar_id ):
330+ event_id = stalwart_client .create_event (
331+ stalwart_calendar_id , _minimal_ical ("Stalwart To Be Deleted" )
332+ )
333+ stalwart_client .delete_event (event_id )
334+ with pytest .raises (JMAPMethodError ):
335+ stalwart_client .get_event (event_id )
336+
337+ def test_event_query_time_range (self , stalwart_client , stalwart_event_id ):
338+ # Stalwart does not support the inCalendars filter; query without calendar_id.
339+ results = stalwart_client .search_events (
340+ start = "2026-06-01T00:00:00" ,
341+ end = "2026-06-02T00:00:00" ,
342+ )
343+ assert len (results ) >= 1
344+ assert any ("Stalwart Test Event" in jscal_to_ical (r .get_data ()) for r in results )
345+
346+ def test_ical_roundtrip (self , stalwart_client , stalwart_calendar_id ):
347+ start = datetime (2026 , 7 , 15 , 9 , 0 , 0 , tzinfo = timezone .utc )
348+ event_id = stalwart_client .create_event (
349+ stalwart_calendar_id , _minimal_ical ("Stalwart Roundtrip Event" , start = start )
350+ )
351+ try :
352+ fetched = jscal_to_ical (stalwart_client .get_event (event_id ).get_data ())
353+ assert "Stalwart Roundtrip Event" in fetched
354+ assert "20260715" in fetched
355+ finally :
356+ stalwart_client .delete_event (event_id )
0 commit comments