generated from Nodarx/template
feat: serve message lists from the cache
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -12,8 +12,12 @@ use KTXM\ProviderImap\Providers\MessageProperties;
|
||||
use KTXM\ProviderImap\Service\Cache\HarmonizationService;
|
||||
use KTXM\ProviderImap\Service\Cache\MessageDeltaService;
|
||||
use KTXM\ProviderImap\Service\Cache\MessageIngestor;
|
||||
use KTXM\ProviderImap\Service\Cache\MessageQueryBuilder;
|
||||
use KTXM\ProviderImap\Service\Live\LiveMailService;
|
||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||
use KTXF\Resource\Range\RangeAnchorType;
|
||||
use KTXF\Resource\Range\RangeType;
|
||||
use KTXF\Resource\Sort\Sort;
|
||||
use KTXT\ProviderImap\Tests\Support\FakeLiveMailService;
|
||||
use KTXT\ProviderImap\Tests\Support\FakeMailboxStore;
|
||||
use KTXT\ProviderImap\Tests\Support\FakeMessageFileStore;
|
||||
@@ -23,6 +27,8 @@ use PHPUnit\Framework\TestCase;
|
||||
final class CachedServiceTest extends TestCase
|
||||
{
|
||||
private FakeMailboxStore $mailboxes;
|
||||
private FakeMessageStore $messages;
|
||||
private FakeMessageFileStore $files;
|
||||
private FakeLiveMailService $live;
|
||||
private CachedService $service;
|
||||
|
||||
@@ -40,8 +46,14 @@ final class CachedServiceTest extends TestCase
|
||||
new MessageIngestor($files, $messages, $this->mailboxes),
|
||||
);
|
||||
|
||||
$this->messages = $messages;
|
||||
$this->files = $files;
|
||||
$this->service = new CachedServiceTestDouble(
|
||||
$this->mailboxes,
|
||||
$messages,
|
||||
$files,
|
||||
new MessageIngestor($files, $messages, $this->mailboxes),
|
||||
new MessageQueryBuilder(),
|
||||
$harmonizer,
|
||||
new MessageDeltaService($this->mailboxes, $messages),
|
||||
);
|
||||
@@ -152,6 +164,128 @@ final class CachedServiceTest extends TestCase
|
||||
$this->assertSame([5], $uids);
|
||||
}
|
||||
|
||||
public function testListOfANotHarmonizedMailboxStreamsFromTheServerAndCaches(): void
|
||||
{
|
||||
$this->live->flags = [1 => [], 2 => ['\\Seen']];
|
||||
|
||||
$uids = $this->listedUids($this->service->entityListStream('INBOX'));
|
||||
|
||||
$this->assertSame([1, 2], $uids);
|
||||
$this->assertCount(1, $this->calls('messageList'));
|
||||
$this->assertSame([1, 2], $this->files->uids(7));
|
||||
$this->assertSame([1 => [], 2 => ['seen']], $this->messages->flags('svc', 'INBOX', 7));
|
||||
}
|
||||
|
||||
public function testListOfAHarmonizedMailboxComesFromTheCache(): void
|
||||
{
|
||||
$this->harmonized([1 => [], 2 => ['\\Seen'], 3 => []]);
|
||||
|
||||
$entities = iterator_to_array($this->service->entityListStream('INBOX'), false);
|
||||
|
||||
$this->assertSame([1, 2, 3], array_map(static fn ($entity): int => (int) $entity->identifier(), $entities));
|
||||
$this->assertSame([], $this->calls('messageList'));
|
||||
$this->assertSame(['seen' => true], $entities[1]->getProperties()->getFlags());
|
||||
$this->assertSame('INBOX', $entities[1]->collection());
|
||||
}
|
||||
|
||||
public function testListRangeIsAppliedLikeTheLiveService(): void
|
||||
{
|
||||
$this->harmonized([1 => [], 2 => [], 3 => [], 4 => []]);
|
||||
|
||||
$absolute = $this->service->entityListRange(RangeType::TALLY);
|
||||
$absolute->setAnchor(RangeAnchorType::ABSOLUTE);
|
||||
$absolute->setPosition(1);
|
||||
$absolute->setTally(2);
|
||||
|
||||
$relative = $this->service->entityListRange(RangeType::TALLY);
|
||||
$relative->setAnchor(RangeAnchorType::RELATIVE);
|
||||
$relative->setPosition(3);
|
||||
$relative->setTally(2);
|
||||
|
||||
$this->assertSame([2, 3], $this->listedUids($this->service->entityListStream('INBOX', null, null, $absolute)));
|
||||
$this->assertSame([3, 4], $this->listedUids($this->service->entityListStream('INBOX', null, null, $relative)));
|
||||
}
|
||||
|
||||
public function testMessagesMissingFromTheContentStoreAreFetchedAndCached(): void
|
||||
{
|
||||
$this->harmonized([1 => [], 2 => []]);
|
||||
$this->files->delete('tenant', 'svc', 'INBOX', 7, 2);
|
||||
$this->live->fetchBatches = [];
|
||||
|
||||
$this->assertSame([1, 2], $this->listedUids($this->service->entityListStream('INBOX')));
|
||||
$this->assertSame([[2]], $this->live->fetchBatches);
|
||||
$this->assertSame([1, 2], $this->files->uids(7));
|
||||
}
|
||||
|
||||
public function testBodyFilterHasTheServerFindTheUids(): void
|
||||
{
|
||||
$this->harmonized([1 => [], 2 => [], 3 => []]);
|
||||
$this->live->found = [3, 1];
|
||||
$this->live->fetchBatches = [];
|
||||
|
||||
$filter = $this->service->entityListFilter();
|
||||
$filter->condition('body', 'invoice');
|
||||
|
||||
$this->assertSame([3, 1], $this->listedUids($this->service->entityListStream('INBOX', $filter)));
|
||||
$this->assertCount(1, $this->calls('entityFind'));
|
||||
$this->assertSame([], $this->live->fetchBatches, 'content comes from the cache');
|
||||
}
|
||||
|
||||
public function testOtherFiltersAreTranslatedForTheMetaStore(): void
|
||||
{
|
||||
$this->harmonized([1 => []]);
|
||||
|
||||
$filter = $this->service->entityListFilter();
|
||||
$filter->condition('subject', 'report');
|
||||
// keyed attributes: entityListSort() passes a plain list, which Sort::condition() ignores (existing bug)
|
||||
$sort = new Sort(['received' => true]);
|
||||
$sort->condition('received', false);
|
||||
|
||||
iterator_to_array($this->service->entityListStream('INBOX', $filter, $sort));
|
||||
|
||||
$this->assertSame([], $this->calls('entityFind'));
|
||||
$this->assertArrayHasKey('subject', $this->messages->lastQuery[0]);
|
||||
$this->assertSame(['received' => -1, 'uid' => 1], $this->messages->lastQuery[1]);
|
||||
}
|
||||
|
||||
public function testListBulkIsTheStreamCollected(): void
|
||||
{
|
||||
$this->harmonized([1 => [], 2 => []]);
|
||||
|
||||
$this->assertCount(2, $this->service->entityListBulk('INBOX'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Harmonize INBOX with the given server UIDs and flags.
|
||||
*
|
||||
* @param array<int, list<string>> $flags
|
||||
*/
|
||||
private function harmonized(array $flags): void
|
||||
{
|
||||
$this->live->flags = $flags;
|
||||
$this->service->entityDelta('INBOX', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
private function listedUids(iterable $entities): array
|
||||
{
|
||||
$uids = [];
|
||||
foreach ($entities as $entity) {
|
||||
$uids[] = (int) $entity->identifier();
|
||||
}
|
||||
return $uids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array>
|
||||
*/
|
||||
private function calls(string $method): array
|
||||
{
|
||||
return array_values(array_filter($this->live->calls, static fn (array $call): bool => $call[0] === $method));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array>
|
||||
*/
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user