@@ -460,9 +460,142 @@ public function testRunCreateCalendarBadRequest(string $body, string $format, st
460460 $ refreshWebcalService ->refreshSubscription ('principals/users/testuser ' , 'sub123 ' );
461461 }
462462
463+ public function testDtstampChangeDoesNotTriggerUpdate (): void {
464+ $ refreshWebcalService = new RefreshWebcalService (
465+ $ this ->caldavBackend ,
466+ $ this ->logger ,
467+ $ this ->connection ,
468+ $ this ->timeFactory ,
469+ $ this ->importService
470+ );
471+
472+ $ this ->caldavBackend ->expects (self ::once ())
473+ ->method ('getSubscriptionsForUser ' )
474+ ->with ('principals/users/testuser ' )
475+ ->willReturn ([
476+ [
477+ 'id ' => '42 ' ,
478+ 'uri ' => 'sub123 ' ,
479+ RefreshWebcalService::STRIP_TODOS => '1 ' ,
480+ RefreshWebcalService::STRIP_ALARMS => '1 ' ,
481+ RefreshWebcalService::STRIP_ATTACHMENTS => '1 ' ,
482+ 'source ' => 'webcal://foo.bar/bla2 ' ,
483+ 'lastmodified ' => 0 ,
484+ ],
485+ ]);
486+
487+ // Feed body has a new DTSTAMP (as happens on every fetch from Google/Outlook)
488+ $ body = "BEGIN:VCALENDAR \r\nVERSION:2.0 \r\nPRODID:-//Test//Test//EN \r\nBEGIN:VEVENT \r\nUID:dtstamp-test \r\nDTSTAMP:20260209T120000Z \r\nDTSTART:20260301T100000Z \r\nSUMMARY:Test Event \r\nEND:VEVENT \r\nEND:VCALENDAR \r\n" ;
489+ $ stream = $ this ->createStreamFromString ($ body );
490+
491+ $ this ->connection ->expects (self ::once ())
492+ ->method ('queryWebcalFeed ' )
493+ ->willReturn (['data ' => $ stream , 'format ' => 'ical ' ]);
494+
495+ // The stored etag was computed from the DTSTAMP-stripped serialization
496+ $ existingEtag = md5 (preg_replace ('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m ' , '' , $ body ));
497+
498+ $ this ->caldavBackend ->expects (self ::once ())
499+ ->method ('getLimitedCalendarObjects ' )
500+ ->willReturn ([
501+ 'dtstamp-test ' => [
502+ 'id ' => 1 ,
503+ 'uid ' => 'dtstamp-test ' ,
504+ 'etag ' => $ existingEtag ,
505+ 'uri ' => 'dtstamp-test.ics ' ,
506+ ],
507+ ]);
508+
509+ $ vCalendar = VObject \Reader::read ($ body );
510+ $ generator = function () use ($ vCalendar ) {
511+ yield $ vCalendar ;
512+ };
513+
514+ $ this ->importService ->expects (self ::once ())
515+ ->method ('importText ' )
516+ ->willReturn ($ generator ());
517+
518+ // DTSTAMP-only change must NOT trigger an update
519+ $ this ->caldavBackend ->expects (self ::never ())
520+ ->method ('updateCalendarObject ' );
521+
522+ $ this ->caldavBackend ->expects (self ::never ())
523+ ->method ('createCalendarObject ' );
524+
525+ $ refreshWebcalService ->refreshSubscription ('principals/users/testuser ' , 'sub123 ' );
526+ }
527+
528+ public function testFoldedDtstampChangeDoesNotTriggerUpdate (): void {
529+ $ refreshWebcalService = new RefreshWebcalService (
530+ $ this ->caldavBackend ,
531+ $ this ->logger ,
532+ $ this ->connection ,
533+ $ this ->timeFactory ,
534+ $ this ->importService
535+ );
536+
537+ $ this ->caldavBackend ->expects (self ::once ())
538+ ->method ('getSubscriptionsForUser ' )
539+ ->with ('principals/users/testuser ' )
540+ ->willReturn ([
541+ [
542+ 'id ' => '42 ' ,
543+ 'uri ' => 'sub123 ' ,
544+ RefreshWebcalService::STRIP_TODOS => '1 ' ,
545+ RefreshWebcalService::STRIP_ALARMS => '1 ' ,
546+ RefreshWebcalService::STRIP_ATTACHMENTS => '1 ' ,
547+ 'source ' => 'webcal://foo.bar/bla2 ' ,
548+ 'lastmodified ' => 0 ,
549+ ],
550+ ]);
551+
552+ // DTSTAMP with TZID parameter exceeds 75 bytes, triggering RFC 5545 content line folding
553+ $ body = "BEGIN:VCALENDAR \r\nVERSION:2.0 \r\nPRODID:-//Test//Test//EN \r\nBEGIN:VEVENT \r\nUID:folded-dtstamp-test \r\nDTSTAMP;X-VOBJ-ORIGINAL-TZID=America/Argentina/Buenos_Aires:20260209T120000Z \r\nDTSTART:20260301T100000Z \r\nSUMMARY:Test Event \r\nEND:VEVENT \r\nEND:VCALENDAR \r\n" ;
554+ $ stream = $ this ->createStreamFromString ($ body );
555+
556+ $ this ->connection ->expects (self ::once ())
557+ ->method ('queryWebcalFeed ' )
558+ ->willReturn (['data ' => $ stream , 'format ' => 'ical ' ]);
559+
560+ // Compute etag from the serialized output (which will be folded) minus DTSTAMP
561+ $ vCalForEtag = VObject \Reader::read ($ body );
562+ $ serialized = $ vCalForEtag ->serialize ();
563+ $ existingEtag = md5 (preg_replace ('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m ' , '' , $ serialized ));
564+
565+ $ this ->caldavBackend ->expects (self ::once ())
566+ ->method ('getLimitedCalendarObjects ' )
567+ ->willReturn ([
568+ 'folded-dtstamp-test ' => [
569+ 'id ' => 1 ,
570+ 'uid ' => 'folded-dtstamp-test ' ,
571+ 'etag ' => $ existingEtag ,
572+ 'uri ' => 'folded-dtstamp-test.ics ' ,
573+ ],
574+ ]);
575+
576+ $ vCalendar = VObject \Reader::read ($ body );
577+ $ generator = function () use ($ vCalendar ) {
578+ yield $ vCalendar ;
579+ };
580+
581+ $ this ->importService ->expects (self ::once ())
582+ ->method ('importText ' )
583+ ->willReturn ($ generator ());
584+
585+ // Folded DTSTAMP change must NOT trigger an update
586+ $ this ->caldavBackend ->expects (self ::never ())
587+ ->method ('updateCalendarObject ' );
588+
589+ $ this ->caldavBackend ->expects (self ::never ())
590+ ->method ('createCalendarObject ' );
591+
592+ $ refreshWebcalService ->refreshSubscription ('principals/users/testuser ' , 'sub123 ' );
593+ }
594+
463595 public static function identicalDataProvider (): array {
464596 $ icalBody = "BEGIN:VCALENDAR \r\nVERSION:2.0 \r\nPRODID:-//Sabre//Sabre VObject " . VObject \Version::VERSION . "//EN \r\nCALSCALE:GREGORIAN \r\nBEGIN:VEVENT \r\nUID:12345 \r\nDTSTAMP:20160218T133704Z \r\nDTSTART;VALUE=DATE:19000101 \r\nDTEND;VALUE=DATE:19000102 \r\nRRULE:FREQ=YEARLY \r\nSUMMARY:12345's Birthday (1900) \r\nTRANSP:TRANSPARENT \r\nEND:VEVENT \r\nEND:VCALENDAR \r\n" ;
465- $ etag = md5 ($ icalBody );
597+ // Etag is computed from DTSTAMP-stripped serialization
598+ $ etag = md5 (preg_replace ('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m ' , '' , $ icalBody ));
466599
467600 return [
468601 [
0 commit comments