@@ -460,8 +460,139 @@ public function testRunCreateCalendarBadRequest(string $body, string $format, st
460460 $ refreshWebcalService ->refreshSubscription ('principals/users/testuser ' , 'sub123 ' );
461461 }
462462
463+ public function testDtstampIsStrippedFromOutput (): 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+ // Input has DTSTAMP
488+ $ body = "BEGIN:VCALENDAR \r\nVERSION:2.0 \r\nPRODID:-//Test//Test//EN \r\nBEGIN:VEVENT \r\nUID:strip-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+ $ this ->caldavBackend ->expects (self ::once ())
496+ ->method ('getLimitedCalendarObjects ' )
497+ ->willReturn ([]);
498+
499+ // Feed the VCalendar with DTSTAMP into the service
500+ $ vCalendar = VObject \Reader::read ($ body );
501+ $ generator = function () use ($ vCalendar ) {
502+ yield $ vCalendar ;
503+ };
504+
505+ $ this ->importService ->expects (self ::once ())
506+ ->method ('importText ' )
507+ ->willReturn ($ generator ());
508+
509+ // Verify DTSTAMP is absent from the data passed to createCalendarObject
510+ $ this ->caldavBackend ->expects (self ::once ())
511+ ->method ('createCalendarObject ' )
512+ ->with (
513+ '42 ' ,
514+ self ::matchesRegularExpression ('/^[a-f0-9-]+\.ics$/ ' ),
515+ self ::callback (function (string $ calendarData ): bool {
516+ self ::assertStringContainsString ('DTSTART ' , $ calendarData );
517+ self ::assertStringNotContainsString ('DTSTAMP ' , $ calendarData );
518+ return true ;
519+ }),
520+ CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
521+ );
522+
523+ $ refreshWebcalService ->refreshSubscription ('principals/users/testuser ' , 'sub123 ' );
524+ }
525+
526+ public function testDtstampChangeDoesNotTriggerUpdate (): void {
527+ $ refreshWebcalService = new RefreshWebcalService (
528+ $ this ->caldavBackend ,
529+ $ this ->logger ,
530+ $ this ->connection ,
531+ $ this ->timeFactory ,
532+ $ this ->importService
533+ );
534+
535+ $ this ->caldavBackend ->expects (self ::once ())
536+ ->method ('getSubscriptionsForUser ' )
537+ ->with ('principals/users/testuser ' )
538+ ->willReturn ([
539+ [
540+ 'id ' => '42 ' ,
541+ 'uri ' => 'sub123 ' ,
542+ RefreshWebcalService::STRIP_TODOS => '1 ' ,
543+ RefreshWebcalService::STRIP_ALARMS => '1 ' ,
544+ RefreshWebcalService::STRIP_ATTACHMENTS => '1 ' ,
545+ 'source ' => 'webcal://foo.bar/bla2 ' ,
546+ 'lastmodified ' => 0 ,
547+ ],
548+ ]);
549+
550+ // Feed body has a new DTSTAMP (as happens on every fetch from Google/Outlook)
551+ $ 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" ;
552+ $ stream = $ this ->createStreamFromString ($ body );
553+
554+ $ this ->connection ->expects (self ::once ())
555+ ->method ('queryWebcalFeed ' )
556+ ->willReturn (['data ' => $ stream , 'format ' => 'ical ' ]);
557+
558+ // The stored etag was computed from the DTSTAMP-stripped serialization.
559+ // Reader::read() preserves the original PRODID and does not add CALSCALE.
560+ $ strippedBody = "BEGIN:VCALENDAR \r\nVERSION:2.0 \r\nPRODID:-//Test//Test//EN \r\nBEGIN:VEVENT \r\nUID:dtstamp-test \r\nDTSTART:20260301T100000Z \r\nSUMMARY:Test Event \r\nEND:VEVENT \r\nEND:VCALENDAR \r\n" ;
561+ $ existingEtag = md5 ($ strippedBody );
562+
563+ $ this ->caldavBackend ->expects (self ::once ())
564+ ->method ('getLimitedCalendarObjects ' )
565+ ->willReturn ([
566+ 'dtstamp-test ' => [
567+ 'id ' => 1 ,
568+ 'uid ' => 'dtstamp-test ' ,
569+ 'etag ' => $ existingEtag ,
570+ 'uri ' => 'dtstamp-test.ics ' ,
571+ ],
572+ ]);
573+
574+ $ vCalendar = VObject \Reader::read ($ body );
575+ $ generator = function () use ($ vCalendar ) {
576+ yield $ vCalendar ;
577+ };
578+
579+ $ this ->importService ->expects (self ::once ())
580+ ->method ('importText ' )
581+ ->willReturn ($ generator ());
582+
583+ // DTSTAMP-only change must NOT trigger an update
584+ $ this ->caldavBackend ->expects (self ::never ())
585+ ->method ('updateCalendarObject ' );
586+
587+ $ this ->caldavBackend ->expects (self ::never ())
588+ ->method ('createCalendarObject ' );
589+
590+ $ refreshWebcalService ->refreshSubscription ('principals/users/testuser ' , 'sub123 ' );
591+ }
592+
463593 public static function identicalDataProvider (): array {
464- $ 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" ;
594+ // DTSTAMP is stripped before etag computation, so the etag body must not contain it
595+ $ 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\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" ;
465596 $ etag = md5 ($ icalBody );
466597
467598 return [
@@ -486,7 +617,8 @@ public static function runDataProvider(): array {
486617 [
487618 "BEGIN:VCALENDAR \r\nVERSION:2.0 \r\nPRODID:-//Sabre//Sabre VObject 4.1.1//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" ,
488619 'ical ' ,
489- "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" ,
620+ // DTSTAMP is stripped before serialization, so the expected result must not contain it
621+ "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\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" ,
490622 ],
491623 ];
492624 }
0 commit comments