Files
provider_imap/tests/php/Unit/CachedServiceTest.php
T
2026-10-07 22:20:13 -04:00

385 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use KTXC\Db\DataStore;
use KTXF\Mail\Collection\CollectionRoles;
use KTXM\ProviderImap\Client\Mailbox;
use KTXM\ProviderImap\Providers\CachedService;
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;
use KTXT\ProviderImap\Tests\Support\FakeMessageStore;
use PHPUnit\Framework\TestCase;
final class CachedServiceTest extends TestCase
{
private FakeMailboxStore $mailboxes;
private FakeMessageStore $messages;
private FakeMessageFileStore $files;
private FakeLiveMailService $live;
private CachedService $service;
protected function setUp(): void
{
$dataStore = $this->createStub(DataStore::class);
$this->mailboxes = new FakeMailboxStore($dataStore);
$messages = new FakeMessageStore($dataStore);
$files = new FakeMessageFileStore('/nonexistent');
$harmonizer = new HarmonizationService(
$this->mailboxes,
$messages,
$files,
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),
);
$this->service->fromStore(['tid' => 'tenant', 'sid' => 'svc']);
$this->live = new FakeLiveMailService($this->service);
$this->live->uidValidity = 7;
$this->live->uidNext = 10;
$this->live->mailboxList = [new Mailbox('INBOX', '/', []), new Mailbox('Sent', '/', ['\\Sent'])];
$this->service->liveMail = $this->live;
}
public function testCollectionListComesFromTheCacheAfterHarmonizingTheList(): void
{
$list = $this->service->collectionList(null);
$this->assertSame(['INBOX', 'Sent'], array_keys($list));
$this->assertSame(CollectionRoles::Sent, $list['Sent']->getProperties()->getRole());
$this->assertCount(1, $this->listCalls());
}
public function testFreshListIsNotHarmonizedAgain(): void
{
$this->service->collectionList(null);
$this->service->collectionList(null);
$this->service->collectionFetch('INBOX');
$this->assertCount(1, $this->listCalls());
}
public function testStaleListIsHarmonizedAgain(): void
{
$this->mailboxes->clock = time() - CachedService::FRESHNESS_WINDOW - 1;
$this->service->collectionList(null);
$this->mailboxes->clock = null;
$this->live->mailboxList[] = new Mailbox('Archive', '/', []);
$list = $this->service->collectionList(null);
$this->assertSame(['INBOX', 'Sent', 'Archive'], array_keys($list));
$this->assertCount(2, $this->listCalls());
}
public function testFilteredListGoesToTheServer(): void
{
$filter = $this->service->collectionListFilter();
$filter->condition('role', CollectionRoles::Sent->value);
$this->service->collectionList(null, $filter);
$this->assertSame([['collectionList', [null, $filter]]], $this->live->calls);
}
public function testCollectionSignatureIsTheDeltaSignature(): void
{
$this->live->flags = [1 => [], 2 => []];
$this->service->entityDelta('INBOX', '');
$this->assertSame('7:2', $this->service->collectionFetch('INBOX')?->signature());
}
public function testCollectionExtantUsesTheCache(): void
{
$this->assertSame(['INBOX' => true, 'Missing' => false], $this->service->collectionExtant('INBOX', 'Missing'));
}
public function testDeltaHarmonizesWhenStaleOnly(): void
{
$this->live->flags = [1 => []];
$first = $this->service->entityDelta('INBOX', '');
$this->live->flags = [1 => [], 2 => []];
$second = $this->service->entityDelta('INBOX', $first->signature);
$this->assertSame('7:1', $first->signature);
$this->assertSame('7:1', $second->signature, 'fresh mailbox is not harmonized again');
$this->mailboxes->updateState('svc', 'INBOX', ['harmonizedAt' => time() - CachedService::FRESHNESS_WINDOW]);
$third = $this->service->entityDelta('INBOX', $first->signature);
$this->assertSame('7:2', $third->signature);
$this->assertSame(['2'], json_decode(json_encode($third->additions), true));
}
public function testDeltaIsAnsweredFromTheCacheWhenTheServerFails(): void
{
$this->live->flags = [1 => []];
$signature = $this->service->entityDelta('INBOX', '')->signature;
$this->mailboxes->updateState('svc', 'INBOX', ['harmonizedAt' => 0]);
$this->live->uidValidity = null; // harmonization throws
$this->assertSame($signature, $this->service->entityDelta('INBOX', $signature)->signature);
}
public function testWritesGoThroughTheLiveService(): void
{
$properties = new MessageProperties([]);
$properties->setFlag('seen', true);
$this->service->entityPatch($properties, new EntityIdentifier('imap', 'svc', 'INBOX', '5'));
$patches = array_values(array_filter($this->live->calls, static fn (array $call): bool => $call[0] === 'entityPatch'));
$this->assertCount(1, $patches);
[$collection, $added, , $uids] = $patches[0][1];
$this->assertSame('INBOX', $collection);
$this->assertContains('seen', $added);
$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'));
}
public function testFetchComesFromTheCache(): void
{
$this->harmonized([1 => [], 2 => ['\\Flagged']]);
$this->live->fetchBatches = [];
$entities = array_values($this->service->entityFetchBulk(
new EntityIdentifier('imap', 'svc', 'INBOX', '2'),
));
$this->assertCount(1, $entities);
$this->assertSame(['flagged' => true], $entities[0]->getProperties()->getFlags());
$this->assertSame([], $this->live->fetchBatches);
}
public function testFetchOfAnUncachedMessageGoesToTheServerAndCaches(): void
{
$this->harmonized([1 => []]);
$this->live->flags[5] = [];
$this->live->fetchBatches = [];
$entities = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'INBOX', '5')));
$this->assertSame('5', (string) $entities[0]->identifier());
$this->assertSame([[5]], $this->live->fetchBatches);
$this->assertContains(5, $this->files->uids(7));
}
public function testFetchFromANeverHarmonizedMailboxIsReadFromTheServer(): void
{
$this->live->flags = [3 => []];
$entities = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'Sent', '3')));
$this->assertSame('3', (string) $entities[0]->identifier());
$this->assertSame([], $this->files->uids(7), 'nothing is cached without a mailbox generation');
}
public function testFetchRejectsIdentifiersOfAnotherService(): void
{
$this->expectException(\InvalidArgumentException::class);
iterator_to_array($this->service->entityFetchStream(new EntityIdentifier('imap', 'other', 'INBOX', '1')));
}
public function testIncompleteSectionsAreCompletedOnceAndStored(): void
{
$this->harmonized([1 => []]);
$content = $this->files->content(7, 1);
$content['incomplete'] = ['1'];
$content['properties']['body'] = ['partId' => '1', 'type' => 'text/plain', 'content' => 'Hello wor'];
$this->files->write('tenant', 'svc', 'INBOX', 7, 1, $content);
$this->live->sections = [1 => ['1' => 'Hello world, the full text.']];
$listed = array_values(iterator_to_array($this->service->entityListStream('INBOX'), false));
$fetched = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'INBOX', '1')));
$this->assertSame('Hello world, the full text.', $listed[0]->getProperties()->getBodyTextPlain());
$this->assertSame('Hello world, the full text.', $fetched[0]->getProperties()->getBodyTextPlain());
$this->assertSame([], $this->files->content(7, 1)['incomplete']);
$this->assertCount(1, $this->calls('messageSections'), 'completed once, then served from the cache');
}
public function testIncompleteSectionsStayCutOffWhenTheServerDoesNotReturnThem(): void
{
$this->harmonized([1 => []]);
$content = $this->files->content(7, 1);
$content['incomplete'] = ['1'];
$content['properties']['body'] = ['partId' => '1', 'type' => 'text/plain', 'content' => 'Hello wor'];
$this->files->write('tenant', 'svc', 'INBOX', 7, 1, $content);
$fetched = array_values($this->service->entityFetchBulk(new EntityIdentifier('imap', 'svc', 'INBOX', '1')));
$this->assertSame('Hello wor', $fetched[0]->getProperties()->getBodyTextPlain());
$this->assertSame(['1'], $this->files->content(7, 1)['incomplete']);
}
/**
* 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>
*/
private function listCalls(): array
{
return array_values(array_filter($this->live->calls, static fn (array $call): bool => $call[0] === 'collectionList'));
}
}
/**
* CachedService with a replaceable IMAP side.
*/
final class CachedServiceTestDouble extends CachedService
{
public ?LiveMailService $liveMail = null;
protected function liveMail(): LiveMailService
{
return $this->liveMail;
}
}