Skip to content

Commit 9953818

Browse files
committed
test: add async counterparts of all sync schedule-tag tests
Five async tests mirroring the _TestSchedulingBase schedule-tag tests: test_schedule_tag_returned_on_save test_schedule_tag_stable_on_partstate_update test_schedule_tag_changes_on_organizer_update test_schedule_tag_mismatch_raises_error test_schedule_tag_match_succeeds Against Stalwart, two fail as expected: - test_schedule_tag_stable_on_partstate_update: accept_invite() raises NotImplementedError for async clients - test_schedule_tag_mismatch_raises_error: _async_put() does not yet send If-Schedule-Tag-Match, so the server never returns 412 The other three pass because the async client already captures the Schedule-Tag response header on PUT and GET, and basic save/load works. prompt: "We need async tests mirroring the sync tests for all the scheduling features. They are expected to fail, as the recent work was only done for sync clients." AI Prompts: claude-sonnet-4-6: We need async tests mirroring the sync tests for all the scheduling features. They are expected to fail, as the recent work was only done for sync clients.
1 parent c7afbd8 commit 9953818

1 file changed

Lines changed: 310 additions & 0 deletions

File tree

tests/test_async_integration.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,316 @@ async def test_freebusy(self, scheduling_setup: Any) -> None:
666666
## Just verify it completes without raising; response format varies per server.
667667
await coro
668668

