markTestSkipped('mail_manager and documents_manager modules are not available alongside this checkout'); } $this->rootDir = sys_get_temp_dir() . '/ktrix-mail-composition-test-' . uniqid(); mkdir($this->rootDir, 0755, true); $this->compositionStore = new CompositionStore($this->rootDir); } protected function tearDown(): void { $this->deleteDir($this->rootDir); } public function testAttachmentAddFromDocumentsHappyPath(): void { $compositionId = $this->stageEmptyComposition(); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $documentsManager->method('entityFetchBulk')->willReturn([ $this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'), ]); $documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world')); $manager = $this->makeManager($documentsManager); $result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [ 'identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1', ]); $this->assertSame('added', $result['disposition']); $this->assertCount(1, $result['attachments']); $this->assertSame([], $result['failed']); $attachment = $result['attachments']['attach-1']; $this->assertSame('documents', $attachment['origin']); $this->assertSame('default:personal:root:doc1', $attachment['source']); $this->assertSame('hello.txt', $attachment['name']); $this->assertSame('text/plain', $attachment['type']); $this->assertSame(strlen('hello world'), $attachment['size']); } public function testAttachmentAddFromDocumentsPartialFailure(): void { $compositionId = $this->stageEmptyComposition(); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); // only doc1 is returned even though doc1 and doc2 are both requested $documentsManager->method('entityFetchBulk')->willReturn([ $this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'), ]); $documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world')); $manager = $this->makeManager($documentsManager); $result = $manager->attachmentAdd( self::TENANT_ID, self::USER_ID, $compositionId, ['identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'], ['identifier' => 'attach-2', 'origin' => 'documents', 'source' => 'default:personal:root:doc2'], ); $this->assertSame('partial', $result['disposition']); $this->assertCount(1, $result['attachments']); $this->assertCount(1, $result['failed']); $this->assertSame('default:personal:root:doc2', $result['failed'][0]['identifier']); } public function testAttachmentAddMixedUploadAndDocuments(): void { $compositionId = $this->stageEmptyComposition(); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $documentsManager->method('entityFetchBulk')->willReturn([ $this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'), ]); $documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world')); $manager = $this->makeManager($documentsManager); $result = $manager->attachmentAdd( self::TENANT_ID, self::USER_ID, $compositionId, ['identifier' => 'attach-upload', 'origin' => 'device', 'name' => 'upload.txt', 'type' => 'text/plain', 'data' => base64_encode('uploaded bytes')], ['identifier' => 'attach-doc', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'], ); $this->assertSame('added', $result['disposition']); $this->assertCount(2, $result['attachments']); $this->assertSame('device', $result['attachments']['attach-upload']['origin']); $this->assertSame('documents', $result['attachments']['attach-doc']['origin']); } public function testAttachmentAddCompositionNotFound(): void { $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $manager = $this->makeManager($documentsManager); $result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, 'unknown-composition', [ 'identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1', ]); $this->assertSame('error', $result['disposition']); $this->assertSame('composition_not_found', $result['error']['type']); } public function testAttachmentAddDocumentsModuleUnavailable(): void { $compositionId = $this->stageEmptyComposition(); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); // documents_manager is not installed/enabled: attachmentAdd must still // work for the composition itself, just report the sourced entry as failed // rather than throwing (e.g. from a failed DI resolution). $manager = $this->makeManager($documentsManager, documentsModuleEnabled: false); $result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [ 'identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1', ]); $this->assertSame('error', $result['disposition']); $this->assertCount(0, $result['attachments']); $this->assertCount(1, $result['failed']); $this->assertSame('default:personal:root:doc1', $result['failed'][0]['identifier']); $this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']); } private function makeManager(Stub $documentsManager, bool $documentsModuleEnabled = true): CompositionManager { $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); $moduleManager = $this->createStub(ModuleManager::class); if ($documentsModuleEnabled) { $module = $this->createStub(ModuleObject::class); $module->method('enabled')->willReturn(true); $moduleManager->method('fetch')->willReturn($module); } else { $moduleManager->method('fetch')->willReturn(null); } $container = $this->createStub(ContainerInterface::class); $container->method('has')->willReturn($documentsModuleEnabled); $container->method('get')->willReturn($documentsManager); return new CompositionManager( container: $container, moduleManager: $moduleManager, compositionStore: $this->compositionStore, mailManager: $mailManager, ); } private function stageEmptyComposition(): string { $compositionId = 'draft-' . uniqid(); $saveComposition = static fn(): array => [ 'identifier' => $compositionId, 'attachments' => [], ]; $this->compositionStore->compositionSave( self::TENANT_ID, self::USER_ID, $compositionId, $saveComposition, ); return $compositionId; } private function makeEntity(string $urn, string $label, string $mime): EntityBaseInterface&Stub { $properties = $this->createStub(EntityPropertiesBaseInterface::class); $properties->method('getLabel')->willReturn($label); $properties->method('getMime')->willReturn($mime); $properties->method('size')->willReturn(0); $entity = $this->createStub(EntityBaseInterface::class); $entity->method('urn')->willReturn($urn); $entity->method('getProperties')->willReturn($properties); return $entity; } /** @return resource */ private function makeStream(string $contents) { $resource = fopen('php://memory', 'r+'); fwrite($resource, $contents); rewind($resource); return $resource; } private function deleteDir(string $path): void { if (!is_dir($path)) { return; } foreach (scandir($path) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $child = $path . '/' . $entry; if (is_dir($child)) { $this->deleteDir($child); continue; } unlink($child); } rmdir($path); } }