feat: serve message lists from the cache

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-10-07 22:15:34 -04:00
parent 852ead324f
commit b1e92e5b07
9 changed files with 800 additions and 5 deletions
+166
View File
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use ArrayIterator;
use KTXC\Db\Collection;
use KTXC\Db\Cursor;
use KTXC\Db\DataStore;
use KTXF\Resource\Filter\FilterComparisonOperator;
use KTXF\Resource\Filter\FilterConjunctionOperator;
use KTXF\Resource\Filter\IFilter;
use KTXF\Resource\Sort\Sort;
use KTXM\ProviderImap\Service\Cache\MessageQueryBuilder;
use KTXM\ProviderImap\Stores\MessageStore;
use MongoDB\BSON\Regex;
use PHPUnit\Framework\TestCase;
final class MessageQueryBuilderTest extends TestCase
{
private MessageQueryBuilder $builder;
protected function setUp(): void
{
$this->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; }
};
}
}