@@ -827,6 +827,76 @@ public function testSearchSince(): void {
827827 $ this ->assertEquals ('1780297200 ' , $ result0Data ['attributes ' ]['createdAt ' ]);
828828 }
829829
830+ public function testSearchSinceOnlyDefaultsUntilToMaxDate (): void {
831+ // No until: expanding recurrences still needs a concrete upper bound,
832+ // so it defaults to CalDavBackend::MAX_DATE (effectively unbounded)
833+ // instead of guessing a caller's intent.
834+ $ user = $ this ->createMock (IUser::class);
835+ $ user ->method ('getUID ' )->willReturn ('john.doe ' );
836+ $ query = $ this ->createMock (ISearchQuery::class);
837+ $ query ->method ('getFilter ' )->willReturnCallback (function ($ name ) {
838+ return match ($ name ) {
839+ 'term ' => new StringFilter ('search term ' ),
840+ 'since ' => new DateTimeFilter ('2026-05-15 ' ),
841+ default => null ,
842+ };
843+ });
844+ $ query ->method ('getLimit ' )->willReturn (5 );
845+ $ query ->method ('getCursor ' )->willReturn (20 );
846+ $ this ->appManager ->method ('isEnabledForUser ' )->willReturn (true );
847+ $ this ->l10n ->method ('t ' )->willReturnArgument (0 );
848+ $ this ->backend ->method ('getCalendarsForUser ' )->willReturn ([]);
849+ $ this ->backend ->method ('getSubscriptionsForUser ' )->willReturn ([]);
850+
851+ $ since = new \DateTimeImmutable ('2026-05-15 00:00:00 ' );
852+ $ this ->backend ->expects ($ this ->once ())
853+ ->method ('searchPrincipalUri ' )
854+ ->with ('principals/users/john.doe ' , 'search term ' , ['VEVENT ' ],
855+ ['SUMMARY ' , 'LOCATION ' , 'DESCRIPTION ' , 'ATTENDEE ' , 'ORGANIZER ' , 'CATEGORIES ' ],
856+ ['ATTENDEE ' => ['CN ' ], 'ORGANIZER ' => ['CN ' ]],
857+ ['limit ' => 5 , 'offset ' => 20 , 'timerange ' => [
858+ 'start ' => $ since ,
859+ 'end ' => new \DateTimeImmutable (CalDavBackend::MAX_DATE , $ since ->getTimezone ()),
860+ ]])
861+ ->willReturn ([]);
862+
863+ $ this ->provider ->search ($ user , $ query );
864+ }
865+
866+ public function testSearchUntilOnlyDefaultsSinceToEpoch (): void {
867+ // No since: the lower bound defaults to the Unix epoch (effectively
868+ // unbounded into the past) for the same reason.
869+ $ user = $ this ->createMock (IUser::class);
870+ $ user ->method ('getUID ' )->willReturn ('john.doe ' );
871+ $ query = $ this ->createMock (ISearchQuery::class);
872+ $ query ->method ('getFilter ' )->willReturnCallback (function ($ name ) {
873+ return match ($ name ) {
874+ 'term ' => new StringFilter ('search term ' ),
875+ 'until ' => new DateTimeFilter ('2026-06-14 ' ),
876+ default => null ,
877+ };
878+ });
879+ $ query ->method ('getLimit ' )->willReturn (5 );
880+ $ query ->method ('getCursor ' )->willReturn (20 );
881+ $ this ->appManager ->method ('isEnabledForUser ' )->willReturn (true );
882+ $ this ->l10n ->method ('t ' )->willReturnArgument (0 );
883+ $ this ->backend ->method ('getCalendarsForUser ' )->willReturn ([]);
884+ $ this ->backend ->method ('getSubscriptionsForUser ' )->willReturn ([]);
885+
886+ $ this ->backend ->expects ($ this ->once ())
887+ ->method ('searchPrincipalUri ' )
888+ ->with ('principals/users/john.doe ' , 'search term ' , ['VEVENT ' ],
889+ ['SUMMARY ' , 'LOCATION ' , 'DESCRIPTION ' , 'ATTENDEE ' , 'ORGANIZER ' , 'CATEGORIES ' ],
890+ ['ATTENDEE ' => ['CN ' ], 'ORGANIZER ' => ['CN ' ]],
891+ ['limit ' => 5 , 'offset ' => 20 , 'timerange ' => [
892+ 'start ' => new \DateTimeImmutable ('@0 ' ),
893+ 'end ' => new \DateTimeImmutable ('2026-06-14 00:00:00 ' ),
894+ ]])
895+ ->willReturn ([]);
896+
897+ $ this ->provider ->search ($ user , $ query );
898+ }
899+
830900 public function testSearchDropsInvalidRowsWithoutStallingCursor (): void {
831901 // A row that resolves to no in-range occurrence must not prevent the
832902 // cursor from advancing, or pagination would keep repeating the same
0 commit comments