feat: implement content cache

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-09-29 19:56:30 -04:00
parent d28800a04e
commit 0250b5a847
10 changed files with 511 additions and 8 deletions
+14
View File
@@ -81,6 +81,20 @@ final class CacheSerializationTest extends TestCase
$this->assertSame(['flagged' => true], $restored->getProperties()->getFlags());
}
public function testIncompleteSectionsRoundTripThroughContentButNotTheApi(): void
{
$entity = $this->entity();
$entity->getProperties()->setIncompleteSections('1');
$content = json_decode(json_encode($entity->toCacheContent()), true);
$restored = (new EntityResource('imap', 'svc'))->fromCacheContent($content);
$this->assertSame(['1'], $content['incomplete']);
$this->assertArrayNotHasKey('incomplete', $content['properties']);
$this->assertSame(['1'], $restored->getProperties()->getIncompleteSections());
$this->assertStringNotContainsString('incomplete', json_encode($restored));
}
public function testContentWithOtherSchemaVersionIsRejected(): void
{
$content = $this->entity()->toCacheContent();
+34
View File
@@ -78,6 +78,40 @@ final class FetchMessageParserTest extends TestCase
$this->assertSame(['1' => 'Hello wor'], $message->bodySections());
}
public function testTruncatedSectionsAreReported(): void
{
$complete = FetchMessageParser::parse($this->fetchResponse(
self::MIXED_STRUCTURE,
'BODY[TEXT]',
self::ALTERNATIVE_PREFIX . "<p>Hello</p>\r\n--alt--\r\n--mix--\r\n",
));
$cutInHtml = FetchMessageParser::parse($this->fetchResponse(
self::MIXED_STRUCTURE,
'BODY[TEXT]<0>',
self::ALTERNATIVE_PREFIX . '<p>Hello wor',
));
$cutInAttachment = FetchMessageParser::parse($this->fetchResponse(
self::MIXED_STRUCTURE,
'BODY[TEXT]<0>',
self::ALTERNATIVE_PREFIX . "<p>Hello</p>\r\n--alt--\r\n--mix\r\nContent-Type: application/pdf\r\n\r\nJVBE",
));
$this->assertSame([], $complete->truncatedSections());
$this->assertSame(['1.2'], $cutInHtml->truncatedSections());
$this->assertSame([], $cutInAttachment->truncatedSections());
}
public function testTruncatedSinglePartBodyIsReportedBySize(): void
{
$structure = '("TEXT" "PLAIN" ("CHARSET" "UTF-8") NIL NIL "7BIT" 11 1)';
$partial = FetchMessageParser::parse($this->fetchResponse($structure, 'BODY[TEXT]<0>', 'Hello'));
$complete = FetchMessageParser::parse($this->fetchResponse($structure, 'BODY[TEXT]', 'Hello world'));
$this->assertSame(['1'], $partial->truncatedSections());
$this->assertSame([], $complete->truncatedSections());
}
private function fetchResponse(string $bodyStructure, string $section, string $body): string
{
return sprintf(
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use FilesystemIterator;
use InvalidArgumentException;
use KTXM\ProviderImap\Stores\MessageFileStore;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
final class MessageFileStoreTest extends TestCase
{
private string $root;
private MessageFileStore $store;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/ktrix-imap-cache-' . bin2hex(random_bytes(4));
$this->store = new MessageFileStore($this->root);
}
protected function tearDown(): void
{
if (!is_dir($this->root)) {
return;
}
$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($this->root);
}
public function testWriteThenReadReturnsContent(): void
{
$content = ['schemaVersion' => 1, 'properties' => ['subject' => 'Grüße / ✓']];
$this->store->write('tenant', 'svc', 'INBOX/Reports', 7, 42, $content);
$this->assertSame($content, $this->store->read('tenant', 'svc', 'INBOX/Reports', 7, 42));
$this->assertFileExists(sprintf(
'%s/storage/tenant/provider_imap/svc/%s/7/42/message.json',
$this->root,
sha1('INBOX/Reports'),
));
}
public function testWriteReplacesAndLeavesNoTemporaryFiles(): void
{
$this->store->write('tenant', 'svc', 'INBOX', 7, 42, ['v' => 1]);
$this->store->write('tenant', 'svc', 'INBOX', 7, 42, ['v' => 2]);
$this->assertSame(['v' => 2], $this->store->read('tenant', 'svc', 'INBOX', 7, 42));
$folder = sprintf('%s/storage/tenant/provider_imap/svc/%s/7/42', $this->root, sha1('INBOX'));
$this->assertSame(['message.json'], array_values(array_diff(scandir($folder), ['.', '..'])));
}
public function testReadOfMissingOrCorruptContentReturnsNull(): void
{
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 42));
$this->store->write('tenant', 'svc', 'INBOX', 7, 42, ['v' => 1]);
file_put_contents(
sprintf('%s/storage/tenant/provider_imap/svc/%s/7/42/message.json', $this->root, sha1('INBOX')),
'{truncated',
);
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 42));
}
public function testDeleteScopes(): void
{
$this->store->write('tenant', 'svc', 'INBOX', 7, 1, ['v' => 1]);
$this->store->write('tenant', 'svc', 'INBOX', 7, 2, ['v' => 2]);
$this->store->write('tenant', 'svc', 'Sent', 3, 1, ['v' => 3]);
$this->store->write('tenant', 'other', 'INBOX', 7, 1, ['v' => 4]);
$this->store->delete('tenant', 'svc', 'INBOX', 7, 1);
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 1));
$this->assertNotNull($this->store->read('tenant', 'svc', 'INBOX', 7, 2));
$this->store->deleteByMailbox('tenant', 'svc', 'INBOX');
$this->assertNull($this->store->read('tenant', 'svc', 'INBOX', 7, 2));
$this->assertNotNull($this->store->read('tenant', 'svc', 'Sent', 3, 1));
$this->store->deleteByService('tenant', 'svc');
$this->assertNull($this->store->read('tenant', 'svc', 'Sent', 3, 1));
$this->assertDirectoryDoesNotExist($this->root . '/storage/tenant/provider_imap/svc');
$this->assertNotNull($this->store->read('tenant', 'other', 'INBOX', 7, 1));
}
public function testIdentifiersThatCouldEscapeTheRootAreRejected(): void
{
$this->expectException(InvalidArgumentException::class);
$this->store->write('tenant', '../escape', 'INBOX', 7, 1, []);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace KTXT\ProviderImap\Tests\Unit;
use KTXM\ProviderImap\Providers\EntityResource;
use KTXM\ProviderImap\Providers\MessageProperties;
use KTXM\ProviderImap\Service\Cache\MessageIngestor;
use KTXM\ProviderImap\Stores\MessageFileStore;
use KTXM\ProviderImap\Stores\MessageStore;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class MessageIngestorTest extends TestCase
{
public function testWritesContentBeforeMetaForEachMessage(): void
{
$calls = [];
$fileStore = $this->createStub(MessageFileStore::class);
$fileStore->method('write')->willReturnCallback(
function (string $tid, string $sid, string $mailbox, int $uidValidity, int $uid, array $content) use (&$calls): void {
$calls[] = ['file', $tid, $sid, $mailbox, $uidValidity, $uid, $content['schemaVersion']];
},
);
$messageStore = $this->createStub(MessageStore::class);
$messageStore->method('upsert')->willReturnCallback(
function (string $tid, string $sid, int $uidValidity, EntityResource $entity) use (&$calls): void {
$calls[] = ['meta', $tid, $sid, $entity->collection(), $uidValidity, $entity->identifier()];
},
);
$ingested = (new MessageIngestor($fileStore, $messageStore))
->ingest('tenant', 'svc', 7, $this->entity(41), $this->entity(42));
$this->assertSame([41, 42], $ingested);
$this->assertSame([
['file', 'tenant', 'svc', 'INBOX', 7, 41, EntityResource::CACHE_SCHEMA_VERSION],
['meta', 'tenant', 'svc', 'INBOX', 7, 41],
['file', 'tenant', 'svc', 'INBOX', 7, 42, EntityResource::CACHE_SCHEMA_VERSION],
['meta', 'tenant', 'svc', 'INBOX', 7, 42],
], $calls);
}
public function testMetaIsNotWrittenWhenContentFails(): void
{
$fileStore = $this->createStub(MessageFileStore::class);
$fileStore->method('write')->willThrowException(new RuntimeException('disk full'));
$messageStore = $this->createMock(MessageStore::class);
$messageStore->expects($this->never())->method('upsert');
$this->expectException(RuntimeException::class);
(new MessageIngestor($fileStore, $messageStore))->ingest('tenant', 'svc', 7, $this->entity(41));
}
private function entity(int $uid): EntityResource
{
$properties = new MessageProperties([]);
$properties->setSubject('Message ' . $uid);
return (new EntityResource('imap', 'svc'))->fromMutation('INBOX', $uid, $properties);
}
}