message(); $this->assertSame(['root@example.com', 'parent@example.com'], $message->references()); $this->assertSame( ['root@example.com', 'parent@example.com'], $this->entity()->getProperties()->getReferences(), ); } public function testMessageRoundTripsThroughMetaAndContent(): void { $original = $this->entity(); $meta = json_decode(json_encode($original->toCacheMeta()), true); $content = json_decode(json_encode($original->toCacheContent()), true); $restored = (new EntityResource('imap', 'svc')) ->fromCacheContent($content) ->fromCacheMeta($meta); $this->assertSame(json_encode($original), json_encode($restored)); } public function testMetaHoldsListFieldsInStoreFriendlyForm(): void { $meta = $this->entity()->toCacheMeta(); $this->assertSame('INBOX', $meta['mailbox']); $this->assertSame(42, $meta['uid']); $this->assertSame(EntityResource::CACHE_SCHEMA_VERSION, $meta['schemaVersion']); $this->assertSame('2026-09-27T08:30:00Z', $meta['sent']); $this->assertSame('Quarterly report', $meta['subject']); $this->assertSame('msg@example.com', $meta['urid']); $this->assertSame('parent@example.com', $meta['inReplyTo']); $this->assertSame(['seen', '$forwarded'], $meta['flags']); $this->assertSame('Hello team, the report is attached.', $meta['preview']); $this->assertTrue($meta['hasAttachments']); $this->assertArrayNotHasKey('body', $meta); } public function testContentExcludesFlags(): void { $content = $this->entity()->toCacheContent(); $this->assertArrayNotHasKey('flags', $content['properties']); $this->assertArrayHasKey('body', $content['properties']); } public function testMetaFlagsOverrideAndContentKeepsThem(): void { $entity = $this->entity(); $meta = $entity->toCacheMeta(); $meta['flags'] = ['flagged']; $restored = (new EntityResource('imap', 'svc')) ->fromCacheMeta($meta) ->fromCacheContent($entity->toCacheContent()); $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(); $content['schemaVersion'] = EntityResource::CACHE_SCHEMA_VERSION + 1; $this->expectException(UnexpectedValueException::class); (new EntityResource('imap', 'svc'))->fromCacheContent($content); } public function testCollectionRoundTripsWithoutStoreFields(): void { $original = (new CollectionResource('imap', 'svc')) ->fromImap(new Mailbox('INBOX/Reports', '/', ['\\HasNoChildren'], 12, 3)); $document = [ 'tid' => 'tenant', 'sid' => 'svc', 'uidValidity' => 7, 'lock' => ['owner' => 'x'], ...json_decode(json_encode($original->toCacheMeta()), true), ]; $restored = (new CollectionResource('imap', 'svc'))->fromCacheMeta($document); $this->assertSame(json_encode($original), json_encode($restored)); $this->assertSame('INBOX/Reports', $document['name']); $this->assertSame('INBOX', $document['parent']); } public function testMessageStoreUpsertSetsKeyAndLeavesOtherFields(): void { $collection = $this->createMock(Collection::class); $collection->expects($this->once()) ->method('updateOne') ->with( ['sid' => 'svc', 'mailbox' => 'INBOX', 'uidValidity' => 7, 'uid' => 42], $this->callback(function (array $update): bool { $this->assertSame(['$set', '$setOnInsert'], array_keys($update)); $this->assertSame('tenant', $update['$set']['tid']); $this->assertSame(7, $update['$set']['uidValidity']); $this->assertSame(5, $update['$set']['modSeq']); $this->assertSame(['addedSeq' => 5], $update['$setOnInsert']); $this->assertArrayNotHasKey('blobs', $update['$set']); return true; }), ['upsert' => true], ); (new MessageStore($this->dataStore($collection)))->upsert('tenant', 'svc', 7, $this->entity(), 5); } public function testMailboxStoreUpsertLeavesHarmonizationState(): void { $collection = $this->createMock(Collection::class); $collection->expects($this->once()) ->method('updateOne') ->with( ['sid' => 'svc', 'name' => 'INBOX'], $this->callback(function (array $update): bool { $this->assertSame(['tid', 'sid', 'name', 'parent', 'signature', 'properties'], array_keys($update['$set'])); return true; }), ['upsert' => true], ); $mailbox = (new CollectionResource('imap', 'svc'))->fromImap(new Mailbox('INBOX', '/', [])); (new MailboxStore($this->dataStore($collection)))->upsert('tenant', 'svc', $mailbox); } private function dataStore(Collection $collection): DataStore { $dataStore = $this->createStub(DataStore::class); $dataStore->method('selectCollection')->willReturn($collection); return $dataStore; } private function entity(): EntityResource { return (new EntityResource('imap', 'svc'))->fromImap($this->message(), 'INBOX'); } private function message(): Message { $header = "Subject: Quarterly report\r\n" . "References: \r\n \r\n" . "\r\n"; $body = "--b\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello team,\r\n the report is attached.\r\n" . "--b\r\nContent-Type: application/pdf\r\nContent-Transfer-Encoding: base64\r\n\r\nJVBERi0=\r\n--b--\r\n"; $structure = '(("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 40 2)' . '("APPLICATION" "PDF" ("NAME" "report.pdf") NIL NIL "BASE64" 8 NIL ("ATTACHMENT" ("FILENAME" "report.pdf")) NIL)' . ' "MIXED" ("BOUNDARY" "b") NIL NIL)'; $envelope = '("Sun, 27 Sep 2026 10:30:00 +0200" "Quarterly report"' . ' (("Alice" NIL "alice" "example.com")) (("Alice" NIL "alice" "example.com")) NIL' . ' (("Bob" NIL "bob" "example.com")) NIL NIL "" "")'; return FetchMessageParser::parse(sprintf( "* 1 FETCH (UID 42 FLAGS (\\Seen \$Forwarded) RFC822.SIZE 1234 INTERNALDATE \"27-Sep-2026 10:31:00 +0200\"" . " ENVELOPE %s BODYSTRUCTURE %s BODY[HEADER] {%d}\r\n%s BODY[TEXT] {%d}\r\n%s)", $envelope, $structure, strlen($header), $header, strlen($body), $body, )); } }