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']); } public function testInlineSaveSynchronizesAndPurgesLocalComposition(): void { $this->stageComposition(); $properties = $this->createStub(MessagePropertiesMutableInterface::class); $service = $this->mutableService(['draftTarget' => 'drafts'], $properties); $created = $this->entity('provider', 'service', 'drafts', 'created-inline'); $mailManager = $this->createMock(\KTXM\MailManager\Manager::class); $mailManager->method('serviceFindByAddress')->willReturn($service); $mailManager->expects($this->once()) ->method('entityCreate') ->willReturn($created); $result = $this->compositionManager($mailManager)->save( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, [ 'revision' => 2, 'sender' => ['address' => 'sender@example.com'], 'message' => ['subject' => 'Saved inline'], ], ); $this->assertSame('saved', $result['disposition']); $this->assertNull($this->compositionStore->compositionFetch( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, )); } public function testInlineSaveFailureKeepsLocalComposition(): void { $this->stageComposition(); $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); $mailManager->method('serviceFindByAddress')->willReturn(null); $result = $this->compositionManager($mailManager)->save( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, [ 'revision' => 2, 'sender' => ['address' => 'sender@example.com'], 'message' => ['subject' => 'Keep this draft'], ], ); $snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID); $this->assertSame('error', $result['disposition']); $this->assertSame('composition_save_failed', $result['error']['type']); $this->assertSame('Keep this draft', $snapshot['message']['subject']); $this->assertSame('failed', $snapshot['remote']['status']); } public function testDiscardDeletesRemoteDraftBeforePurgingLocalComposition(): void { $remote = 'provider:service:drafts:remote-1'; $this->stageComposition($remote); $mailManager = $this->createMock(\KTXM\MailManager\Manager::class); $mailManager->expects($this->once()) ->method('entityDelete') ->with( self::TENANT_ID, self::USER_ID, $this->callback(static fn(EntityIdentifier $target): bool => (string)$target === $remote), ) ->willReturn([$remote => ['disposition' => 'deleted']]); $result = $this->compositionManager($mailManager)->discard( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, ); $this->assertSame('discarded', $result['disposition']); $this->assertNull($this->compositionStore->compositionFetch( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, )); } public function testRemoteDiscardFailureKeepsLocalComposition(): void { $remote = 'provider:service:drafts:remote-1'; $this->stageComposition($remote); $mailManager = $this->createMock(\KTXM\MailManager\Manager::class); $mailManager->method('entityDelete') ->willReturn([$remote => ['disposition' => 'error', 'error' => 'Provider unavailable']]); $result = $this->compositionManager($mailManager)->discard( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, ); $this->assertSame('error', $result['disposition']); $this->assertSame('Provider unavailable', $result['error']['message']); $this->assertNotNull($this->compositionStore->compositionFetch( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, )); } public function testSendPurgesLocalCompositionOnSuccess(): void { $this->stageComposition(); $properties = $this->createStub(MessagePropertiesMutableInterface::class); $service = $this->submitService($properties); $service->method('entitySubmit')->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT)); $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); $mailManager->method('serviceFindByAddress')->willReturn($service); $result = $this->compositionManager($mailManager)->send( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, ['address' => 'sender@example.com'], ['subject' => 'Sent draft'], [], ); $this->assertSame('sent', $result['disposition']); $this->assertNull($this->compositionStore->compositionFetch( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, )); } public function testSendReusesSyncedRemoteDraftAsSubmissionSource(): void { $this->stageComposition('provider:service:drafts:remote-1'); $properties = $this->createStub(MessagePropertiesMutableInterface::class); $service = $this->createMockForIntersectionOfInterfaces([ ServiceBaseInterface::class, ServiceEntitySubmitInterface::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('entityFresh')->willReturn($entity); $service->expects($this->once()) ->method('entitySubmit') ->with( $this->anything(), $this->callback(static fn(?EntityIdentifier $source): bool => $source !== null && (string)$source === 'provider:service:drafts:remote-1'), $properties, ) ->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT)); $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); $mailManager->method('serviceFindByAddress')->willReturn($service); $result = $this->compositionManager($mailManager)->send( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, ['address' => 'sender@example.com'], ['subject' => 'Sent draft'], [], ); $this->assertSame('sent', $result['disposition']); } public function testSendIgnoresRemoteDraftBelongingToAnotherService(): void { $this->stageComposition('provider:other-service:drafts:remote-1'); $properties = $this->createStub(MessagePropertiesMutableInterface::class); $service = $this->createMockForIntersectionOfInterfaces([ ServiceBaseInterface::class, ServiceEntitySubmitInterface::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('entityFresh')->willReturn($entity); $service->expects($this->once()) ->method('entitySubmit') ->with($this->anything(), $this->isNull(), $properties) ->willReturn(new EntitySubmitResult(EntitySubmitResult::DISPOSITION_SENT)); $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); $mailManager->method('serviceFindByAddress')->willReturn($service); $result = $this->compositionManager($mailManager)->send( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, ['address' => 'sender@example.com'], ['subject' => 'Sent draft'], [], ); $this->assertSame('sent', $result['disposition']); } public function testSendFailureKeepsLocalComposition(): void { $this->stageComposition(); $properties = $this->createStub(MessagePropertiesMutableInterface::class); $service = $this->submitService($properties); $service->method('entitySubmit')->willReturn(new EntitySubmitResult( EntitySubmitResult::DISPOSITION_ERROR, errorMessage: 'Transport unavailable', )); $mailManager = $this->createStub(\KTXM\MailManager\Manager::class); $mailManager->method('serviceFindByAddress')->willReturn($service); $result = $this->compositionManager($mailManager)->send( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, ['address' => 'sender@example.com'], ['subject' => 'Keep this draft'], [], ); $this->assertSame('error', $result['disposition']); $this->assertSame('Transport unavailable', $result['error']['message']); $this->assertNotNull($this->compositionStore->compositionFetch( self::TENANT_ID, self::USER_ID, self::COMPOSITION_ID, )); } 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 submitService(MessagePropertiesMutableInterface $properties): ServiceBaseInterface&ServiceEntitySubmitInterface&Stub { $service = $this->createStubForIntersectionOfInterfaces([ ServiceBaseInterface::class, ServiceEntitySubmitInterface::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('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); } }