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> $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; } }