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')); } /** * Harmonize INBOX with the given server UIDs and flags. * * @param array> $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 */ private function calls(string $method): array { return array_values(array_filter($this->live->calls, static fn (array $call): bool => $call[0] === $method)); } /** * @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; } }