0c7f103353
Signed-off-by: Sebastian <krupinski01@gmail.com>
463 lines
20 KiB
PHP
463 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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;
|
|
use Psr\Container\ContainerInterface;
|
|
|
|
// KTXM\MailManager\Manager and KTXM\DocumentsManager\Manager live in sibling
|
|
// modules. This module's isolated test bootstrap only autoloads its own PSR-4
|
|
// map plus the server's KTXC/KTXF namespaces (see tests/php/bootstrap.php), so
|
|
// sibling-module classes are not otherwise resolvable here. Require them
|
|
// directly so PHPUnit can reflect on the real classes to build mocks; both
|
|
// files only depend on already-autoloadable KTXC/KTXF types. Skipped
|
|
// gracefully in setUp() if this module is ever checked out standalone.
|
|
$mailManagerFile = dirname(__DIR__, 4) . '/mail_manager/lib/Manager.php';
|
|
$documentsManagerFile = dirname(__DIR__, 4) . '/documents_manager/lib/Manager.php';
|
|
if (is_file($mailManagerFile)) {
|
|
require_once $mailManagerFile;
|
|
}
|
|
if (is_file($documentsManagerFile)) {
|
|
require_once $documentsManagerFile;
|
|
}
|
|
|
|
final class CompositionManagerTest extends TestCase {
|
|
|
|
private const TENANT_ID = 'tenant-1';
|
|
private const USER_ID = 'user-1';
|
|
|
|
private string $rootDir;
|
|
private CompositionStore $compositionStore;
|
|
|
|
protected function setUp(): void {
|
|
if (!class_exists(\KTXM\MailManager\Manager::class) || !class_exists(\KTXM\DocumentsManager\Manager::class)) {
|
|
$this->markTestSkipped('mail_manager and documents_manager modules are not available alongside this checkout');
|
|
}
|
|
|
|
$this->rootDir = sys_get_temp_dir() . '/ktrix-mail-composition-test-' . uniqid();
|
|
mkdir($this->rootDir, 0755, true);
|
|
$this->compositionStore = new CompositionStore($this->rootDir);
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
$this->deleteDir($this->rootDir);
|
|
}
|
|
|
|
public function testStageInitializesDirtyRemoteState(): void {
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$manager = $this->makeManager($documentsManager);
|
|
|
|
$response = $manager->stage(
|
|
self::TENANT_ID,
|
|
self::USER_ID,
|
|
'draft-new',
|
|
'new',
|
|
['address' => 'sender@example.com'],
|
|
['subject' => 'Draft'],
|
|
);
|
|
|
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-new');
|
|
$this->assertArrayNotHasKey('remote', $response);
|
|
$this->assertSame([
|
|
'status' => 'dirty',
|
|
'entity' => null,
|
|
'error' => null,
|
|
], $snapshot['remote']);
|
|
}
|
|
|
|
public function testStageQueuesDraftSynchronizationAfterLocalSave(): void {
|
|
$events = $this->createMock(EventDispatcherInterface::class);
|
|
$events->expects($this->once())
|
|
->method('dispatch')
|
|
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
|
|
$event->tenantIdentifier() === self::TENANT_ID
|
|
&& $event->userIdentifier() === self::USER_ID
|
|
&& $event->compositionIdentifier() === 'draft-event'
|
|
&& $event->revision() === 1));
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$manager = $this->makeManager($documentsManager, events: $events);
|
|
|
|
$result = $manager->stage(
|
|
self::TENANT_ID,
|
|
self::USER_ID,
|
|
'draft-event',
|
|
'fresh',
|
|
['address' => 'sender@example.com'],
|
|
['subject' => 'Draft'],
|
|
);
|
|
|
|
$this->assertSame('staged', $result['disposition']);
|
|
}
|
|
|
|
public function testStageSurfacesEventQueueingFailureAfterLocalSave(): void {
|
|
$events = $this->createStub(EventDispatcherInterface::class);
|
|
$events->method('dispatch')->willThrowException(new \RuntimeException('No active event scope'));
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$manager = $this->makeManager($documentsManager, events: $events);
|
|
|
|
try {
|
|
$manager->stage(
|
|
self::TENANT_ID,
|
|
self::USER_ID,
|
|
'draft-event-failure',
|
|
'fresh',
|
|
['address' => 'sender@example.com'],
|
|
['subject' => 'Draft'],
|
|
);
|
|
$this->fail('Event dispatch failure should be surfaced');
|
|
} catch (\RuntimeException $exception) {
|
|
$this->assertSame('No active event scope', $exception->getMessage());
|
|
}
|
|
|
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, 'draft-event-failure');
|
|
$this->assertSame('dirty', $snapshot['remote']['status']);
|
|
}
|
|
|
|
public function testPatchMarksExistingRemoteStateDirty(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$setSynced = static function (?array $snapshot): array {
|
|
$snapshot['revision'] = 1;
|
|
$snapshot['remote'] = [
|
|
'status' => 'synced',
|
|
'entity' => 'provider:service:drafts:123',
|
|
'error' => 'old error',
|
|
];
|
|
return $snapshot;
|
|
};
|
|
$this->compositionStore->compositionSave(
|
|
self::TENANT_ID,
|
|
self::USER_ID,
|
|
$compositionId,
|
|
$setSynced,
|
|
);
|
|
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$events = $this->createMock(EventDispatcherInterface::class);
|
|
$events->expects($this->once())
|
|
->method('dispatch')
|
|
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
|
|
$event->compositionIdentifier() === $compositionId
|
|
&& $event->revision() === 2));
|
|
$manager = $this->makeManager($documentsManager, events: $events);
|
|
$result = $manager->patch(self::TENANT_ID, self::USER_ID, $compositionId, [
|
|
'revision' => 2,
|
|
'sender' => ['address' => 'sender@example.com'],
|
|
'message' => ['subject' => 'Changed'],
|
|
]);
|
|
|
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
|
$this->assertSame('patched', $result['disposition']);
|
|
$this->assertSame('dirty', $snapshot['remote']['status']);
|
|
$this->assertSame('provider:service:drafts:123', $snapshot['remote']['entity']);
|
|
$this->assertNull($snapshot['remote']['error']);
|
|
}
|
|
|
|
public function testAttachmentAddFromDocumentsHappyPath(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
|
]);
|
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
|
|
|
$events = $this->createMock(EventDispatcherInterface::class);
|
|
$events->expects($this->once())
|
|
->method('dispatch')
|
|
->with($this->callback(static fn(CompositionSavedEvent $event): bool =>
|
|
$event->compositionIdentifier() === $compositionId));
|
|
$manager = $this->makeManager($documentsManager, events: $events);
|
|
|
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
|
'identifier' => 'attach-1',
|
|
'origin' => 'documents',
|
|
'source' => 'default:personal:root:doc1',
|
|
]);
|
|
|
|
$this->assertSame('added', $result['disposition']);
|
|
$this->assertCount(1, $result['attachments']);
|
|
$this->assertSame([], $result['failed']);
|
|
$this->assertSame(1, $result['revision']);
|
|
|
|
$attachment = $result['attachments']['attach-1'];
|
|
$this->assertSame('documents', $attachment['origin']);
|
|
$this->assertSame('default:personal:root:doc1', $attachment['source']);
|
|
$this->assertSame('hello.txt', $attachment['name']);
|
|
$this->assertSame('text/plain', $attachment['type']);
|
|
$this->assertSame(strlen('hello world'), $attachment['size']);
|
|
|
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
|
$this->assertSame(1, $snapshot['revision']);
|
|
$this->assertSame('dirty', $snapshot['remote']['status']);
|
|
$this->assertNull($snapshot['remote']['entity']);
|
|
$this->assertNull($snapshot['remote']['error']);
|
|
}
|
|
|
|
public function testAttachmentAddIncrementsRevisionOnSubsequentAdd(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
|
$this->makeEntity('default:personal:root:doc2', 'world.txt', 'text/plain'),
|
|
]);
|
|
$documentsManager->method('entityReadStream')->willReturnCallback(
|
|
fn(): mixed => $this->makeStream('hello world'),
|
|
);
|
|
$manager = $this->makeManager($documentsManager);
|
|
|
|
$manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
|
'identifier' => 'attach-1',
|
|
'origin' => 'documents',
|
|
'source' => 'default:personal:root:doc1',
|
|
]);
|
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
|
'identifier' => 'attach-2',
|
|
'origin' => 'documents',
|
|
'source' => 'default:personal:root:doc2',
|
|
]);
|
|
|
|
$this->assertSame(2, $result['revision']);
|
|
}
|
|
|
|
public function testAttachmentAddDoesNotChangeRevisionWhenNothingIsAdded(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
// documents_manager unavailable means the sourced attachment fails
|
|
// entirely, so no attachment is actually added to the composition.
|
|
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
|
|
|
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
|
'identifier' => 'attach-1',
|
|
'origin' => 'documents',
|
|
'source' => 'default:personal:root:doc1',
|
|
]);
|
|
|
|
$this->assertSame('error', $result['disposition']);
|
|
$this->assertArrayNotHasKey('revision', $result);
|
|
|
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
|
$this->assertArrayNotHasKey('revision', $snapshot);
|
|
}
|
|
|
|
public function testAttachmentAddFromDocumentsPartialFailure(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
// only doc1 is returned even though doc1 and doc2 are both requested
|
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
|
]);
|
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
|
|
|
$manager = $this->makeManager($documentsManager);
|
|
|
|
$result = $manager->attachmentAdd(
|
|
self::TENANT_ID,
|
|
self::USER_ID,
|
|
$compositionId,
|
|
['identifier' => 'attach-1', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
|
|
['identifier' => 'attach-2', 'origin' => 'documents', 'source' => 'default:personal:root:doc2'],
|
|
);
|
|
|
|
$this->assertSame('partial', $result['disposition']);
|
|
$this->assertCount(1, $result['attachments']);
|
|
$this->assertCount(1, $result['failed']);
|
|
$this->assertSame('default:personal:root:doc2', $result['failed'][0]['identifier']);
|
|
}
|
|
|
|
public function testAttachmentAddMixedUploadAndDocuments(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
|
]);
|
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
|
|
|
$manager = $this->makeManager($documentsManager);
|
|
|
|
$result = $manager->attachmentAdd(
|
|
self::TENANT_ID,
|
|
self::USER_ID,
|
|
$compositionId,
|
|
['identifier' => 'attach-upload', 'origin' => 'device', 'name' => 'upload.txt', 'type' => 'text/plain', 'data' => base64_encode('uploaded bytes')],
|
|
['identifier' => 'attach-doc', 'origin' => 'documents', 'source' => 'default:personal:root:doc1'],
|
|
);
|
|
|
|
$this->assertSame('added', $result['disposition']);
|
|
$this->assertCount(2, $result['attachments']);
|
|
$this->assertSame('device', $result['attachments']['attach-upload']['origin']);
|
|
$this->assertSame('documents', $result['attachments']['attach-doc']['origin']);
|
|
}
|
|
|
|
public function testAttachmentAddCompositionNotFound(): void {
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$manager = $this->makeManager($documentsManager);
|
|
|
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, 'unknown-composition', [
|
|
'identifier' => 'attach-1',
|
|
'origin' => 'documents',
|
|
'source' => 'default:personal:root:doc1',
|
|
]);
|
|
|
|
$this->assertSame('error', $result['disposition']);
|
|
$this->assertSame('composition_not_found', $result['error']['type']);
|
|
}
|
|
|
|
public function testAttachmentAddDocumentsModuleUnavailable(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
// documents_manager is not installed/enabled: attachmentAdd must still
|
|
// work for the composition itself, just report the sourced entry as failed
|
|
// rather than throwing (e.g. from a failed DI resolution).
|
|
$manager = $this->makeManager($documentsManager, documentsModuleEnabled: false);
|
|
|
|
$result = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
|
'identifier' => 'attach-1',
|
|
'origin' => 'documents',
|
|
'source' => 'default:personal:root:doc1',
|
|
]);
|
|
|
|
$this->assertSame('error', $result['disposition']);
|
|
$this->assertCount(0, $result['attachments']);
|
|
$this->assertCount(1, $result['failed']);
|
|
$this->assertSame('default:personal:root:doc1', $result['failed'][0]['identifier']);
|
|
$this->assertStringContainsString('not installed or enabled', $result['failed'][0]['message']);
|
|
}
|
|
|
|
public function testAttachmentRemoveIncrementsRevision(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$documentsManager->method('entityFetchBulk')->willReturn([
|
|
$this->makeEntity('default:personal:root:doc1', 'hello.txt', 'text/plain'),
|
|
]);
|
|
$documentsManager->method('entityReadStream')->willReturn($this->makeStream('hello world'));
|
|
$manager = $this->makeManager($documentsManager);
|
|
|
|
$added = $manager->attachmentAdd(self::TENANT_ID, self::USER_ID, $compositionId, [
|
|
'identifier' => 'attach-1',
|
|
'origin' => 'documents',
|
|
'source' => 'default:personal:root:doc1',
|
|
]);
|
|
$this->assertSame(1, $added['revision']);
|
|
|
|
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'attach-1');
|
|
|
|
$this->assertSame('removed', $result['disposition']);
|
|
$this->assertSame(2, $result['revision']);
|
|
|
|
$snapshot = $this->compositionStore->compositionFetch(self::TENANT_ID, self::USER_ID, $compositionId);
|
|
$this->assertSame(2, $snapshot['revision']);
|
|
$this->assertArrayNotHasKey('attach-1', $snapshot['attachments']);
|
|
$this->assertSame('dirty', $snapshot['remote']['status']);
|
|
}
|
|
|
|
public function testAttachmentRemoveNotFoundDoesNotChangeRevision(): void {
|
|
$compositionId = $this->stageEmptyComposition();
|
|
$documentsManager = $this->createStub(\KTXM\DocumentsManager\Manager::class);
|
|
$manager = $this->makeManager($documentsManager);
|
|
|
|
$result = $manager->attachmentRemove(self::TENANT_ID, self::USER_ID, $compositionId, 'missing-attachment');
|
|
|
|
$this->assertSame('error', $result['disposition']);
|
|
$this->assertSame('attachment_not_found', $result['error']['type']);
|
|
$this->assertArrayNotHasKey('revision', $result);
|
|
}
|
|
|
|
private function makeManager(
|
|
Stub $documentsManager,
|
|
bool $documentsModuleEnabled = true,
|
|
?EventDispatcherInterface $events = null,
|
|
): CompositionManager {
|
|
$mailManager = $this->createStub(\KTXM\MailManager\Manager::class);
|
|
|
|
$moduleManager = $this->createStub(ModuleManager::class);
|
|
if ($documentsModuleEnabled) {
|
|
$module = $this->createStub(ModuleObject::class);
|
|
$module->method('enabled')->willReturn(true);
|
|
$moduleManager->method('fetch')->willReturn($module);
|
|
} else {
|
|
$moduleManager->method('fetch')->willReturn(null);
|
|
}
|
|
|
|
$container = $this->createStub(ContainerInterface::class);
|
|
$container->method('has')->willReturn($documentsModuleEnabled);
|
|
$container->method('get')->willReturn($documentsManager);
|
|
|
|
return new CompositionManager(
|
|
container: $container,
|
|
moduleManager: $moduleManager,
|
|
compositionStore: $this->compositionStore,
|
|
mailManager: $mailManager,
|
|
events: $events ?? $this->createStub(EventDispatcherInterface::class),
|
|
);
|
|
}
|
|
|
|
private function stageEmptyComposition(): string {
|
|
$compositionId = 'draft-' . uniqid();
|
|
$saveComposition = static fn(): array => [
|
|
'identifier' => $compositionId,
|
|
'attachments' => [],
|
|
];
|
|
|
|
$this->compositionStore->compositionSave(
|
|
self::TENANT_ID,
|
|
self::USER_ID,
|
|
$compositionId,
|
|
$saveComposition,
|
|
);
|
|
return $compositionId;
|
|
}
|
|
|
|
private function makeEntity(string $urn, string $label, string $mime): EntityBaseInterface&Stub {
|
|
$properties = $this->createStub(EntityPropertiesBaseInterface::class);
|
|
$properties->method('getLabel')->willReturn($label);
|
|
$properties->method('getMime')->willReturn($mime);
|
|
$properties->method('size')->willReturn(0);
|
|
|
|
$entity = $this->createStub(EntityBaseInterface::class);
|
|
$entity->method('urn')->willReturn($urn);
|
|
$entity->method('getProperties')->willReturn($properties);
|
|
|
|
return $entity;
|
|
}
|
|
|
|
/** @return resource */
|
|
private function makeStream(string $contents) {
|
|
$resource = fopen('php://memory', 'r+');
|
|
fwrite($resource, $contents);
|
|
rewind($resource);
|
|
return $resource;
|
|
}
|
|
|
|
private function deleteDir(string $path): void {
|
|
if (!is_dir($path)) {
|
|
return;
|
|
}
|
|
|
|
foreach (scandir($path) ?: [] as $entry) {
|
|
if ($entry === '.' || $entry === '..') {
|
|
continue;
|
|
}
|
|
|
|
$child = $path . '/' . $entry;
|
|
if (is_dir($child)) {
|
|
$this->deleteDir($child);
|
|
continue;
|
|
}
|
|
|
|
unlink($child);
|
|
}
|
|
|
|
rmdir($path);
|
|
}
|
|
}
|