From 67024b5bfdc9450f1fbbe62f399b9d49d4c72b8f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 18 Aug 2026 21:57:38 -0400 Subject: [PATCH] feat: add logic to sync drafts to server Signed-off-by: Sebastian --- lib/CompositionManager.php | 178 +++++++++++++- lib/CompositionSavedEvent.php | 48 ++++ lib/CompositionSyncListener.php | 20 ++ lib/Module.php | 18 +- tests/php/Unit/CompositionManagerTest.php | 72 +++++- tests/php/Unit/CompositionSavedEventTest.php | 45 ++++ .../Unit/CompositionSynchronizationTest.php | 228 ++++++++++++++++++ 7 files changed, 600 insertions(+), 9 deletions(-) create mode 100644 lib/CompositionSavedEvent.php create mode 100644 lib/CompositionSyncListener.php create mode 100644 tests/php/Unit/CompositionSavedEventTest.php create mode 100644 tests/php/Unit/CompositionSynchronizationTest.php diff --git a/lib/CompositionManager.php b/lib/CompositionManager.php index e01600d..c69badc 100644 --- a/lib/CompositionManager.php +++ b/lib/CompositionManager.php @@ -6,13 +6,19 @@ namespace KTXM\Mail; use InvalidArgumentException; use KTXC\Module\ModuleManager; +use KTXF\Event\EventDispatcherInterface; +use KTXF\Mail\Collection\CollectionBaseInterface; +use KTXF\Mail\Collection\CollectionRoles; use KTXF\Mail\Object\Address; use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Object\MessagePart; use KTXF\Mail\Object\MessagePropertiesMutableInterface; +use KTXF\Mail\Service\ServiceEntityMutableInterface; +use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceEntitySubmitInterface; use KTXF\Mail\Submission\EntitySubmitResult; use KTXF\Resource\BinaryResource; +use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\ResourceIdentifier; use KTXF\Utile\UUID; @@ -20,6 +26,8 @@ use KTXM\Mail\Stores\CompositionStore; use KTXM\MailManager\Manager as MailManager; use KTXM\DocumentsManager\Manager as DocumentsManager; use Psr\Container\ContainerInterface; +use RuntimeException; +use Throwable; class CompositionManager { @@ -30,6 +38,7 @@ class CompositionManager { private readonly ModuleManager $moduleManager, private readonly CompositionStore $compositionStore, private readonly MailManager $mailManager, + private readonly EventDispatcherInterface $events, ) {} public function stage(string $tenantId, string $userId, string $identifier, string $action, array $sender, array $message, ?string $source = null): array { @@ -115,6 +124,8 @@ class CompositionManager { $identifier, $saveComposition, ) ?? $snapshot; + $event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)$snapshot['revision']); + $this->events->dispatch($event); $response = $snapshot; unset($response['remote']); @@ -157,12 +168,16 @@ class CompositionManager { }; // Execute the mutation and atomically persist the snapshot it returns. - $this->compositionStore->compositionSave( + $composed = $this->compositionStore->compositionSave( $tenantId, $userId, $identifier, $patchComposition, ); + if (($result['disposition'] ?? null) === 'patched' && $composed !== null) { + $event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)($composed['revision'] ?? 0)); + $this->events->dispatch($event); + } return $result; } @@ -174,6 +189,113 @@ class CompositionManager { ]; } + public function synchronize(string $tenantId, string $userId, string $identifier): array { + $result = [ + 'identifier' => $identifier, + 'disposition' => 'staged', + ]; + + // Construct the synchronization operation that will run while the composition is locked. + $synchronizeComposition = function (?array $composed) use ($tenantId, $userId, $identifier, &$result): ?array { + if ($composed === null) { + $result['error'] = 'Composition not found'; + return null; + } + + $remote = isset($composed['remote']) && is_array($composed['remote']) + ? $composed['remote'] + : ['status' => 'dirty', 'entity' => null, 'error' => null]; + + if (($remote['status'] ?? 'dirty') === 'synced') { + $result['disposition'] = 'saved'; + return null; + } + + try { + $sender = isset($composed['sender']) && is_array($composed['sender']) + ? $composed['sender'] + : []; + $senderAddress = (string)($sender['address'] ?? ''); + if ($senderAddress === '' || !filter_var($senderAddress, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Composition sender is invalid'); + } + + $service = $this->mailManager->serviceFindByAddress($tenantId, $userId, $senderAddress); + if ($service === null || $service->getEnabled() === false) { + throw new RuntimeException("No enabled mail service handles '$senderAddress'"); + } + if (!$service instanceof ServiceEntityMutableInterface) { + throw new RuntimeException("Mail service '{$service->identifier()}' does not support draft mutations"); + } + + $target = $this->resolveDraftCollection($service); + $attachments = isset($composed['attachments']) && is_array($composed['attachments']) + ? array_values($composed['attachments']) + : []; + $properties = $this->buildMessageProperties( + service: $service, + sender: Address::fromArray($sender), + message: is_array($composed['message'] ?? null) ? $composed['message'] : [], + attachments: $attachments, + composed: $composed, + tenantId: $tenantId, + userId: $userId, + compositionId: $identifier, + ); + $properties->setFlag('draft', true); + $properties->setFlag('seen', true); + + $remoteEntity = $this->remoteEntityIdentifier($remote['entity'] ?? null); + if ($remoteEntity === null) { + $entity = $this->mailManager->entityCreate($tenantId, $userId, $target, $properties); + } else { + if ($remoteEntity->provider() !== $service->provider() + || (string)$remoteEntity->service() !== (string)$service->identifier()) { + throw new RuntimeException('Changing the draft service is not supported yet'); + } + $entity = $this->mailManager->entityModify($tenantId, $userId, $remoteEntity, $properties); + } + + if ($entity->collection() === null || $entity->identifier() === null) { + throw new RuntimeException('Provider returned an incomplete draft identifier'); + } + + $composed['remote'] = [ + ...$remote, + 'status' => 'synced', + 'entity' => (string)new EntityIdentifier( + $entity->provider(), + (string)$entity->service(), + (string)$entity->collection(), + (string)$entity->identifier(), + ), + 'error' => null, + ]; + $result['disposition'] = 'saved'; + } catch (Throwable $throwable) { + $composed['remote'] = [ + ...$remote, + 'status' => 'failed', + 'entity' => $remote['entity'] ?? null, + 'error' => $throwable->getMessage(), + ]; + $result['error'] = $throwable->getMessage(); + } + + return $composed; + }; + + // Synchronize and atomically persist the resulting remote state. + $this->compositionStore->compositionSave( + $tenantId, + $userId, + $identifier, + $synchronizeComposition, + ); + + return $result; + } + public function send(string $tenantId, string $userId, string $identifier, array $sender, array $message, array $attachments): array { // Construct the snapshot mutation that will run while the composition is locked. $saveComposition = function (?array $composed) use ($sender, $message): ?array { @@ -269,7 +391,7 @@ class CompositionManager { } private function buildMessageProperties( - ServiceEntitySubmitInterface $service, + ServiceEntitySubmitInterface|ServiceEntityMutableInterface $service, AddressInterface $sender, array $message, array $attachments, @@ -348,8 +470,9 @@ class CompositionManager { 'attachments' => [], ]; $failed = []; + $compositionChanged = false; // Construct the snapshot mutation that will run while the composition is locked. - $addAttachments = function (?array $composed) use ($tenantId, $userId, $composition, $attachments, &$result, &$failed): ?array { + $addAttachments = function (?array $composed) use ($tenantId, $userId, $composition, $attachments, &$result, &$failed, &$compositionChanged): ?array { if ($composed === null) { $result['disposition'] = 'error'; $result['error'] = [ @@ -379,6 +502,7 @@ class CompositionManager { $documentAttachmentsChanged = count($failed) < count($documents); if ($deviceAttachmentsChanged || $documentAttachmentsChanged) { $composed = $this->markRemoteDirty($composed); + $compositionChanged = true; } return $composed; @@ -410,6 +534,10 @@ class CompositionManager { 'message' => 'None of the requested attachments could be added', ]; } + if ($compositionChanged) { + $event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0)); + $this->events->dispatch($event); + } return $result; } @@ -571,12 +699,16 @@ class CompositionManager { }; // Execute the mutation and atomically persist the snapshot it returns. - $this->compositionStore->compositionSave( + $composed = $this->compositionStore->compositionSave( $tenantId, $userId, $composition, $removeAttachment, ); + if (($result['disposition'] ?? null) === 'removed' && $composed !== null) { + $event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0)); + $this->events->dispatch($event); + } return $result; } @@ -594,6 +726,44 @@ class CompositionManager { return $composed; } + private function resolveDraftCollection(ServiceBaseInterface $service): CollectionIdentifier { + $configuredTarget = $service->getAuxiliary()['draftTarget'] ?? null; + if ($configuredTarget !== null && (string)$configuredTarget !== '') { + return new CollectionIdentifier( + $service->provider(), + (string)$service->identifier(), + (string)$configuredTarget, + ); + } + + $filter = $service->collectionListFilter(); + $filter->condition('role', CollectionRoles::Drafts->value); + $collections = $service->collectionList('', $filter); + $collection = reset($collections); + if (!$collection instanceof CollectionBaseInterface || $collection->identifier() === null) { + throw new RuntimeException("Mail service '{$service->identifier()}' has no Drafts collection"); + } + + return new CollectionIdentifier( + $service->provider(), + (string)$service->identifier(), + (string)$collection->identifier(), + ); + } + + private function remoteEntityIdentifier(mixed $value): ?EntityIdentifier { + if (!is_string($value) || $value === '') { + return null; + } + + $identifier = ResourceIdentifier::fromString($value); + if (!$identifier instanceof EntityIdentifier) { + throw new RuntimeException("Invalid remote draft identifier '$value'"); + } + + return $identifier; + } + /** * @param array $entries * @return array diff --git a/lib/CompositionSavedEvent.php b/lib/CompositionSavedEvent.php new file mode 100644 index 0000000..dc51ea1 --- /dev/null +++ b/lib/CompositionSavedEvent.php @@ -0,0 +1,48 @@ +userId === '' || $this->compositionId === '') { + throw new InvalidArgumentException('Composition save events require tenant, user, and composition identifiers'); + } + if ($this->revision < 0) { + throw new InvalidArgumentException('Composition save event revision cannot be negative'); + } + + parent::__construct( + self::class, + [ + 'userId' => $this->userId, + 'compositionId' => $this->compositionId, + 'revision' => $this->revision, + ], + $tenantId, + $this->userId, + ); + } + + public function userIdentifier(): string { + return $this->userId; + } + + public function compositionIdentifier(): string { + return $this->compositionId; + } + + public function revision(): int { + return $this->revision; + } +} diff --git a/lib/CompositionSyncListener.php b/lib/CompositionSyncListener.php new file mode 100644 index 0000000..58e7a23 --- /dev/null +++ b/lib/CompositionSyncListener.php @@ -0,0 +1,20 @@ +compositionManager->synchronize( + $event->tenantIdentifier(), + $event->userIdentifier(), + $event->compositionIdentifier(), + ); + } +} diff --git a/lib/Module.php b/lib/Module.php index f2ae074..1c9bfe8 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -2,6 +2,8 @@ namespace KTXM\Mail; +use KTXF\Event\DeliveryMode; +use KTXF\Event\EventListenerRegistrarInterface; use KTXF\Module\ModuleBrowserInterface; use KTXF\Module\ModuleInstanceAbstract; @@ -11,8 +13,20 @@ use KTXF\Module\ModuleInstanceAbstract; class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface { - public function __construct() - { } + public function __construct( + private readonly EventListenerRegistrarInterface $events, + ) {} + + public function boot(): void + { + $this->events->listen( + $this->handle(), + CompositionSavedEvent::class, + CompositionSyncListener::class, + 'onCompositionSaved', + DeliveryMode::Deferred, + ); + } public function handle(): string { diff --git a/tests/php/Unit/CompositionManagerTest.php b/tests/php/Unit/CompositionManagerTest.php index 88f97b4..688e7c8 100644 --- a/tests/php/Unit/CompositionManagerTest.php +++ b/tests/php/Unit/CompositionManagerTest.php @@ -6,9 +6,11 @@ namespace KTXT\Mail\Tests\Unit; use KTXC\Module\ModuleManager; use KTXC\Module\ModuleObject; +use KTXF\Event\EventDispatcherInterface; use KTXF\Documents\Entity\EntityBaseInterface; use KTXF\Documents\Entity\EntityPropertiesBaseInterface; use KTXM\Mail\CompositionManager; +use KTXM\Mail\CompositionSavedEvent; use KTXM\Mail\Stores\CompositionStore; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -74,6 +76,54 @@ final class CompositionManagerTest extends TestCase { ], $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 { @@ -93,7 +143,13 @@ final class CompositionManagerTest extends TestCase { ); $documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class); - $manager = $this->makeManager($documentsManager); + $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'], @@ -115,7 +171,12 @@ final class CompositionManagerTest extends TestCase { ]); $documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world')); - $manager = $this->makeManager($documentsManager); + $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', @@ -224,7 +285,11 @@ final class CompositionManagerTest extends TestCase { $this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']); } - private function makeManager(Stub $documentsManager, bool $documentsModuleEnabled = true): CompositionManager { + private function makeManager( + Stub $documentsManager, + bool $documentsModuleEnabled = true, + ?EventDispatcherInterface $events = null, + ): CompositionManager { $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); $moduleManager = $this->createStub(ModuleManager::class); @@ -245,6 +310,7 @@ final class CompositionManagerTest extends TestCase { moduleManager: $moduleManager, compositionStore: $this->compositionStore, mailManager: $mailManager, + events: $events ?? $this->createStub(EventDispatcherInterface::class), ); } diff --git a/tests/php/Unit/CompositionSavedEventTest.php b/tests/php/Unit/CompositionSavedEventTest.php new file mode 100644 index 0000000..af84003 --- /dev/null +++ b/tests/php/Unit/CompositionSavedEventTest.php @@ -0,0 +1,45 @@ +assertSame(CompositionSavedEvent::class, $event->label()); + $this->assertSame('tenant-1', $event->tenantIdentifier()); + $this->assertSame('user-1', $event->userIdentifier()); + $this->assertSame('draft-1', $event->compositionIdentifier()); + $this->assertSame(7, $event->revision()); + $this->assertSame([ + 'userId' => 'user-1', + 'compositionId' => 'draft-1', + 'revision' => 7, + ], $event->context()); + } + + public function testModuleRegistersDeferredCompositionSyncListener(): void { + $events = $this->createMock(EventListenerRegistrarInterface::class); + $events->expects($this->once()) + ->method('listen') + ->with( + 'mail', + CompositionSavedEvent::class, + CompositionSyncListener::class, + 'onCompositionSaved', + DeliveryMode::Deferred, + ); + + (new Module($events))->boot(); + } +} diff --git a/tests/php/Unit/CompositionSynchronizationTest.php b/tests/php/Unit/CompositionSynchronizationTest.php new file mode 100644 index 0000000..05c4cb6 --- /dev/null +++ b/tests/php/Unit/CompositionSynchronizationTest.php @@ -0,0 +1,228 @@ +markTestSkipped('mail_manager module is not available alongside this checkout'); + } + + $this->rootDir = sys_get_temp_dir() . '/ktrix-mail-sync-test-' . uniqid('', true); + mkdir($this->rootDir, 0755, true); + $this->compositionStore = new CompositionStore($this->rootDir); + } + + protected function tearDown(): void { + $this->deleteDir($this->rootDir); + } + + public function testCreatesDraftInConfiguredCollection(): void { + $this->stageComposition(); + $properties = $this->createStub(MessagePropertiesMutableInterface::class); + $service = $this->mutableService(['draftTarget' => 'drafts'], $properties); + $created = $this->entity('provider', 'service', 'drafts', 'created-1'); + + $mailManager = $this->createMock(\KTXM\MailManager\Manager::class); + $mailManager->method('serviceFindByAddress')->willReturn($service); + $mailManager->expects($this->once()) + ->method('entityCreate') + ->with( + self::TENANT_ID, + self::USER_ID, + $this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:drafts'), + $properties, + ) + ->willReturn($created); + + $compositionManager = $this->compositionManager($mailManager); + + $result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + $replayed = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + + $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + $this->assertSame('saved', $result['disposition']); + $this->assertSame('saved', $replayed['disposition']); + $this->assertSame('synced', $snapshot['remote']['status']); + $this->assertSame('provider:service:drafts:created-1', $snapshot['remote']['entity']); + $this->assertNull($snapshot['remote']['error']); + } + + public function testModifiesExistingDraftAndStoresAuthoritativeIdentifier(): void { + $this->stageComposition('provider:service:drafts:old-1'); + $properties = $this->createStub(MessagePropertiesMutableInterface::class); + $service = $this->mutableService(['draftTarget' => 'drafts'], $properties); + $modified = $this->entity('provider', 'service', 'drafts', 'replacement-2'); + + $mailManager = $this->createMock(\KTXM\MailManager\Manager::class); + $mailManager->method('serviceFindByAddress')->willReturn($service); + $mailManager->expects($this->once()) + ->method('entityModify') + ->with( + self::TENANT_ID, + self::USER_ID, + $this->callback(static fn(EntityIdentifier $target): bool => (string)$target === 'provider:service:drafts:old-1'), + $properties, + ) + ->willReturn($modified); + + $compositionManager = $this->compositionManager($mailManager); + + $result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + + $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + $this->assertSame('saved', $result['disposition']); + $this->assertSame('provider:service:drafts:replacement-2', $snapshot['remote']['entity']); + } + + public function testResolvesDraftCollectionByRoleWhenNoTargetIsConfigured(): void { + $this->stageComposition(); + $properties = $this->createStub(MessagePropertiesMutableInterface::class); + $service = $this->mutableService([], $properties); + $filter = $this->createMock(IFilter::class); + $filter->expects($this->once())->method('condition')->with('role', 'drafts'); + $service->method('collectionListFilter')->willReturn($filter); + $collection = $this->createStub(CollectionBaseInterface::class); + $collection->method('identifier')->willReturn('role-drafts'); + $service->method('collectionList')->willReturn([$collection]); + + $created = $this->entity('provider', 'service', 'role-drafts', 'created-1'); + $mailManager = $this->createMock(\KTXM\MailManager\Manager::class); + $mailManager->method('serviceFindByAddress')->willReturn($service); + $mailManager->expects($this->once()) + ->method('entityCreate') + ->with( + self::TENANT_ID, + self::USER_ID, + $this->callback(static fn(CollectionIdentifier $target): bool => (string)$target === 'provider:service:role-drafts'), + $properties, + ) + ->willReturn($created); + + $compositionManager = $this->compositionManager($mailManager); + + $result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + + $this->assertSame('saved', $result['disposition']); + } + + public function testRecordsFailureWhileKeepingLocalDraftStaged(): void { + $this->stageComposition(); + $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); + $mailManager->method('serviceFindByAddress')->willReturn(null); + $compositionManager = $this->compositionManager($mailManager); + + $result = $compositionManager->synchronize(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + + $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); + $this->assertSame('staged', $result['disposition']); + $this->assertArrayHasKey('error', $result); + $this->assertSame('failed', $snapshot['remote']['status']); + $this->assertSame($result['error'], $snapshot['remote']['error']); + $this->assertNull($snapshot['remote']['entity']); + } + + private function stageComposition(?string $remoteEntity = null): void { + $saveComposition = static fn(): array => [ + 'identifier' => self::COMPOSITION_ID, + 'sender' => ['address' => 'sender@example.com'], + 'message' => ['subject' => 'Draft'], + 'attachments' => [], + 'remote' => [ + 'status' => 'dirty', + 'entity' => $remoteEntity, + 'error' => null, + ], + ]; + $this->compositionStore->compositionSave( + self::TENANT_ID, + self::USER_ID, + self::COMPOSITION_ID, + $saveComposition, + ); + } + + private function mutableService(array $auxiliary, MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntityMutableInterface&Stub { + $service = $this->createStubForIntersectionOfInterfaces([ + ServiceBaseInterface::class, + ServiceEntityMutableInterface::class, + ]); + $entity = $this->createStub(EntityMutableInterface::class); + $entity->method('getProperties')->willReturn($properties); + $service->method('provider')->willReturn('provider'); + $service->method('identifier')->willReturn('service'); + $service->method('getEnabled')->willReturn(true); + $service->method('getAuxiliary')->willReturn($auxiliary); + $service->method('entityFresh')->willReturn($entity); + return $service; + } + + private function compositionManager(\KTXM\MailManager\Manager $mailManager): CompositionManager { + return new CompositionManager( + container: $this->createStub(ContainerInterface::class), + moduleManager: $this->createStub(ModuleManager::class), + compositionStore: $this->compositionStore, + mailManager: $mailManager, + events: $this->createStub(EventDispatcherInterface::class), + ); + } + + private function entity(string $provider, string $service, string $collection, string $identifier): EntityBaseInterface&Stub { + $entity = $this->createStub(EntityBaseInterface::class); + $entity->method('provider')->willReturn($provider); + $entity->method('service')->willReturn($service); + $entity->method('collection')->willReturn($collection); + $entity->method('identifier')->willReturn($identifier); + return $entity; + } + + 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); + } else { + unlink($child); + } + } + rmdir($path); + } +}