669+
# ------------------------------------------------------------------ #
670+
# Schedule-Tag tests (RFC 6638 section 3.2–3.3) #
671+
# These are async counterparts of the sync tests in #
672+
# _TestSchedulingBase. They are EXPECTED TO FAIL until async #
673+
# scheduling support (_async_put with If-Schedule-Tag-Match etc.) #
674+
# is implemented. #
675+
# ------------------------------------------------------------------ #
676+
677+
@pytest.mark.asyncio
678+
async def test_schedule_tag_returned_on_save(self, scheduling_setup: Any) -> None:
679+
"""Saving a scheduling object must return a Schedule-Tag header.
680+
681+
Async counterpart of testScheduleTagReturnedOnSave.
682+
Expected to fail: _async_put() does not yet capture the Schedule-Tag
683+
response header into event.props.
684+
"""
685+
import uuid
686+
687+
clients, principals, calendars, auto_uids = scheduling_setup
688+
self._skip_unless_support("scheduling.schedule-tag")
689+
if len(principals) < 2:
690+
pytest.skip("need 2 principals")
691+
692+
organizer_cal = calendars[0]
693+
addr = await principals[0].get_vcal_address()
694+
addr2 = await principals[1].get_vcal_address()
695+
uid = str(uuid.uuid4())
696+
ical = (
697+
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n"
698+
"BEGIN:VEVENT\r\n"
699+
f"UID:{uid}\r\n"
700+
"DTSTAMP:20260101T000000Z\r\n"
701+
"DTSTART:20320601T100000Z\r\nDURATION:PT1H\r\n"
702+
"SUMMARY:Schedule-Tag test\r\n"
703+
f"ORGANIZER:{addr}\r\n"
704+
f"ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:{addr}\r\n"
705+
f"ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:{addr2}\r\n"
706+
"END:VEVENT\r\nEND:VCALENDAR\r\n"
707+
)
708+
event = await organizer_cal.save_event(ical)
709+
auto_uids.append(uid)
710+
711+
assert event.schedule_tag is not None, "Server did not return Schedule-Tag header after PUT"
712+
713+
@pytest.mark.asyncio
714+
async def test_schedule_tag_stable_on_partstate_update(self, scheduling_setup: Any) -> None:
715+
"""PARTSTAT-only update must not change the Schedule-Tag.
716+
717+
Async counterpart of testScheduleTagStableOnPartstateUpdate.
718+
Expected to fail: accept_invite() raises NotImplementedError for
719+
async clients.
720+
"""
721+
import uuid
722+
723+
clients, principals, calendars, auto_uids = scheduling_setup
724+
self._skip_unless_support("scheduling.schedule-tag")
725+
if len(principals) < 2:
726+
pytest.skip("need 2 principals")
727+
if not clients[1].features.is_supported("scheduling.mailbox.inbox-delivery"):
728+
pytest.skip("server does not deliver iTIP requests to the inbox")
729+
730+
organizer_cal = calendars[0]
731+
attendee_cal = calendars[1]
732+
organizer_addr = await principals[0].get_vcal_address()
733+
attendee_addr = await principals[1].get_vcal_address()
734+
uid = str(uuid.uuid4())
735+
ical = (
736+
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n"
737+
"BEGIN:VEVENT\r\n"
738+
f"UID:{uid}\r\n"
739+
"SEQUENCE:0\r\n"
740+
"DTSTAMP:20260101T000000Z\r\n"
741+
"DTSTART:20320601T100000Z\r\nDURATION:PT1H\r\n"
742+
"SUMMARY:Partstat stability test\r\n"
743+
f"ORGANIZER:{organizer_addr}\r\n"
744+
f"ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:{attendee_addr}\r\n"
745+
"END:VEVENT\r\nEND:VCALENDAR\r\n"
746+
)
747+
saved_event = await organizer_cal.save_with_invites(ical, [principals[0], attendee_addr])
748+
auto_uids.append(uid)
749+
750+
## Wait for the REQUEST invite to land in attendee's inbox
751+
invite = None
752+
for _ in range(30):
753+
inbox = await principals[1].schedule_inbox()
754+
for item in await inbox.get_items():
755+
await item.load()
756+
if item.is_invite_request() and item.id == saved_event.id:
757+
invite = item
758+
break
759+
if invite:
760+
break
761+
await asyncio.sleep(1)
762+
763+
if not invite:
764+
pytest.skip("Invite not delivered to attendee inbox; cannot test PARTSTAT stability")
765+
766+
## accept_invite is not yet implemented for async clients
767+
invite.accept_invite(calendar=attendee_cal)
768+
769+
## Find the attendee's copy
770+
attendee_event = None
771+
for _ in range(5):
772+
for cal in await principals[1].calendars():
773+
try:
774+
attendee_event = await cal.get_event_by_uid(saved_event.id)
775+
break
776+
except Exception:
777+
pass
778+
if attendee_event:
779+
break
780+
await asyncio.sleep(1)
781+
782+
assert attendee_event is not None, "Event not found in any attendee calendar after accept"
783+
await attendee_event.load()
784+
tag_before = attendee_event.schedule_tag
785+
assert tag_before is not None, "No Schedule-Tag on attendee's calendar event after accept"
786+
787+
## PARTSTAT-only change — tag must not move
788+
attendee_event.change_attendee_status(partstat="TENTATIVE")
789+
await attendee_event.save()
790+
await attendee_event.load()
791+
tag_after = attendee_event.schedule_tag
792+
793+
assert tag_after is not None, "No Schedule-Tag on attendee's event after PARTSTAT update"
794+
assert tag_before == tag_after, (
795+
f"Schedule-Tag changed after PARTSTAT-only update: {tag_before!r}{tag_after!r}"
796+
)
797+
798+
@pytest.mark.asyncio
799+
async def test_schedule_tag_changes_on_organizer_update(self, scheduling_setup: Any) -> None:
800+
"""Organizer update must advance the Schedule-Tag on the attendee's copy.
801+
802+
Async counterpart of testScheduleTagChangesOnOrganizerUpdate.
803+
Expected to fail: _async_load() does not yet capture the Schedule-Tag
804+
response header.
805+
"""
806+
import uuid
807+
808+
clients, principals, calendars, auto_uids = scheduling_setup
809+
self._skip_unless_support("scheduling.schedule-tag")
810+
if len(principals) < 2:
811+
pytest.skip("need 2 principals")
812+
813+
organizer_cal = calendars[0]
814+
organizer_addr = await principals[0].get_vcal_address()
815+
attendee_addr = await principals[1].get_vcal_address()
816+
uid = str(uuid.uuid4())
817+
seqno = 0
818+
819+
def _make_ical(summary: str) -> str:
820+
nonlocal seqno
821+
s = seqno
822+
seqno += 1
823+
return (
824+
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n"
825+
"BEGIN:VEVENT\r\n"
826+
f"UID:{uid}\r\n"
827+
f"SEQUENCE:{s}\r\n"
828+
"DTSTAMP:20260101T000000Z\r\n"
829+
"DTSTART:20320601T100000Z\r\nDURATION:PT1H\r\n"
830+
f"SUMMARY:{summary}\r\n"
831+
f"ORGANIZER:{organizer_addr}\r\n"
832+
f"ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:{attendee_addr}\r\n"
833+
"END:VEVENT\r\nEND:VCALENDAR\r\n"
834+
)
835+
836+
await organizer_cal.save_with_invites(
837+
_make_ical("Original summary"), [principals[0], attendee_addr]
838+
)
839+
auto_uids.append(uid)
840+
841+
## Poll for attendee's copy
842+
attendee_event = None
843+
for _ in range(30):
844+
for cal in await principals[1].calendars():
845+
for ev in await cal.get_events():
846+
if ev.id == uid:
847+
attendee_event = ev
848+
break
849+
if attendee_event:
850+
break
851+
if attendee_event:
852+
break
853+
await asyncio.sleep(1)
854+
855+
if attendee_event is None:
856+
pytest.skip("Event not delivered to attendee; cannot test tag change")
857+
858+
await attendee_event.load()
859+
tag_before = attendee_event.schedule_tag
860+
assert tag_before is not None, "No Schedule-Tag on attendee's copy before organizer update"
861+
862+
## Organizer sends a substantive update
863+
await organizer_cal.save_with_invites(
864+
_make_ical("Updated summary"), [principals[0], attendee_addr]
865+
)
866+
867+
## Poll until the tag advances
868+
for _ in range(30):
869+
await attendee_event.load()
870+
if attendee_event.schedule_tag != tag_before:
871+
break
872+
await asyncio.sleep(1)
873+
874+
assert attendee_event.schedule_tag != tag_before, (
875+
f"Schedule-Tag did not change after organizer update: still {tag_before!r}"
876+
)
877+
878+
@pytest.mark.asyncio
879+
async def test_schedule_tag_mismatch_raises_error(self, scheduling_setup: Any) -> None:
880+
"""save() with a stale Schedule-Tag must raise ScheduleTagMismatchError.
881+
882+
Async counterpart of testScheduleTagMismatchRaisesError.
883+
Expected to fail: _async_put() does not yet send If-Schedule-Tag-Match
884+
or raise ScheduleTagMismatchError on a 412 response.
885+
"""
886+
import uuid
887+
888+
from caldav.lib import error
889+
890+
clients, principals, calendars, auto_uids = scheduling_setup
891+
self._skip_unless_support("scheduling.schedule-tag")
892+
if len(principals) < 2:
893+
pytest.skip("need 2 principals to cause a server-side tag advance")
894+
895+
organizer_cal = calendars[0]
896+
organizer_addr = await principals[0].get_vcal_address()
897+
attendee_addr = await principals[1].get_vcal_address()
898+
uid = str(uuid.uuid4())
899+
900+
def _make_ical(summary: str, seq: int) -> str:
901+
return (
902+
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n"
903+
"BEGIN:VEVENT\r\n"
904+
f"UID:{uid}\r\n"
905+
f"SEQUENCE:{seq}\r\n"
906+
"DTSTAMP:20260101T000000Z\r\n"
907+
"DTSTART:20320601T100000Z\r\nDURATION:PT1H\r\n"
908+
f"SUMMARY:{summary}\r\n"
909+
f"ORGANIZER:{organizer_addr}\r\n"
910+
f"ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:{attendee_addr}\r\n"
911+
"END:VEVENT\r\nEND:VCALENDAR\r\n"
912+
)
913+
914+
## Create event, load it: event holds original content + tag=1
915+
event = await organizer_cal.save_event(_make_ical("Original", 0))
916+
auto_uids.append(uid)
917+
await event.load()
918+
assert event.schedule_tag is not None, (
919+
"server did not return Schedule-Tag after initial save"
920+
)
921+
922+
## Make a local conflicting edit before the concurrent organizer update
923+
event.icalendar_component["SUMMARY"] = "Conflicting client change"
924+
925+
## Concurrent organizer PUT advances the server-side tag
926+
await organizer_cal.save_event(_make_ical("Organizer update", 1))
927+
928+
## PUT stale content with stale tag — server must reject with 412
929+
with pytest.raises(error.ScheduleTagMismatchError):
930+
await event.save(increase_seqno=False)
931+
932+
@pytest.mark.asyncio
933+
async def test_schedule_tag_match_succeeds(self, scheduling_setup: Any) -> None:
934+
"""save() with the correct Schedule-Tag must succeed.
935+
936+
Async counterpart of testScheduleTagMatchSucceeds.
937+
Expected to fail: _async_put() does not yet send If-Schedule-Tag-Match,
938+
so the conditional PUT is not exercised.
939+
"""
940+
import uuid
941+
942+
clients, principals, calendars, auto_uids = scheduling_setup
943+
self._skip_unless_support("scheduling.schedule-tag")
944+
if len(principals) < 2:
945+
pytest.skip("need 2 principals for Schedule-Tag to be assigned")
946+
947+
cal = calendars[0]
948+
addr = await principals[0].get_vcal_address()
949+
addr2 = await principals[1].get_vcal_address()
950+
uid = str(uuid.uuid4())
951+
ical = (
952+
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n"
953+
"BEGIN:VEVENT\r\n"
954+
f"UID:{uid}\r\n"
955+
"DTSTAMP:20260101T000000Z\r\n"
956+
"DTSTART:20320601T100000Z\r\nDURATION:PT1H\r\n"
957+
"SUMMARY:Correct-tag test\r\n"
958+
f"ORGANIZER:{addr}\r\n"
959+
f"ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:{addr}\r\n"
960+
f"ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:{addr2}\r\n"
961+
"END:VEVENT\r\nEND:VCALENDAR\r\n"
962+
)
963+
event = await cal.save_event(ical)
964+
auto_uids.append(uid)
965+
await event.load()
966+
967+
tag_before = event.schedule_tag
968+
assert tag_before is not None, "Server did not return Schedule-Tag"
969+
970+
## Minor update with the correct tag — must not raise
971+
event.icalendar_component["SUMMARY"] = "Correct-tag test (updated)"
972+
await event.save(increase_seqno=False)
973+
974+
## Tag must still be present after save
975+
assert event.schedule_tag is not None, (
976+
"schedule_tag property disappeared after conditional save"
977+
)
978+
669979

670980
# ==================== Dynamic Test Class Generation ====================
671981
#

0 commit comments

Comments
 (0)