feat: serve message fetches from the cache

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-10-07 22:20:13 -04:00
parent b1e92e5b07
commit 5e458c5bc8
7 changed files with 261 additions and 4 deletions
@@ -96,6 +96,15 @@ final class FakeLiveMailService extends LiveMailService
return $this->found;
}
/** @var array<int, array<string, string>> full section content by UID */
public array $sections = [];
public function messageSections(string $collection, int $uid, string ...$partIds): array
{
$this->calls[] = ['messageSections', [$collection, $uid, $partIds]];
return array_intersect_key($this->sections[$uid] ?? [], array_flip($partIds));
}
public function entityPatch(string $collection, array $flagsToAdd = [], array $flagsToRemove = [], int ...$uids): void
{
$this->calls[] = ['entityPatch', [$collection, $flagsToAdd, $flagsToRemove, $uids]];
@@ -36,6 +36,12 @@ final class FakeMessageFileStore extends MessageFileStore
$this->files = [];
}
/** Direct access for tests */
public function content(int $uidValidity, int $uid): ?array
{
return $this->files[$uidValidity][$uid] ?? null;
}
/** @return int[] */
public function uids(int $uidValidity): array
{
+75
View File
@@ -255,6 +255,81 @@ final class CachedServiceTest extends TestCase
$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.
*
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use Generator;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
use KTXM\ProviderImap\Client\Protocol\Parser\FetchMessageParser;
use KTXM\ProviderImap\Providers\LiveService;
use KTXM\ProviderImap\Providers\MessageProperties;
use KTXM\ProviderImap\Service\Live\LiveMailService;
use PHPUnit\Framework\TestCase;
final class MessageSectionsTest extends TestCase
{
public function testSectionsAreFetchedWithStructureAndDecoded(): void
{
$live = new class(new LiveService()) extends LiveMailService {
public ?string $command = null;
public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator
{
$this->command = $options?->toCommand();
$structure = '(("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "BASE64" 16 1)'
. '("TEXT" "HTML" ("CHARSET" "UTF-8") NIL NIL "7BIT" 12 1) "ALTERNATIVE" ("BOUNDARY" "b") NIL NIL)';
yield 7 => FetchMessageParser::parse(
"* 1 FETCH (UID 7 BODYSTRUCTURE {$structure} BODY[1] {16}\r\nSGVsbG8gd29ybGQ= BODY[2] {12}\r\n<p>Hello</p>)"
);
}
};
$sections = $live->messageSections('INBOX', 7, '1', '2');
$this->assertStringContainsString('BODYSTRUCTURE', (string) $live->command);
$this->assertStringContainsString('BODY.PEEK[1]', (string) $live->command);
$this->assertStringContainsString('BODY.PEEK[2]', (string) $live->command);
$this->assertSame(['1' => 'Hello world', '2' => '<p>Hello</p>'], $sections);
}
public function testCompleteSectionReplacesNestedContentAndClearsTheMark(): void
{
$properties = new MessageProperties([]);
$properties->jsonDeserialize(['body' => [
'partId' => '1', 'type' => 'multipart/alternative', 'subParts' => [
['partId' => '1.1', 'type' => 'text/plain', 'content' => 'Hi'],
['partId' => '1.2', 'type' => 'text/html', 'content' => '<p>Hello wor'],
],
]]);
$properties->setIncompleteSections('1.2');
$this->assertTrue($properties->completeSection('1.2', '<p>Hello world</p>'));
$this->assertFalse($properties->completeSection('9', 'x'));
$this->assertSame('<p>Hello world</p>', $properties->getBodyTextHtml());
$this->assertSame('Hi', $properties->getBodyTextPlain());
$this->assertSame([], $properties->getIncompleteSections());
}
}