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 testStageInitializesDirtyRemoteState(): void { $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $manager = $this->makeManager($documentsManager); $response = $manager->stage( self::TENANT_ID, self::USER_ID, 'draft-new', 'new', ['address' => 'sender@example.com'], ['subject' => 'Draft'], ); $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-new'); $this->assertArrayNotHasKey('remote', $response); $this->assertSame([ 'status' => 'dirty', 'entity' => null, 'error' => null, ], $snapshot['remote']); } public function testStageQueuesDraftSynchronizationAfterLocalSave(): void { $events = $this->createMock(EventDispatcherInterface::class); $events->expects($this->once()) ->method('dispatch') ->with($this->callback(static fn(CompositionSavedEvent $event): bool => $event->tenantIdentifier() === self::TENANT_ID && $event->userIdentifier() === self::USER_ID && $event->compositionIdentifier() === 'draft-event' && $event->revision() === 1)); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $manager = $this->makeManager($documentsManager, events: $events); $result = $manager->stage( self::TENANT_ID, self::USER_ID, 'draft-event', 'fresh', ['address' => 'sender@example.com'], ['subject' => 'Draft'], ); $this->assertSame('staged', $result['disposition']); } public function testStageSurfacesEventQueueingFailureAfterLocalSave(): void { $events = $this->createStub(EventDispatcherInterface::class); $events->method('dispatch')->willThrowException(new \RuntimeException('No active event scope')); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $manager = $this->makeManager($documentsManager, events: $events); try { $manager->stage( self::TENANT_ID, self::USER_ID, 'draft-event-failure', 'fresh', ['address' => 'sender@example.com'], ['subject' => 'Draft'], ); $this->fail('Event dispatch failure should be surfaced'); } catch (\RuntimeException $exception) { $this->assertSame('No active event scope', $exception->getMessage()); } $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-event-failure'); $this->assertSame('dirty', $snapshot['remote']['status']); } public function testPatchMarksExistingRemoteStateDirty(): void { $compositionId = $this->stageEmptyComposition(); $setSynced = static function (?array $snapshot): array { $snapshot['revision'] = 1; $snapshot['remote'] = [ 'status' => 'synced', 'entity' => 'provider:service:drafts:123', 'error' => 'old error', ]; return $snapshot; }; $this->compositionStore->compositionSave( self::TENANT_ID, self::USER_ID, $compositionId, $setSynced, ); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); $events = $this->createMock(EventDispatcherInterface::class); $events->expects($this->once()) ->method('dispatch') ->with($this->callback(static fn(CompositionSavedEvent $event): bool => $event->compositionIdentifier() === $compositionId && $event->revision() === 2)); $manager = $this->makeManager($documentsManager, events: $events); $result = $manager->patch(self::TENANT_ID, self::USER_ID, $compositionId, [ 'revision' => 2, 'sender' => ['address' => 'sender@example.com'], 'message' => ['subject' => 'Changed'], ]); $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId); $this->assertSame('patched', $result['disposition']); $this->assertSame('dirty', $snapshot['remote']['status']); $this->assertSame('provider:service:drafts:123', $snapshot['remote']['entity']); $this->assertNull($snapshot['remote']['error']); } 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')); $events = $this->createMock(EventDispatcherInterface::class); $events->expects($this->once()) ->method('dispatch') ->with($this->callback(static fn(CompositionSavedEvent $event): bool => $event->compositionIdentifier() === $compositionId)); $manager = $this->makeManager($documentsManager, events: $events); $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']); $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId); $this->assertSame('dirty', $snapshot['remote']['status']); $this->assertNull($snapshot['remote']['entity']); $this->assertNull($snapshot['remote']['error']); } 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, ?EventDispatcherInterface $events = null, ): 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, events: $events ?? $this->createStub(EventDispatcherInterface::class), ); } 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); } }