a5eb104bd7
Signed-off-by: Sebastian <krupinski01@gmail.com>
475 lines
20 KiB
PHP
475 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXT\Mail\Tests\Unit;
|
|
|
|
use KTXC\Module\ModuleManager;
|
|
use KTXF\Event\EventDispatcherInterface;
|
|
use KTXF\Mail\Collection\CollectionBaseInterface;
|
|
use KTXF\Mail\Entity\EntityBaseInterface;
|
|
use KTXF\Mail\Entity\EntityMutableInterface;
|
|
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
|
use KTXF\Mail\Service\ServiceBaseInterface;
|
|
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
|
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
|
use KTXF\Mail\Submission\EntitySubmitResult;
|
|
use KTXF\Resource\Filter\IFilter;
|
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
|
use KTXM\Mail\CompositionManager;
|
|
use KTXM\Mail\Stores\CompositionStore;
|
|
use PHPUnit\Framework\MockObject\Stub;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Psr\Container\ContainerInterface;
|
|
|
|
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
|
|
if (is_file($mailManagerFile)) {
|
|
require_once $mailManagerFile;
|
|
}
|
|
|
|
final class CompositionSynchronizationTest extends TestCase {
|
|
|
|
private const TENANT_ID = 'tenant-1';
|
|
private const USER_ID = 'user-1';
|
|
private const COMPOSITION_ID = 'draft-1';
|
|
|
|
private string $rootDir;
|
|
private CompositionStore $compositionStore;
|
|
|
|
protected function setUp(): void {
|
|
if (!class_exists(\KTXM\MailManager\Manager::class)) {
|
|
$this->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);
|
|
}
|
|
}
|