builder = new MessageQueryBuilder(); } public function testNoFilterMatchesEverything(): void { $this->assertSame([], $this->builder->filter(null)); $this->assertFalse($this->builder->needsServer(null)); } public function testBodyAndFullTextNeedTheServer(): void { $this->assertTrue($this->builder->needsServer($this->filter(['body', 'invoice']))); $this->assertTrue($this->builder->needsServer($this->filter(['*', 'invoice']))); $this->assertFalse($this->builder->needsServer($this->filter(['subject', 'invoice']))); } public function testSubjectIsACaseInsensitiveSubstring(): void { $filter = $this->builder->filter($this->filter(['subject', 'Q3 (draft)'])); $this->assertEquals(['subject' => new Regex('Q3 \\(draft\\)', 'i')], $filter); } public function testAddressesMatchAddressOrLabel(): void { $filter = $this->builder->filter($this->filter(['from', 'alice'])); $this->assertEquals(['$or' => [ ['from.address' => new Regex('alice', 'i')], ['from.label' => new Regex('alice', 'i')], ]], $filter); } public function testNegationAndMultipleValues(): void { $not = $this->builder->filter($this->filter(['subject', 'spam', FilterComparisonOperator::NLIKE])); $any = $this->builder->filter($this->filter(['subject', ['a', 'b'], FilterComparisonOperator::IN])); $this->assertEquals(['$nor' => [['subject' => new Regex('spam', 'i')]]], $not); $this->assertEquals(['$or' => [['subject' => new Regex('a', 'i')], ['subject' => new Regex('b', 'i')]]], $any); } public function testConditionsCombineLeftToRightWithTheirConjunction(): void { $filter = $this->builder->filter($this->filter( ['subject', 'a'], ['subject', 'b', null, FilterConjunctionOperator::OR], ['min', 100, FilterComparisonOperator::GTE, FilterConjunctionOperator::AND], )); $this->assertEquals(['$and' => [ ['$or' => [['subject' => new Regex('a', 'i')], ['subject' => new Regex('b', 'i')]]], ['size' => ['$gte' => 100]], ]], $filter); } public function testDatesCompareWholeUtcDaysOnReceived(): void { $before = $this->builder->filter($this->filter(['before', '2026-10-05T18:00:00+02:00', FilterComparisonOperator::LT])); $after = $this->builder->filter($this->filter(['after', '2026-10-05', FilterComparisonOperator::GTE])); $on = $this->builder->filter($this->filter(['after', '2026-10-05', FilterComparisonOperator::EQ])); $this->assertSame(['received' => ['$lt' => '2026-10-05T00:00:00Z']], $before); $this->assertSame(['received' => ['$gte' => '2026-10-05T00:00:00Z']], $after); $this->assertSame(['received' => ['$gte' => '2026-10-05T00:00:00Z', '$lt' => '2026-10-06T00:00:00Z']], $on); } public function testSizes(): void { $this->assertSame(['size' => ['$lte' => 500]], $this->builder->filter($this->filter(['max', 500, FilterComparisonOperator::LTE]))); $this->assertSame(['size' => ['$gt' => 10]], $this->builder->filter($this->filter(['min', 10, FilterComparisonOperator::GT]))); } public function testUnknownAttributesAndEmptyValuesAreIgnored(): void { $this->assertSame([], $this->builder->filter($this->filter(['unknown', 'x'], ['subject', ' ']))); } public function testSortMapsFieldsAndEndsWithUid(): void { $sort = new Sort(['received' => true, 'subject' => true, 'to' => true]); $sort->condition('received', false); $this->assertSame(['sort' => ['received' => -1, 'uid' => 1], 'collation' => null], $this->builder->sort($sort)); $sort->condition('to', true); $result = $this->builder->sort($sort); $this->assertSame(['received' => -1, 'to.0.address' => 1, 'uid' => 1], $result['sort']); $this->assertSame(['locale' => 'en', 'strength' => 2], $result['collation']); } public function testNoSortIsUidOrder(): void { $this->assertSame(['sort' => ['uid' => 1], 'collation' => null], $this->builder->sort(null)); } public function testMetaStoreQueryExcludesTombstonesAndAppliesSortAndCollation(): void { $collection = $this->createMock(Collection::class); $collection->expects($this->once()) ->method('find') ->with( ['$and' => [ ['sid' => 'svc', 'mailbox' => 'INBOX', 'uidValidity' => 7, 'removedSeq' => null], ['size' => ['$gt' => 1]], ]], ['projection' => ['uid' => 1], 'sort' => ['subject' => 1, 'uid' => 1], 'collation' => ['locale' => 'en', 'strength' => 2]], ) ->willReturn(new Cursor(new ArrayIterator([['uid' => 4], ['uid' => 2]]))); $dataStore = $this->createStub(DataStore::class); $dataStore->method('selectCollection')->willReturn($collection); $uids = (new MessageStore($dataStore))->query('svc', 'INBOX', 7, ['size' => ['$gt' => 1]], ['subject' => 1, 'uid' => 1], ['locale' => 'en', 'strength' => 2]); $this->assertSame([4, 2], $uids); } /** * A filter holding the given conditions as-is (Filter itself keeps one condition per attribute). * * @param array{0: string, 1: mixed, 2?: ?FilterComparisonOperator, 3?: ?FilterConjunctionOperator} ...$conditions */ private function filter(array ...$conditions): IFilter { $list = array_map(static fn (array $condition): array => [ 'attribute' => $condition[0], 'value' => $condition[1], 'comparator' => $condition[2] ?? FilterComparisonOperator::EQ, 'conjunction' => $condition[3] ?? null, ], $conditions); return new class($list) implements IFilter { public function __construct(private readonly array $list) {} public function attributes(): array { return []; } public function comparators(): string { return ''; } public function conjunctions(): string { return ''; } public function condition(string $property, mixed $value, ?FilterComparisonOperator $comparator = null, ?FilterConjunctionOperator $conjunction = null): void {} public function conditions(): array { return $this->list; } }; } }