diff --git a/lib/Providers/CachedService.php b/lib/Providers/CachedService.php new file mode 100644 index 0000000..757ab3a --- /dev/null +++ b/lib/Providers/CachedService.php @@ -0,0 +1,260 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Providers; + +use Generator; +use KTXF\Mail\Collection\CollectionBaseInterface; +use KTXF\Mail\Collection\CollectionPropertiesBaseInterface; +use KTXF\Mail\Object\AddressInterface; +use KTXF\Mail\Object\MessagePropertiesMutableInterface; +use KTXF\Mail\Submission\EntitySubmitResult; +use KTXF\Resource\BinaryResource; +use KTXF\Resource\Delta\Delta; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\EntityIdentifierInterface; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Sort\ISort; +use KTXM\ProviderImap\Service\Cache\HarmonizationService; +use KTXM\ProviderImap\Service\Cache\MessageDeltaService; +use KTXM\ProviderImap\Service\Live\LiveMailService; +use KTXM\ProviderImap\Stores\MailboxStore; + +/** + * IMAP mail service that serves reads from the cache. + * + * Holds a LiveService for the same account: writes and uncached operations go + * through it (its rules stay in one place), reads come from the cache and + * harmonize with the server when stale. Extends ServiceBase rather than + * LiveService, so every mail method is written out here. + */ +class CachedService extends ServiceBase +{ + /** Seconds after which a mailbox (or the mailbox list) is harmonized again on access */ + public const FRESHNESS_WINDOW = 60; + + private ?LiveMailService $liveMail = null; + private ?LiveService $live = null; + + public function __construct( + private readonly MailboxStore $mailboxStore, + private readonly HarmonizationService $harmonizer, + private readonly MessageDeltaService $deltas, + ) {} + + // ── Collections (cache) ────────────────────────────────────────────────── + + /** + * Unfiltered lists come from the cache; filtered lists (e.g. role lookups) go to the server, + * so filter behaviour stays identical to live mode. + */ + public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array + { + if ($location !== null || $filter !== null) { + return $this->live()->collectionList($location, $filter, $sort); + } + + $this->harmonizeMailboxesIfStale(); + + $list = []; + foreach ($this->mailboxStore->list($this->serviceId()) as $name => $document) { + $list[(string) $name] = $this->collectionFromCache($document); + } + + return $list; + } + + public function collectionExtant(string|int ...$identifiers): array + { + $this->harmonizeMailboxesIfStale(); + $cached = $this->mailboxStore->list($this->serviceId()); + + $list = []; + foreach ($identifiers as $identifier) { + $list[(string) $identifier] = isset($cached[(string) $identifier]); + } + + return $list; + } + + public function collectionFetch(string|int $identifier): ?CollectionResource + { + $this->harmonizeMailboxesIfStale(); + $document = $this->mailboxStore->fetch($this->serviceId(), (string) $identifier); + + return $document === null ? null : $this->collectionFromCache($document); + } + + // ── Collections (server; cache maintenance follows in step 6) ──────────── + + public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface + { + return $this->live()->collectionCreate($target, $properties, $options); + } + + public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface + { + return $this->live()->collectionUpdate($target, $properties); + } + + public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true + { + return $this->live()->collectionDelete($target, $force); + } + + public function collectionMove(CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface + { + return $this->live()->collectionMove($target, $source); + } + + // ── Entities: reads ────────────────────────────────────────────────────── + + public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array + { + // served from the cache in 5.2b + return $this->live()->entityListBulk($collection, $filter, $sort, $range, $properties); + } + + public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator + { + // served from the cache in 5.2b + return $this->live()->entityListStream($collection, $filter, $sort, $range, $properties); + } + + public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array + { + // served from the cache in 5.2c + return $this->live()->entityFetchBulk(...$identifiers); + } + + public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator + { + // served from the cache in 5.2c + return $this->live()->entityFetchStream(...$identifiers); + } + + /** + * Changes since a signature, harmonizing the mailbox first when stale. + * + * Harmonization never waits on another run's lock, and a server that cannot be + * reached does not fail the request: the delta is answered from what is cached. + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta + { + $mailbox = (string) $collection; + + if ($this->isStale($this->mailboxStore->state($this->serviceId(), $mailbox)['harmonizedAt'])) { + try { + $this->harmonizer()->harmonizeMessages($mailbox); + } catch (\Throwable) { + // answer from the cache + } + } + + return $this->deltas->delta($this->serviceId(), $mailbox, $signature); + } + + public function entityExtant(string|int $collection, string|int ...$identifiers): array + { + return $this->live()->entityExtant($collection, ...$identifiers); + } + + public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource + { + return $this->live()->entityDownload($target, $part); + } + + // ── Entities: writes (server; cache maintenance follows in step 6) ─────── + + public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult + { + return $this->live()->entitySubmit($sender, $source, $message); + } + + public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource + { + return $this->live()->entityCreate($target, $properties, $options); + } + + public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource + { + return $this->live()->entityModify($target, $properties); + } + + public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array + { + return $this->live()->entityPatch($properties, ...$targets); + } + + public function entityDelete(EntityIdentifier ...$targets): array + { + return $this->live()->entityDelete(...$targets); + } + + public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array + { + return $this->live()->entityMove($target, ...$sources); + } + + public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array + { + return $this->live()->entityCopy($target, ...$sources); + } + + // ── Internals ──────────────────────────────────────────────────────────── + + /** + * Build a collection from its cached document; its signature is the delta signature of the mailbox. + */ + private function collectionFromCache(array $document): CollectionResource + { + if (isset($document['uidValidity'])) { + $document['signature'] = MessageDeltaService::signature((int) $document['uidValidity'], (int) ($document['changeSeq'] ?? 0)); + } + + return $this->collectionFresh()->fromCacheMeta($document); + } + + private function harmonizeMailboxesIfStale(): void + { + if ($this->isStale($this->mailboxStore->listedAt($this->serviceId()))) { + $this->harmonizer()->harmonizeMailboxes(); + } + } + + private function isStale(?int $timestamp): bool + { + return $timestamp === null || time() - $timestamp >= self::FRESHNESS_WINDOW; + } + + private function serviceId(): string + { + return (string) $this->identifier(); + } + + /** + * One IMAP connection per service object, shared by the LiveService and harmonization. + */ + protected function liveMail(): LiveMailService + { + return $this->liveMail ??= new LiveMailService($this); + } + + private function live(): LiveService + { + return $this->live ??= (new LiveService($this->liveMail()))->fromStore($this->toStore()); + } + + private function harmonizer(): HarmonizationService + { + return $this->harmonizer->for($this, $this->liveMail()); + } +} diff --git a/lib/Providers/LiveService.php b/lib/Providers/LiveService.php index aea60be..12151b9 100644 --- a/lib/Providers/LiveService.php +++ b/lib/Providers/LiveService.php @@ -55,6 +55,16 @@ class LiveService extends ServiceBase { private LiveMailService $mailService; + /** + * @param LiveMailService|null $mailService share an existing IMAP connection (e.g. CachedService); created on demand otherwise + */ + public function __construct(?LiveMailService $mailService = null) + { + if ($mailService !== null) { + $this->mailService = $mailService; + } + } + public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $this->initialize(); diff --git a/lib/Stores/MailboxStore.php b/lib/Stores/MailboxStore.php index 46604d0..100008c 100644 --- a/lib/Stores/MailboxStore.php +++ b/lib/Stores/MailboxStore.php @@ -76,13 +76,26 @@ class MailboxStore $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( ['sid' => $serviceId, 'name' => $document['name']], [ - '$set' => ['tid' => $tenantId, 'sid' => $serviceId, ...$document], + '$set' => ['tid' => $tenantId, 'sid' => $serviceId, ...$document, 'listedAt' => time()], '$setOnInsert' => self::STATE_DEFAULTS, ], ['upsert' => true], ); } + /** + * When the mailbox list of a service was last harmonized (newest listedAt); null when never. + */ + public function listedAt(string $serviceId): ?int + { + $document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne( + ['sid' => $serviceId], + ['projection' => ['listedAt' => 1], 'sort' => ['listedAt' => -1]], + ); + + return isset($document['listedAt']) ? (int) $document['listedAt'] : null; + } + /** * Retrieve the document of a mailbox. */ diff --git a/tests/php/Support/FakeLiveMailService.php b/tests/php/Support/FakeLiveMailService.php new file mode 100644 index 0000000..54e9bae --- /dev/null +++ b/tests/php/Support/FakeLiveMailService.php @@ -0,0 +1,82 @@ +> */ + public array $flags = []; + /** @var int[] */ + public array $unfetchable = []; + public array $fetchBatches = []; + public int $selects = 0; + public ?int $bodyTextLimit = null; + /** @var Mailbox[] */ + public array $mailboxList = []; + /** @var string[] */ + public array $failing = []; + + /** @var list */ + public array $calls = []; + + public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array + { + $this->calls[] = ['collectionList', [$location, $filter]]; + $list = []; + foreach ($this->mailboxList as $mailbox) { + $list[$mailbox->name()] = (new CollectionResource('imap', 'svc'))->fromImap($mailbox); + } + return $list; + } + + public function mailboxFetch(string $identifier): ?Mailbox + { + if (in_array($identifier, $this->failing, true)) { + return null; + } + $this->selects++; + return new Mailbox($identifier, '/', [], count($this->flags), 0, $this->uidValidity, 0, [], true, $this->uidNext); + } + + public function entityFlags(string $collection): Generator + { + yield from $this->flags; + } + + public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator + { + $this->fetchBatches[] = $uids; + if (preg_match('/BODY\.PEEK\[TEXT\]<0\.(\d+)>/', (string) $options?->toCommand(), $matches) === 1) { + $this->bodyTextLimit = (int) $matches[1]; + } + + foreach ($uids as $uid) { + if (in_array($uid, $this->unfetchable, true)) { + continue; + } + $flags = implode(' ', $this->flags[$uid] ?? []); + yield $uid => FetchMessageParser::parse("* {$uid} FETCH (UID {$uid} FLAGS ({$flags}))"); + } + } + + public function entityPatch(string $collection, array $flagsToAdd = [], array $flagsToRemove = [], int ...$uids): void + { + $this->calls[] = ['entityPatch', [$collection, $flagsToAdd, $flagsToRemove, $uids]]; + } +} diff --git a/tests/php/Support/FakeMailboxStore.php b/tests/php/Support/FakeMailboxStore.php new file mode 100644 index 0000000..8cacdc4 --- /dev/null +++ b/tests/php/Support/FakeMailboxStore.php @@ -0,0 +1,83 @@ + */ + public array $documents = []; + public ?string $lockOwner = null; + /** Fixed time for listedAt; null = now */ + public ?int $clock = null; + + public function upsert(string $tenantId, string $serviceId, CollectionResource $collection): void + { + $name = (string) $collection->identifier(); + $this->documents[$name] = [ + ...($this->documents[$name] ?? self::STATE_DEFAULTS), + ...$collection->toCacheMeta(), + 'listedAt' => $this->clock ?? time(), + ]; + } + + public function fetch(string $serviceId, string $name): ?array + { + return $this->documents[$name] ?? null; + } + + public function list(string $serviceId): array + { + return $this->documents; + } + + public function updateState(string $serviceId, string $name, array $state): void + { + $this->documents[$name] = array_replace($this->documents[$name], array_intersect_key($state, self::STATE_DEFAULTS)); + } + + public function acquireLock(string $serviceId, string $name, string $owner, int $ttl): bool + { + if ($this->lockOwner !== null && $this->lockOwner !== $owner) { + return false; + } + $this->lockOwner = $owner; + return true; + } + + public function releaseLock(string $serviceId, string $name, string $owner): void + { + if ($this->lockOwner === $owner) { + $this->lockOwner = null; + } + } + + public function reserveSequence(string $serviceId, string $name, int $count = 1): int + { + $this->documents[$name]['changeSeq'] += $count; + return $this->documents[$name]['changeSeq'] - $count + 1; + } + + public function state(string $serviceId, string $name): array + { + return array_replace(self::STATE_DEFAULTS, array_intersect_key($this->documents[$name] ?? [], self::STATE_DEFAULTS)); + } + + public function listedAt(string $serviceId): ?int + { + $times = array_filter(array_column($this->documents, 'listedAt')); + return $times === [] ? null : max($times); + } + + public function delete(string $serviceId, string $name): void + { + unset($this->documents[$name]); + } +} diff --git a/tests/php/Support/FakeMessageFileStore.php b/tests/php/Support/FakeMessageFileStore.php new file mode 100644 index 0000000..657f869 --- /dev/null +++ b/tests/php/Support/FakeMessageFileStore.php @@ -0,0 +1,41 @@ +> content by UIDVALIDITY and UID */ + private array $files = []; + + public function write(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid, array $content): void + { + $this->files[$uidValidity][$uid] = $content; + } + + public function delete(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int ...$uids): void + { + foreach ($uids as $uid) { + unset($this->files[$uidValidity][$uid]); + } + } + + public function deleteByMailbox(string $tenantId, string $serviceId, string $mailbox): void + { + $this->files = []; + } + + /** @return int[] */ + public function uids(int $uidValidity): array + { + $uids = array_keys($this->files[$uidValidity] ?? []); + sort($uids); + return $uids; + } +} diff --git a/tests/php/Support/FakeMessageStore.php b/tests/php/Support/FakeMessageStore.php new file mode 100644 index 0000000..cbe9e6f --- /dev/null +++ b/tests/php/Support/FakeMessageStore.php @@ -0,0 +1,78 @@ +, addedSeq: int, modSeq: int, removedSeq: ?int}>> by UIDVALIDITY and UID */ + private array $documents = []; + + public function upsert(string $tenantId, string $serviceId, int $uidValidity, EntityResource $entity, int $seq): void + { + $meta = $entity->toCacheMeta(); + $existing = $this->documents[$uidValidity][$meta['uid']] ?? null; + $this->documents[$uidValidity][$meta['uid']] = [ + 'flags' => $meta['flags'], + 'addedSeq' => $existing['addedSeq'] ?? $seq, + 'modSeq' => $seq, + 'removedSeq' => null, + ]; + ksort($this->documents[$uidValidity]); + } + + public function flags(string $serviceId, string $mailbox, int $uidValidity): array + { + $flags = []; + foreach ($this->documents[$uidValidity] ?? [] as $uid => $document) { + if ($document['removedSeq'] === null) { + $flags[$uid] = $document['flags']; + } + } + return $flags; + } + + public function updateFlags(string $serviceId, string $mailbox, int $uidValidity, int $uid, array $flags, int $seq): void + { + $this->documents[$uidValidity][$uid]['flags'] = array_values($flags); + $this->documents[$uidValidity][$uid]['modSeq'] = $seq; + } + + public function tombstone(string $serviceId, string $mailbox, int $uidValidity, int $seq, int ...$uids): void + { + foreach ($uids as $uid) { + $this->documents[$uidValidity][$uid]['removedSeq'] = $seq; + $this->documents[$uidValidity][$uid]['modSeq'] = $seq; + $this->documents[$uidValidity][$uid]['flags'] = []; + } + } + + public function changes(string $serviceId, string $mailbox, int $uidValidity, int $since): array + { + $changes = ['additions' => [], 'modifications' => [], 'deletions' => []]; + foreach ($this->documents[$uidValidity] ?? [] as $uid => $document) { + if ($document['removedSeq'] !== null) { + if ($document['addedSeq'] <= $since && $document['removedSeq'] > $since) { + $changes['deletions'][] = $uid; + } + } elseif ($document['addedSeq'] > $since) { + $changes['additions'][] = $uid; + } elseif ($document['modSeq'] > $since) { + $changes['modifications'][] = $uid; + } + } + return $changes; + } + + public function deleteByMailbox(string $serviceId, string $mailbox): void + { + $this->documents = []; + } +} diff --git a/tests/php/Unit/CacheSerializationTest.php b/tests/php/Unit/CacheSerializationTest.php index 701f8a8..c0436af 100644 --- a/tests/php/Unit/CacheSerializationTest.php +++ b/tests/php/Unit/CacheSerializationTest.php @@ -150,7 +150,7 @@ final class CacheSerializationTest extends TestCase ->with( ['sid' => 'svc', 'name' => 'INBOX'], $this->callback(function (array $update): bool { - $this->assertSame(['tid', 'sid', 'name', 'parent', 'signature', 'properties'], array_keys($update['$set'])); + $this->assertSame(['tid', 'sid', 'name', 'parent', 'signature', 'properties', 'listedAt'], array_keys($update['$set'])); return true; }), ['upsert' => true], diff --git a/tests/php/Unit/CachedServiceTest.php b/tests/php/Unit/CachedServiceTest.php new file mode 100644 index 0000000..743a84f --- /dev/null +++ b/tests/php/Unit/CachedServiceTest.php @@ -0,0 +1,175 @@ +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->service = new CachedServiceTestDouble( + $this->mailboxes, + $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); + } + + /** + * @return list + */ + 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; + } +} diff --git a/tests/php/Unit/MessageHarmonizationTest.php b/tests/php/Unit/MessageHarmonizationTest.php index 82a56ea..2663244 100644 --- a/tests/php/Unit/MessageHarmonizationTest.php +++ b/tests/php/Unit/MessageHarmonizationTest.php @@ -22,6 +22,10 @@ use KTXM\ProviderImap\Service\Live\LiveMailService; use KTXM\ProviderImap\Stores\MailboxStore; use KTXM\ProviderImap\Stores\MessageFileStore; use KTXM\ProviderImap\Stores\MessageStore; +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 MessageHarmonizationTest extends TestCase @@ -29,7 +33,7 @@ final class MessageHarmonizationTest extends TestCase private FakeMailboxStore $mailboxes; private FakeMessageStore $messages; private FakeMessageFileStore $files; - private MessageHarmonizationLiveStub $live; + private FakeLiveMailService $live; private HarmonizationService $harmonizer; private MessageDeltaService $deltas; @@ -41,7 +45,7 @@ final class MessageHarmonizationTest extends TestCase $this->files = new FakeMessageFileStore('/nonexistent'); $service = (new LiveService())->fromStore(['tid' => 'tenant', 'sid' => 'svc']); - $this->live = new MessageHarmonizationLiveStub($service); + $this->live = new FakeLiveMailService($service); $this->live->uidValidity = 7; $this->live->uidNext = 100; @@ -270,214 +274,3 @@ final class MessageHarmonizationTest extends TestCase } } -final class MessageHarmonizationLiveStub extends LiveMailService -{ - public ?int $uidValidity = null; - public ?int $uidNext = null; - /** @var array> */ - public array $flags = []; - /** @var int[] */ - public array $unfetchable = []; - public array $fetchBatches = []; - public int $selects = 0; - public ?int $bodyTextLimit = null; - /** @var Mailbox[] */ - public array $mailboxList = []; - /** @var string[] */ - public array $failing = []; - - public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array - { - $list = []; - foreach ($this->mailboxList as $mailbox) { - $list[$mailbox->name()] = (new CollectionResource('imap', 'svc'))->fromImap($mailbox); - } - return $list; - } - - public function mailboxFetch(string $identifier): ?Mailbox - { - if (in_array($identifier, $this->failing, true)) { - return null; - } - $this->selects++; - return new Mailbox($identifier, '/', [], count($this->flags), 0, $this->uidValidity, 0, [], true, $this->uidNext); - } - - public function entityFlags(string $collection): Generator - { - yield from $this->flags; - } - - public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator - { - $this->fetchBatches[] = $uids; - if (preg_match('/BODY\.PEEK\[TEXT\]<0\.(\d+)>/', (string) $options?->toCommand(), $matches) === 1) { - $this->bodyTextLimit = (int) $matches[1]; - } - - foreach ($uids as $uid) { - if (in_array($uid, $this->unfetchable, true)) { - continue; - } - $flags = implode(' ', $this->flags[$uid] ?? []); - yield $uid => FetchMessageParser::parse("* {$uid} FETCH (UID {$uid} FLAGS ({$flags}))"); - } - } -} - -final class FakeMailboxStore extends MailboxStore -{ - /** @var array */ - public array $documents = []; - public ?string $lockOwner = null; - - public function upsert(string $tenantId, string $serviceId, CollectionResource $collection): void - { - $name = (string) $collection->identifier(); - $this->documents[$name] = [ - ...($this->documents[$name] ?? self::STATE_DEFAULTS), - ...$collection->toCacheMeta(), - ]; - } - - public function fetch(string $serviceId, string $name): ?array - { - return $this->documents[$name] ?? null; - } - - public function list(string $serviceId): array - { - return $this->documents; - } - - public function updateState(string $serviceId, string $name, array $state): void - { - $this->documents[$name] = array_replace($this->documents[$name], array_intersect_key($state, self::STATE_DEFAULTS)); - } - - public function acquireLock(string $serviceId, string $name, string $owner, int $ttl): bool - { - if ($this->lockOwner !== null && $this->lockOwner !== $owner) { - return false; - } - $this->lockOwner = $owner; - return true; - } - - public function releaseLock(string $serviceId, string $name, string $owner): void - { - if ($this->lockOwner === $owner) { - $this->lockOwner = null; - } - } - - public function reserveSequence(string $serviceId, string $name, int $count = 1): int - { - $this->documents[$name]['changeSeq'] += $count; - return $this->documents[$name]['changeSeq'] - $count + 1; - } - - public function state(string $serviceId, string $name): array - { - return array_replace(self::STATE_DEFAULTS, array_intersect_key($this->documents[$name] ?? [], self::STATE_DEFAULTS)); - } -} - -final class FakeMessageStore extends MessageStore -{ - /** @var array, addedSeq: int, modSeq: int, removedSeq: ?int}>> by UIDVALIDITY and UID */ - private array $documents = []; - - public function upsert(string $tenantId, string $serviceId, int $uidValidity, EntityResource $entity, int $seq): void - { - $meta = $entity->toCacheMeta(); - $existing = $this->documents[$uidValidity][$meta['uid']] ?? null; - $this->documents[$uidValidity][$meta['uid']] = [ - 'flags' => $meta['flags'], - 'addedSeq' => $existing['addedSeq'] ?? $seq, - 'modSeq' => $seq, - 'removedSeq' => null, - ]; - ksort($this->documents[$uidValidity]); - } - - public function flags(string $serviceId, string $mailbox, int $uidValidity): array - { - $flags = []; - foreach ($this->documents[$uidValidity] ?? [] as $uid => $document) { - if ($document['removedSeq'] === null) { - $flags[$uid] = $document['flags']; - } - } - return $flags; - } - - public function updateFlags(string $serviceId, string $mailbox, int $uidValidity, int $uid, array $flags, int $seq): void - { - $this->documents[$uidValidity][$uid]['flags'] = array_values($flags); - $this->documents[$uidValidity][$uid]['modSeq'] = $seq; - } - - public function tombstone(string $serviceId, string $mailbox, int $uidValidity, int $seq, int ...$uids): void - { - foreach ($uids as $uid) { - $this->documents[$uidValidity][$uid]['removedSeq'] = $seq; - $this->documents[$uidValidity][$uid]['modSeq'] = $seq; - $this->documents[$uidValidity][$uid]['flags'] = []; - } - } - - public function changes(string $serviceId, string $mailbox, int $uidValidity, int $since): array - { - $changes = ['additions' => [], 'modifications' => [], 'deletions' => []]; - foreach ($this->documents[$uidValidity] ?? [] as $uid => $document) { - if ($document['removedSeq'] !== null) { - if ($document['addedSeq'] <= $since && $document['removedSeq'] > $since) { - $changes['deletions'][] = $uid; - } - } elseif ($document['addedSeq'] > $since) { - $changes['additions'][] = $uid; - } elseif ($document['modSeq'] > $since) { - $changes['modifications'][] = $uid; - } - } - return $changes; - } - - public function deleteByMailbox(string $serviceId, string $mailbox): void - { - $this->documents = []; - } -} - -final class FakeMessageFileStore extends MessageFileStore -{ - /** @var array> content by UIDVALIDITY and UID */ - private array $files = []; - - public function write(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int $uid, array $content): void - { - $this->files[$uidValidity][$uid] = $content; - } - - public function delete(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, int ...$uids): void - { - foreach ($uids as $uid) { - unset($this->files[$uidValidity][$uid]); - } - } - - public function deleteByMailbox(string $tenantId, string $serviceId, string $mailbox): void - { - $this->files = []; - } - - /** @return int[] */ - public function uids(int $uidValidity): array - { - $uids = array_keys($this->files[$uidValidity] ?? []); - sort($uids); - return $uids; - } -}