feat: add logic to sync drafts to server
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
+174
-4
@@ -6,13 +6,19 @@ namespace KTXM\Mail;
|
|||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use KTXC\Module\ModuleManager;
|
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\Address;
|
||||||
use KTXF\Mail\Object\AddressInterface;
|
use KTXF\Mail\Object\AddressInterface;
|
||||||
use KTXF\Mail\Object\MessagePart;
|
use KTXF\Mail\Object\MessagePart;
|
||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||||
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||||
use KTXF\Mail\Submission\EntitySubmitResult;
|
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||||
use KTXF\Resource\BinaryResource;
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
use KTXF\Resource\Identifier\EntityIdentifier;
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
use KTXF\Resource\Identifier\ResourceIdentifier;
|
use KTXF\Resource\Identifier\ResourceIdentifier;
|
||||||
use KTXF\Utile\UUID;
|
use KTXF\Utile\UUID;
|
||||||
@@ -20,6 +26,8 @@ use KTXM\Mail\Stores\CompositionStore;
|
|||||||
use KTXM\MailManager\Manager as MailManager;
|
use KTXM\MailManager\Manager as MailManager;
|
||||||
use KTXM\DocumentsManager\Manager as DocumentsManager;
|
use KTXM\DocumentsManager\Manager as DocumentsManager;
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class CompositionManager {
|
class CompositionManager {
|
||||||
|
|
||||||
@@ -30,6 +38,7 @@ class CompositionManager {
|
|||||||
private readonly ModuleManager $moduleManager,
|
private readonly ModuleManager $moduleManager,
|
||||||
private readonly CompositionStore $compositionStore,
|
private readonly CompositionStore $compositionStore,
|
||||||
private readonly MailManager $mailManager,
|
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 {
|
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,
|
$identifier,
|
||||||
$saveComposition,
|
$saveComposition,
|
||||||
) ?? $snapshot;
|
) ?? $snapshot;
|
||||||
|
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)$snapshot['revision']);
|
||||||
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
$response = $snapshot;
|
$response = $snapshot;
|
||||||
unset($response['remote']);
|
unset($response['remote']);
|
||||||
@@ -157,12 +168,16 @@ class CompositionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Execute the mutation and atomically persist the snapshot it returns.
|
// Execute the mutation and atomically persist the snapshot it returns.
|
||||||
$this->compositionStore->compositionSave(
|
$composed = $this->compositionStore->compositionSave(
|
||||||
$tenantId,
|
$tenantId,
|
||||||
$userId,
|
$userId,
|
||||||
$identifier,
|
$identifier,
|
||||||
$patchComposition,
|
$patchComposition,
|
||||||
);
|
);
|
||||||
|
if (($result['disposition'] ?? null) === 'patched' && $composed !== null) {
|
||||||
|
$event = new CompositionSavedEvent($tenantId, $userId, $identifier, (int)($composed['revision'] ?? 0));
|
||||||
|
$this->events->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
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 {
|
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.
|
// Construct the snapshot mutation that will run while the composition is locked.
|
||||||
$saveComposition = function (?array $composed) use ($sender, $message): ?array {
|
$saveComposition = function (?array $composed) use ($sender, $message): ?array {
|
||||||
@@ -269,7 +391,7 @@ class CompositionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function buildMessageProperties(
|
private function buildMessageProperties(
|
||||||
ServiceEntitySubmitInterface $service,
|
ServiceEntitySubmitInterface|ServiceEntityMutableInterface $service,
|
||||||
AddressInterface $sender,
|
AddressInterface $sender,
|
||||||
array $message,
|
array $message,
|
||||||
array $attachments,
|
array $attachments,
|
||||||
@@ -348,8 +470,9 @@ class CompositionManager {
|
|||||||
'attachments' => [],
|
'attachments' => [],
|
||||||
];
|
];
|
||||||
$failed = [];
|
$failed = [];
|
||||||
|
$compositionChanged = false;
|
||||||
// Construct the snapshot mutation that will run while the composition is locked.
|
// 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) {
|
if ($composed === null) {
|
||||||
$result['disposition'] = 'error';
|
$result['disposition'] = 'error';
|
||||||
$result['error'] = [
|
$result['error'] = [
|
||||||
@@ -379,6 +502,7 @@ class CompositionManager {
|
|||||||
$documentAttachmentsChanged = count($failed) < count($documents);
|
$documentAttachmentsChanged = count($failed) < count($documents);
|
||||||
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
|
if ($deviceAttachmentsChanged || $documentAttachmentsChanged) {
|
||||||
$composed = $this->markRemoteDirty($composed);
|
$composed = $this->markRemoteDirty($composed);
|
||||||
|
$compositionChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $composed;
|
return $composed;
|
||||||
@@ -410,6 +534,10 @@ class CompositionManager {
|
|||||||
'message' => 'None of the requested attachments could be added',
|
'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;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -571,12 +699,16 @@ class CompositionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Execute the mutation and atomically persist the snapshot it returns.
|
// Execute the mutation and atomically persist the snapshot it returns.
|
||||||
$this->compositionStore->compositionSave(
|
$composed = $this->compositionStore->compositionSave(
|
||||||
$tenantId,
|
$tenantId,
|
||||||
$userId,
|
$userId,
|
||||||
$composition,
|
$composition,
|
||||||
$removeAttachment,
|
$removeAttachment,
|
||||||
);
|
);
|
||||||
|
if (($result['disposition'] ?? null) === 'removed' && $composed !== null) {
|
||||||
|
$event = new CompositionSavedEvent($tenantId, $userId, $composition, (int)($composed['revision'] ?? 0));
|
||||||
|
$this->events->dispatch($event);
|
||||||
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -594,6 +726,44 @@ class CompositionManager {
|
|||||||
return $composed;
|
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<int,array|string> $entries
|
* @param array<int,array|string> $entries
|
||||||
* @return array<int,Address>
|
* @return array<int,Address>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\Mail;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class CompositionSavedEvent extends Event {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $tenantId,
|
||||||
|
private readonly string $userId,
|
||||||
|
private readonly string $compositionId,
|
||||||
|
private readonly int $revision,
|
||||||
|
) {
|
||||||
|
if ($tenantId === '' || $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\Mail;
|
||||||
|
|
||||||
|
final class CompositionSyncListener {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CompositionManager $compositionManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function onCompositionSaved(CompositionSavedEvent $event): void {
|
||||||
|
$this->compositionManager->synchronize(
|
||||||
|
$event->tenantIdentifier(),
|
||||||
|
$event->userIdentifier(),
|
||||||
|
$event->compositionIdentifier(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-2
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace KTXM\Mail;
|
namespace KTXM\Mail;
|
||||||
|
|
||||||
|
use KTXF\Event\DeliveryMode;
|
||||||
|
use KTXF\Event\EventListenerRegistrarInterface;
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\ModuleBrowserInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
@@ -11,8 +13,20 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
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
|
public function handle(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ namespace KTXT\Mail\Tests\Unit;
|
|||||||
|
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use KTXC\Module\ModuleObject;
|
use KTXC\Module\ModuleObject;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\Documents\Entity\EntityBaseInterface;
|
use KTXF\Documents\Entity\EntityBaseInterface;
|
||||||
use KTXF\Documents\Entity\EntityPropertiesBaseInterface;
|
use KTXF\Documents\Entity\EntityPropertiesBaseInterface;
|
||||||
use KTXM\Mail\CompositionManager;
|
use KTXM\Mail\CompositionManager;
|
||||||
|
use KTXM\Mail\CompositionSavedEvent;
|
||||||
use KTXM\Mail\Stores\CompositionStore;
|
use KTXM\Mail\Stores\CompositionStore;
|
||||||
use PHPUnit\Framework\MockObject\Stub;
|
use PHPUnit\Framework\MockObject\Stub;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -74,6 +76,54 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
], $snapshot['remote']);
|
], $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 {
|
public function testPatchMarksExistingRemoteStateDirty(): void {
|
||||||
$compositionId = $this->stageEmptyComposition();
|
$compositionId = $this->stageEmptyComposition();
|
||||||
$setSynced = static function (?array $snapshot): array {
|
$setSynced = static function (?array $snapshot): array {
|
||||||
@@ -93,7 +143,13 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
);
|
);
|
||||||
|
|
||||||
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
$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, [
|
$result = $manager->patch(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
'revision' => 2,
|
'revision' => 2,
|
||||||
'sender' => ['address' => 'sender@example.com'],
|
'sender' => ['address' => 'sender@example.com'],
|
||||||
@@ -115,7 +171,12 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
]);
|
]);
|
||||||
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
$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, [
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
||||||
'identifier' => 'attach-1',
|
'identifier' => 'attach-1',
|
||||||
@@ -224,7 +285,11 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
|
$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);
|
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
||||||
|
|
||||||
$moduleManager = $this->createStub(ModuleManager::class);
|
$moduleManager = $this->createStub(ModuleManager::class);
|
||||||
@@ -245,6 +310,7 @@ final class CompositionManagerTest extends TestCase {
|
|||||||
moduleManager: $moduleManager,
|
moduleManager: $moduleManager,
|
||||||
compositionStore: $this->compositionStore,
|
compositionStore: $this->compositionStore,
|
||||||
mailManager: $mailManager,
|
mailManager: $mailManager,
|
||||||
|
events: $events ?? $this->createStub(EventDispatcherInterface::class),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Mail\Tests\Unit;
|
||||||
|
|
||||||
|
use KTXF\Event\DeliveryMode;
|
||||||
|
use KTXF\Event\EventListenerRegistrarInterface;
|
||||||
|
use KTXM\Mail\CompositionSavedEvent;
|
||||||
|
use KTXM\Mail\CompositionSyncListener;
|
||||||
|
use KTXM\Mail\Module;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CompositionSavedEventTest extends TestCase {
|
||||||
|
|
||||||
|
public function testCarriesOnlySynchronizationCoordinates(): void {
|
||||||
|
$event = new CompositionSavedEvent('tenant-1', 'user-1', 'draft-1', 7);
|
||||||
|
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<?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\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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user