@@ -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 \n VERSION:2.0\r \n PRODID:-//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 \n DURATION: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 \n END: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 \n VERSION:2.0\r \n PRODID:-//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 \n DURATION: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 \n END: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 \n VERSION:2.0\r \n PRODID:-//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 \n DURATION: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 \n END: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 \n VERSION:2.0\r \n PRODID:-//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 \n DURATION: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 \n END: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 \n VERSION:2.0\r \n PRODID:-//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 \n DURATION: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 \n END: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