diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff377ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + ci: + name: CI + uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1 + with: + dynamic_matrix: false + extra_jobs: | + - php: '8.1' + db: mysql80 + phpunit: true + installer_version: ^4 + - php: '8.2' + db: mysql80 + phpunit: true + installer_version: ^5 + - php: '8.3' + db: mariadb + phpunit: true + installer_version: ^5 + + coding-standards: + name: Coding Standards + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer:v2, php-cs-fixer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Check coding standards + run: php-cs-fixer fix --dry-run --diff + + - name: Static Analysis + run: vendor/bin/phpstan analyse \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d2bf30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/vendor/ +.phpunit.result.cache +.php-cs-cache +.env +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +composer.lock +/public/ +.php-cs-fixer.cache +phpstan.cache/ \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..a9aeee2 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,32 @@ +in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +$config = new PhpCsFixer\Config(); + +return $config + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'trailing_comma_in_multiline' => true, + 'phpdoc_align' => true, + 'phpdoc_order' => true, + 'phpdoc_separation' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_trim' => true, + 'phpdoc_var_without_name' => true, + 'return_type_declaration' => ['space_before' => 'none'], + 'single_quote' => true, + 'ternary_operator_spaces' => true, + 'unary_operator_spaces' => true, + ]) + ->setFinder($finder); \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..42061ce --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +# Silverstripe CMS RevoltEvent Dispatcher Module (experimental) +[![CI](https://github.com/archiprocode/silverstripe-revolt-event-dispatcher/actions/workflows/ci.yml/badge.svg)](https://github.com/archiprocode/silverstripe-revolt-event-dispatcher/actions/workflows/ci.yml) + +This module adds the ability to dispatch and listen for events in Silverstripe CMS. It's built around Revolt PHP +and AMPHP. It aims to process events asynchronously. It also provides some abstraction to help managing event around +common DataObject operations. + +## Installation + +```bash +composer require archipro/silverstripe-revolt-event-dispatcher +``` + +## Features +- Automatic event dispatching for DataObject operations (create, update, delete) +- Support for versioned operations (publish, unpublish, archive, restore) +- Asynchronous event handling using Revolt Event Loop + +## Setting up the Event Loop + +Because we are using Revolt PHP, you need to run the event loop to process the events. + +Somewhere in your code you need to start the event loop by running `\Revolt\EventLoop::run()`. This will process all the events up to that point. + +A simple approach is to put it at the end of your `public/index.php` file in a `try-finally` block. You can also add a `fastcgi_finish_request()` call to ensure all output is sent before processing the events. + +```php +try { + $kernel = new CoreKernel(BASE_PATH); + $app = new HTTPApplication($kernel); + $response = $app->handle($request); + $response->output(); +} finally { + // This call will complete the request without closing the PHP worker. A nice side effect of this is that your + // event listeners won't block your request from being sent to the client. So you can use them to run slow + // operations like sending emails or doing API calls without delaying the response. + fastcgi_finish_request(); + + // Many methods in Silverstripe CMS rely on having a current controller with a request. + $controller = new Controller(); + $controller->setRequest($request); + $controller->pushCurrent(); + + // Now we can process the events in the event loop + \Revolt\EventLoop::run(); +} +``` + +### TODO + +- Need to find a an elegant way to run the event loop on `sake` commands. This won't hit `public/index.php`. + +## Basic Usage + +### Firing a Custom Event + +```php +use SilverStripe\Core\Injector\Injector; +use ArchiPro\Silverstripe\EventDispatcher\Service\EventService; + +// Create your event class +class MyCustomEvent +{ + public function __construct( + private readonly string $message + ) {} + + public function getMessage(): string + { + return $this->message; + } +} + +// Dispatch the event +$event = new MyCustomEvent('Hello World'); +$service = Injector::inst()->get(EventService::class); +$service->dispatch($event); +``` + +### Adding a Simple Event Listener + +```php +use SilverStripe\Core\Injector\Injector; +use ArchiPro\Silverstripe\EventDispatcher\Service\EventService; + +// Add a listener +$service = Injector::inst()->get(EventService::class); +$service->addListener(MyCustomEvent::class, function(MyCustomEvent $event) { + error_log('MyCustomEventListener::handleEvent was called'); +}); +``` + +### Configuration-based Listeners + +You can register listeners via YAML configuration: + +```yaml +ArchiPro\Silverstripe\EventDispatcher\Service\EventService: + listeners: + MyCustomEvent: + - ['MyApp\EventListener', 'handleEvent'] +``` + +## Registering many listeners at once with loaders + +You can use listeners loaders to register many listeners at once. + +```php +selfRegister($provider); + } + + public function onMemberCreated(DataObjectEvent $event): void + { + $member = $event->getObject(); + Email::create() + ->setTo($member->Email) + ->setSubject('Welcome to our site') + ->setFrom('no-reply@example.com') + ->setBody('Welcome to our site') + ->send(); + } +} +``` + +Loaders can be registered in your YAML configuration file: +```yaml +ArchiPro\Silverstripe\EventDispatcher\Service\EventService: + loaders: + - MemberListenerLoader +``` + +## DataObject Event Handling + +This module automatically dispatches events for DataObject operations. You can listen for these events using the +`DataObjectEventListener` class. + +### Firing DataObject Events + +Applying the `EventDispatchExtension` to a DataObject will automatically fire events when changes are made to an +instance of that DataObject. + +```yaml + +## This will fire events for SiteTree instances only +SilverStripe\SiteTree\SiteTree: + extensions: + - ArchiPro\Silverstripe\EventDispatcher\Extension\EventDispatchExtension + +## This will fire events for all DataObjects +SilverStripe\ORM\DataObject: + extensions: + - ArchiPro\Silverstripe\EventDispatcher\Extension\EventDispatchExtension +``` + +### Listening for DataObject Events + +```php +use ArchiPro\Silverstripe\EventDispatcher\Event\DataObjectEvent; +use ArchiPro\Silverstripe\EventDispatcher\Event\Operation; +use ArchiPro\Silverstripe\EventDispatcher\Listener\DataObjectEventListener; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Security\Member; + +// Create a listener for all Member operations +DataObjectEventListener::create( + function (DataObjectEvent $event) { + echo "Operation {$event->getOperation()->value} performed on Member {$event->getObjectID()}"; + }, + [Member::class] +)->selfRegister(); + +// Listen for specific operations on multiple classes +DataObjectEventListener::create( + function (DataObjectEvent $event) { + // Handle create/update operations + }, + [Member::class, Group::class], + [Operation::CREATE, Operation::UPDATE] +)->selfRegister(); +``` + +### Available Operations + +The following operations are automatically tracked: + +- `Operation::CREATE` - When a DataObject is first written +- `Operation::UPDATE` - When an existing DataObject is modified +- `Operation::DELETE` - When a DataObject is deleted +- `Operation::PUBLISH` - When a versioned DataObject is published +- `Operation::UNPUBLISH` - When a versioned DataObject is unpublished +- `Operation::ARCHIVE` - When a versioned DataObject is archived +- `Operation::RESTORE` - When a versioned DataObject is restored from archive + +### Accessing Event Data + +The `DataObjectEvent` class provides several methods to access information about the event: + +```php +DataObjectEventListener::create( + function (DataObjectEvent $event) { + $object = $event->getObject(); // Get the affected DataObject + $class = $event->getObjectClass(); // Get the class name + $operation = $event->getOperation(); // Get the operation type + $version = $event->getVersion(); // Get version number (if versioned) + $member = $event->getMember(); // Get the Member who performed the action + $time = $event->getTimestamp(); // Get when the event occurred + }, + [DataObject::class] +)->selfRegister(); +``` + +`DataObjectEvent` is configured to be serializable so it can easily be stored for later use. + +Note that `DataObjectEvent` doesn't store the actual DataObject instance that caused the event to be fired. +`DataObjectEvent::getObject()` will refetch the latest version of the DataObject from the database ... which will +return `null` if the DataObject has been deleted. + +`DataObjectEvent::getObject(true) will attempt to retrieve the exact version of the DataObject that fired the event, +assuming it was versioned. + +## Testing Your Events + +### Writing Event Tests + +When testing your event listeners, you'll need to: +1. Dispatch your events +2. Run the event loop +3. Assert the expected outcomes + +Here's an example test: + +```php +use Revolt\EventLoop; +use SilverStripe\Dev\SapphireTest; +use SilverStripe\Core\Injector\Injector; +use ArchiPro\Silverstripe\EventDispatcher\Service\EventService; + +class MyEventTest extends SapphireTest +{ + public function testMyCustomEvent(): void + { + // Create your test event + $event = new MyCustomEvent('test message'); + + // Get the event service + $service = Injector::inst()->get(EventService::class); + + // Add your test listener ... or if you have already + $wasCalled = false; + $service->addListener( + MyCustomEvent::class, + [MyCustomEventListener::class, 'handleEvent'] + ); + + // Dispatch the event + $service->dispatch($event); + + // Run the event loop to process events + EventLoop::run(); + + // Assert your listener was called + $this->assertTrue( + MyCustomEventListener::wasCalled(), + 'Assert some side effect of the event being handled' + ); + } +} +``` + +### Disabling event dispatching + +You can disable event dispatching for test to avoid side affects from irrelevant events that might be fired while +scaffolding fixtures. + +Call `EventService::singleton()->disableDispatch()` to disable event dispatching while setting up your test. + +When you are ready to start running your test, call `EventService::singleton()->enableDispatch()` to start listening for +events again. + +### Important Testing Notes + +- Events are processed asynchronously by default. You can force processing of events by: + - calling `EventLoop::run()` or + - calling `await()` on the returned value of the `dispatch` method. e.g.: `EventService::dispatch($event)->await()`. +- For DataObject events, make sure your test class applies the `EventDispatchExtension` to the relevant DataObject + classes. diff --git a/_config/events.yml b/_config/events.yml new file mode 100644 index 0000000..6579fbe --- /dev/null +++ b/_config/events.yml @@ -0,0 +1,23 @@ +--- +Name: events +After: + - '#coreservices' +--- +SilverStripe\Core\Injector\Injector: + # Define the listener provider + ArchiPro\EventDispatcher\ListenerProvider: + class: ArchiPro\EventDispatcher\ListenerProvider + + # Default event dispatcher + ArchiPro\EventDispatcher\AsyncEventDispatcher: + class: ArchiPro\EventDispatcher\AsyncEventDispatcher + constructor: + listenerProvider: '%$ArchiPro\EventDispatcher\ListenerProvider' + Psr\EventDispatcher\EventDispatcherInterface: + alias: '%$ArchiPro\EventDispatcher\AsyncEventDispatcher' + + # Bootstrap the event service + ArchiPro\Silverstripe\EventDispatcher\Service\EventService: + constructor: + dispatcher: '%$ArchiPro\EventDispatcher\AsyncEventDispatcher' + listenerProvider: '%$ArchiPro\EventDispatcher\ListenerProvider' \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1a898be --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "archipro/silverstripe-revolt-event-dispatcher", + "description": "A Revolt Event Dispatcher integration for Silverstripe CMS", + "type": "silverstripe-vendormodule", + "license": "MIT", + "require": { + "php": "^8.1", + "silverstripe/framework": "^4.13 || ^5.0", + "silverstripe/versioned": "^1.13 || ^2.0", + "psr/event-dispatcher": "^1.0", + "psr/event-dispatcher-implementation": "^1.0", + "archipro/revolt-event-dispatcher": "^0.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.0", + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "ArchiPro\\Silverstripe\\EventDispatcher\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ArchiPro\\Silverstripe\\EventDispatcher\\Tests\\": "tests/php/" + } + }, + "scripts": { + "lint": "php-cs-fixer fix --dry-run --diff", + "lint-fix": "php-cs-fixer fix", + "analyse": "phpstan analyse", + "test": "phpunit" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "expose": [] + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "silverstripe/vendor-plugin": true + } + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..562c415 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + level: 8 + paths: + - src + - tests + tmpDir: phpstan.cache + excludePaths: + analyseAndScan: + - vendor + ignoreErrors: + - '#Static property .* is never read, only written\.#' + - '#Offset \d+ does not exist on array\{\}#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..3801dc3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,8 @@ + + + + + tests/php + + + diff --git a/src/Contract/ListenerLoaderInterface.php b/src/Contract/ListenerLoaderInterface.php new file mode 100644 index 0000000..413ff80 --- /dev/null +++ b/src/Contract/ListenerLoaderInterface.php @@ -0,0 +1,25 @@ +ID, + * Operation::UPDATE, + * $dataObject->Version, + * Security::getCurrentUser()?->ID + * ); + * ``` + * + * @template T of DataObject + */ +class DataObjectEvent +{ + use Injectable; + + /** + * @var int Unix timestamp when the event was created + */ + private readonly int $timestamp; + + /** + * @param class-string $objectClass The class name of the affected DataObject + * @param int $objectID The ID of the affected DataObject + * @param Operation $operation The type of operation performed + * @param int|null $version The version number (for versioned objects) + * @param int|null $memberID The ID of the member who performed the operation + */ + public function __construct( + private readonly string $objectClass, + private readonly int $objectID, + private readonly Operation $operation, + private readonly ?int $version = null, + private readonly ?int $memberID = null + ) { + $this->timestamp = time(); + } + + /** + * Get the ID of the affected DataObject + */ + public function getObjectID(): int + { + return $this->objectID; + } + + /** + * Get the class name of the affected DataObject + */ + public function getObjectClass(): string + { + return $this->objectClass; + } + + /** + * Get the type of operation performed + */ + public function getOperation(): Operation + { + return $this->operation; + } + + /** + * Get the version number (for versioned objects) + */ + public function getVersion(): ?int + { + return $this->version; + } + + /** + * Get the ID of the member who performed the operation + */ + public function getMemberID(): ?int + { + return $this->memberID; + } + + /** + * Get the timestamp when the event was created + */ + public function getTimestamp(): int + { + return $this->timestamp; + } + + /** + * Get the DataObject associated with this event + * + * @phpstan-return T|null + * + * @param bool $useVersion If true and the object is versioned, retrieves the specific version that was affected + * Note: This may return null if the object has been deleted since the event was created + */ + public function getObject(bool $useVersion = false): ?DataObject + { + if (!$this->objectID) { + return null; + } + + if (!$useVersion || empty($this->version)) { + /** @var T|null $object */ + $object = DataObject::get($this->objectClass)->byID($this->objectID); + return $object; + } + + return Versioned::get_version($this->objectClass, $this->objectID, $this->version); + } + + /** + * Get the Member who performed the operation + * + * Note: This may return null if the member has been deleted since the event was created + * or if the operation was performed by a system process + */ + public function getMember(): ?Member + { + if (!$this->memberID) { + return null; + } + + return Member::get()->byID($this->memberID); + } + + /** + * Serialize the event to a string + */ + public function serialize(): string + { + return serialize([ + 'objectID' => $this->objectID, + 'objectClass' => $this->objectClass, + 'operation' => $this->operation, + 'version' => $this->version, + 'memberID' => $this->memberID, + 'timestamp' => $this->timestamp, + ]); + } + + /** + * Unserialize the event from a string + * + * @param string $data + */ + public function unserialize(string $data): void + { + $unserialized = unserialize($data); + + // Use reflection to set readonly properties + $reflection = new \ReflectionClass($this); + + foreach ($unserialized as $property => $value) { + $prop = $reflection->getProperty($property); + $prop->setAccessible(true); + $prop->setValue($this, $value); + } + } +} diff --git a/src/Event/Operation.php b/src/Event/Operation.php new file mode 100644 index 0000000..b0975a8 --- /dev/null +++ b/src/Event/Operation.php @@ -0,0 +1,28 @@ + + */ +class EventDispatchExtension extends Extension +{ + /** + * Fires an event after the object is written (created or updated) + */ + public function onAfterWrite(): void + { + $owner = $this->getOwner(); + $event = DataObjectEvent::create( + get_class($owner), + $owner->ID, + // By this point isInDB() will return true even for new records since the ID is already set + // Instead check if the ID field was changed which indicates this is a new record + $owner->isChanged('ID') ? Operation::CREATE : Operation::UPDATE, + $this->getVersion(), + Security::getCurrentUser()?->ID + ); + + $this->dispatchEvent($event); + } + + /** + * Fires before a DataObject is deleted from the database + */ + public function onBeforeDelete(): void + { + $owner = $this->getOwner(); + $event = DataObjectEvent::create( + get_class($owner), + $owner->ID, + Operation::DELETE, + $this->getVersion(), + Security::getCurrentUser()?->ID + ); + + $this->dispatchEvent($event); + } + + /** + * Fires when a versioned DataObject is published + */ + public function onAfterPublish(): void + { + $owner = $this->getOwner(); + if (!$owner->hasExtension(Versioned::class)) { + return; + } + + $event = DataObjectEvent::create( + get_class($owner), + $owner->ID, + Operation::PUBLISH, + $this->getVersion(), + Security::getCurrentUser()?->ID + ); + + $this->dispatchEvent($event); + } + + /** + * Fires when a versioned DataObject is unpublished + */ + public function onAfterUnpublish(): void + { + $owner = $this->getOwner(); + if (!$owner->hasExtension(Versioned::class)) { + return; + } + + $event = DataObjectEvent::create( + get_class($owner), + $owner->ID, + Operation::UNPUBLISH, + $this->getVersion(), + Security::getCurrentUser()?->ID + ); + + $this->dispatchEvent($event); + } + + /** + * Fires when a versioned DataObject is archived + */ + public function onAfterArchive(): void + { + $owner = $this->getOwner(); + if (!$owner->hasExtension(Versioned::class)) { + return; + } + + $event = DataObjectEvent::create( + get_class($owner), + $owner->ID, + Operation::ARCHIVE, + $this->getVersion(), + Security::getCurrentUser()?->ID + ); + + $this->dispatchEvent($event); + } + + /** + * Fires when a versioned DataObject is restored from archive + */ + public function onAfterRestore(): void + { + $owner = $this->getOwner(); + if (!$owner->hasExtension(Versioned::class)) { + return; + } + + $event = DataObjectEvent::create( + get_class($owner), + $owner->ID, + Operation::RESTORE, + $this->getVersion(), + Security::getCurrentUser()?->ID + ); + + $this->dispatchEvent($event); + } + + /** + * Dispatches an event using the EventService + * + * @phpstan-param DataObjectEvent $event + * + * @phpstan-return Future> + */ + protected function dispatchEvent(DataObjectEvent $event): Future + { + return Injector::inst()->get(EventService::class)->dispatch($event); + } + + private function getVersion(): ?int + { + $owner = $this->getOwner(); + if (!$owner->hasExtension(Versioned::class)) { + return null; + } + + /** @var Versioned $owner */ + return $owner->Version; + } +} diff --git a/src/Listener/DataObjectEventListener.php b/src/Listener/DataObjectEventListener.php new file mode 100644 index 0000000..eb9b6cc --- /dev/null +++ b/src/Listener/DataObjectEventListener.php @@ -0,0 +1,100 @@ +): void $callback Callback to execute when an event matches + * @param class-string[] $classes Array of DataObject class names to listen for + * @param Operation[] $operations Array of operations to listen for. If null, listens for all operations. + */ + public function __construct( + private readonly Closure $callback, + private readonly array $classes, + array $operations = null + ) { + $this->operations = $operations ?? Operation::cases(); + } + + /** + * Registers this listener with the given provider. + * + * If no provider is provided, the global EventService will be used. + */ + public function selfRegister(ListenerProvider|EventService $provider = null): void + { + if (empty($provider)) { + $provider = Injector::inst()->get(EventService::class); + } + $provider->addListener(DataObjectEvent::class, $this); + } + + /** + * Handles a DataObject event. + * + * Checks if the event matches the configured operations and classes, + * and executes the callback if it does. + * + * @param DataObjectEvent $event The event to handle + */ + public function __invoke(DataObjectEvent $event): void + { + // Check if we should handle this class + if (!$this->shouldHandleClass($event->getObjectClass())) { + return; + } + + // Check if we should handle this operation + if (!in_array($event->getOperation(), $this->operations)) { + return; + } + + // Execute callback + call_user_func($this->callback, $event); + } + + /** + * Checks if the given class matches any of the configured target classes. + * + * A match occurs if the class is either the same as or a subclass of any target class. + * + * @param string $class The class name to check + * + * @return bool True if the class should be handled, false otherwise + */ + private function shouldHandleClass(string $class): bool + { + foreach ($this->classes as $targetClass) { + if (is_a($class, $targetClass, true)) { + return true; + } + } + return false; + } +} diff --git a/src/Service/EventService.php b/src/Service/EventService.php new file mode 100644 index 0000000..cf64f92 --- /dev/null +++ b/src/Service/EventService.php @@ -0,0 +1,151 @@ +> Map of event class names to arrays of listener callbacks + */ + private static array $listeners = []; + + /** + * @config + * + * @var array Array of listener loaders + */ + private static array $loaders = []; + + /** Whether events should be suppressed from being dispatched. Used for testing. */ + private bool $suppressDispatch = false; + + public function __construct( + private readonly AsyncEventDispatcher $dispatcher, + private readonly ListenerProvider $listenerProvider + ) { + $this->registerListeners(); + $this->loadListeners(); + } + + /** + * Registers listeners from the configuration + */ + private function registerListeners(): void + { + $listeners = $this->config()->get('listeners'); + if (empty($listeners)) { + return; + } + + foreach ($listeners as $eventClass => $listeners) { + foreach ($listeners as $listener) { + if (is_string($listener)) { + $listener = Injector::inst()->get($listener); + } + $this->addListener($eventClass, $listener); + } + } + } + + /** + * Loads listeners from the configuration + */ + private function loadListeners(): void + { + foreach ($this->config()->get('loaders') as $loader) { + if (is_string($loader)) { + $loader = Injector::inst()->get($loader); + } + $this->addListenerLoader($loader); + } + } + + /** + * Adds a listener to the event service + * + * @template T of object + * + * @param class-string $event The event class name + * @param callable(T): void $listener The listener callback + */ + public function addListener(string $event, callable $listener): void + { + $this->listenerProvider->addListener($event, $listener); + } + + /** + * Adds a listener loader to the event service + * + * @throws \RuntimeException If the loader does not implement ListenerLoaderInterface + */ + public function addListenerLoader(ListenerLoaderInterface $loader): void + { + if (!$loader instanceof ListenerLoaderInterface) { + throw new \RuntimeException(sprintf( + 'Listener loader class "%s" must implement ListenerLoaderInterface', + get_class($loader) + )); + } + $loader->loadListeners($this->listenerProvider); + } + + /** + * Dispatches an event to all registered listeners + * + * @template T of object + * + * @param T $event + * + * @return Future + */ + public function dispatch(object $event): Future + { + if ($this->suppressDispatch) { + return Future::complete($event); + } + return $this->dispatcher->dispatch($event); + } + + /** + * Gets the listener provider instance + */ + public function getListenerProvider(): ListenerProvider + { + return $this->listenerProvider; + } + + /** + * Enables event dispatching. Use when testing to avoid side effects. + */ + public function enableDispatch(): void + { + $this->suppressDispatch = false; + } + + /** + * Disables event dispatching. Use when testing to avoid side effects. + */ + public function disableDispatch(): void + { + $this->suppressDispatch = true; + } +} diff --git a/tests/php/Event/DataObjectEventTest.php b/tests/php/Event/DataObjectEventTest.php new file mode 100644 index 0000000..6c159b1 --- /dev/null +++ b/tests/php/Event/DataObjectEventTest.php @@ -0,0 +1,102 @@ +assertEquals(1, $event->getObjectID()); + $this->assertEquals(SimpleDataObject::class, $event->getObjectClass()); + $this->assertEquals(Operation::CREATE, $event->getOperation()); + $this->assertNull($event->getVersion()); + $this->assertEquals(1, $event->getMemberID()); + $this->assertGreaterThan(0, $event->getTimestamp()); + } + + public function testGetObject(): void + { + /** @var SimpleDataObject $object */ + $object = $this->objFromFixture(SimpleDataObject::class, 'object1'); + + $event = DataObjectEvent::create(SimpleDataObject::class, $object->ID, Operation::UPDATE); + + $this->assertNotNull($event->getObject()); + $this->assertEquals($object->ID, $event->getObject()->ID); + } + + public function testGetVersionedObject(): void + { + /** @var VersionedDataObject $object */ + $object = $this->objFromFixture(VersionedDataObject::class, 'versioned1'); + + // Create a new version + $object->Title = 'Updated Title'; + $object->write(); + + /** @var DataObjectEvent $event */ + $event = DataObjectEvent::create(VersionedDataObject::class, $object->ID, Operation::UPDATE, $object->Version); + + // Get current version + /** @var VersionedDataObject $currentObject */ + $currentObject = $event->getObject(false); + $this->assertEquals('Updated Title', $currentObject->Title); + + // Get specific version + /** @var VersionedDataObject $versionedObject */ + $versionedObject = $event->getObject(true); + $this->assertEquals('Updated Title', $versionedObject->Title); + + // Get previous version + /** @var DataObjectEvent $previousEvent */ + $previousEvent = DataObjectEvent::create(VersionedDataObject::class, $object->ID, Operation::UPDATE, $object->Version - 1); + /** @var VersionedDataObject $previousVersion */ + $previousVersion = $previousEvent->getObject(true); + $this->assertEquals('Original Title', $previousVersion->Title); + } + + public function testGetMember(): void + { + /** @var Member $member */ + $member = $this->objFromFixture(Member::class, 'member1'); + + $event = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE, null, $member->ID); + + $this->assertNotNull($event->getMember()); + $this->assertEquals($member->ID, $event->getMember()->ID); + } + + public function testSerialization(): void + { + $event = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE, 2, 3); + + $serialized = serialize($event); + /** @var DataObjectEvent $unserialized */ + $unserialized = unserialize($serialized); + + $this->assertEquals(1, $unserialized->getObjectID()); + $this->assertEquals(SimpleDataObject::class, $unserialized->getObjectClass()); + $this->assertEquals(Operation::CREATE, $unserialized->getOperation()); + $this->assertEquals(2, $unserialized->getVersion()); + $this->assertEquals(3, $unserialized->getMemberID()); + $this->assertEquals($event->getTimestamp(), $unserialized->getTimestamp()); + } +} diff --git a/tests/php/Event/DataObjectEventTest.yml b/tests/php/Event/DataObjectEventTest.yml new file mode 100644 index 0000000..deb1782 --- /dev/null +++ b/tests/php/Event/DataObjectEventTest.yml @@ -0,0 +1,13 @@ +ArchiPro\Silverstripe\EventDispatcher\Tests\Mock\SimpleDataObject: + object1: + Title: 'Test Object' + +ArchiPro\Silverstripe\EventDispatcher\Tests\Mock\VersionedDataObject: + versioned1: + Title: 'Original Title' + +SilverStripe\Security\Member: + member1: + FirstName: 'Test' + Surname: 'User' + Email: 'test@example.com' \ No newline at end of file diff --git a/tests/php/Extension/EventDispatchExtensionTest.php b/tests/php/Extension/EventDispatchExtensionTest.php new file mode 100644 index 0000000..746350d --- /dev/null +++ b/tests/php/Extension/EventDispatchExtensionTest.php @@ -0,0 +1,123 @@ +[] */ + protected static $extra_dataobjects = [ + SimpleDataObject::class, + VersionedDataObject::class, + ]; + + /** @var DataObjectEvent[] */ + protected static array $events = []; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + DataObjectEventListener::create( + function (DataObjectEvent $event) { + static::$events[] = $event; + }, + [SimpleDataObject::class, VersionedDataObject::class] + )->selfRegister(); + } + + protected function setUp(): void + { + parent::setUp(); + static::$events = []; + } + + + public function testWriteEvents(): void + { + // Test create + $object = SimpleDataObject::create(['Title' => 'Test']); + $object->write(); + EventLoop::run(); + + $this->assertCount(1, static::$events); + $this->assertEquals(Operation::CREATE, static::$events[0]->getOperation()); + + // Clear events + static::$events = []; + + // Test update + $object->Title = 'Updated'; + $object->write(); + + EventLoop::run(); + + $this->assertCount(1, static::$events); + $this->assertEquals(Operation::UPDATE, static::$events[0]->getOperation()); + } + + public function testDeleteEvent(): void + { + $object = SimpleDataObject::create(['Title' => 'Test']); + $object->write(); + EventLoop::run(); + + static::$events = []; + $object->delete(); + EventLoop::run(); + + $this->assertCount(1, static::$events); + $this->assertEquals(Operation::DELETE, static::$events[0]->getOperation()); + } + + public function testVersionedEvents(): void + { + /** @var Member $member */ + $member = $this->objFromFixture(Member::class, 'member1'); + Security::setCurrentUser($member); + + /** @var VersionedDataObject $object */ + $object = VersionedDataObject::create(['Title' => 'Test']); + $object->write(); + + EventLoop::run(); + static::$events = []; + + // Test publish + $object->publishRecursive(); + EventLoop::run(); + + $this->assertCount(2, static::$events, 'Expected 2 events, 1 for create and 1 for publish'); + $this->assertEquals(Operation::PUBLISH, static::$events[1]->getOperation()); + $this->assertEquals($member->ID, static::$events[1]->getMemberID()); + + // Test unpublish + static::$events = []; + $object->doUnpublish(); + EventLoop::run(); + + $this->assertCount(2, static::$events, 'Expected 2 events, 1 for deleting the live version and 1 for unpublish'); + $this->assertEquals(Operation::UNPUBLISH, static::$events[1]->getOperation()); + + // Test archive + static::$events = []; + $object->doArchive(); + EventLoop::run(); + + $this->assertCount(2, static::$events, 'Expected 2 events, 1 for deleting the draft version version and 1 for archive'); + $this->assertEquals(Operation::ARCHIVE, static::$events[1]->getOperation()); + } +} diff --git a/tests/php/Extension/EventDispatchExtensionTest.yml b/tests/php/Extension/EventDispatchExtensionTest.yml new file mode 100644 index 0000000..874fe2e --- /dev/null +++ b/tests/php/Extension/EventDispatchExtensionTest.yml @@ -0,0 +1,5 @@ +SilverStripe\Security\Member: + member1: + FirstName: 'Test' + Surname: 'User' + Email: 'test@example.com' \ No newline at end of file diff --git a/tests/php/Listener/DataObjectEventListenerTest.php b/tests/php/Listener/DataObjectEventListenerTest.php new file mode 100644 index 0000000..9467958 --- /dev/null +++ b/tests/php/Listener/DataObjectEventListenerTest.php @@ -0,0 +1,150 @@ +[] */ + protected static $extra_dataobjects = [ + SimpleDataObject::class, + VersionedDataObject::class, + ]; + + /** @var DataObjectEvent[] */ + protected array $receivedEvents = []; + + protected function setUp(): void + { + parent::setUp(); + $this->receivedEvents = []; + } + + public function testListenerFiltersByClass(): void + { + // Create listener that only handles SimpleDataObject events + $listener = DataObjectEventListener::create( + function (DataObjectEvent $event) { + $this->receivedEvents[] = $event; + }, + [SimpleDataObject::class] + ); + + // Should handle SimpleDataObject event + $simpleEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE); + $listener($simpleEvent); + $this->assertCount(1, $this->receivedEvents, 'Listener should handle SimpleDataObject events'); + + // Should not handle VersionedDataObject event + $versionedEvent = DataObjectEvent::create(VersionedDataObject::class, 1, Operation::CREATE); + $listener($versionedEvent); + $this->assertCount(1, $this->receivedEvents, 'Listener should not handle VersionedDataObject events'); + } + + public function testListenerHandlesInheritedClasses(): void + { + // Create listener that handles all DataObject events + $listener = DataObjectEventListener::create( + function (DataObjectEvent $event) { + $this->receivedEvents[] = $event; + }, + [DataObject::class] + ); + + // Should handle both SimpleDataObject and VersionedDataObject events + $simpleEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE); + $versionedEvent = DataObjectEvent::create(VersionedDataObject::class, 1, Operation::CREATE); + + $listener($simpleEvent); + $listener($versionedEvent); + + $this->assertCount(2, $this->receivedEvents, 'Listener should handle events from DataObject subclasses'); + } + + public function testListenerFiltersByOperation(): void + { + // Create listener that only handles CREATE and UPDATE operations + $listener = DataObjectEventListener::create( + function (DataObjectEvent $event) { + $this->receivedEvents[] = $event; + }, + [SimpleDataObject::class], + [Operation::CREATE, Operation::UPDATE] + ); + + // Should handle CREATE event + $createEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::CREATE); + $listener($createEvent); + $this->assertCount(1, $this->receivedEvents, 'Listener should handle CREATE events'); + + // Should handle UPDATE event + $updateEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::UPDATE); + $listener($updateEvent); + $this->assertCount(2, $this->receivedEvents, 'Listener should handle UPDATE events'); + + // Should not handle DELETE event + $deleteEvent = DataObjectEvent::create(SimpleDataObject::class, 1, Operation::DELETE); + $listener($deleteEvent); + $this->assertCount(2, $this->receivedEvents, 'Listener should not handle DELETE events'); + } + + public function testListenerHandlesAllOperationsWhenNotSpecified(): void + { + // Create listener without specifying operations + $listener = DataObjectEventListener::create( + function (DataObjectEvent $event) { + $this->receivedEvents[] = $event; + }, + [SimpleDataObject::class] + ); + + // Should handle all operations + foreach (Operation::cases() as $operation) { + $event = DataObjectEvent::create(SimpleDataObject::class, 1, $operation); + $listener($event); + } + + $this->assertCount( + count(Operation::cases()), + $this->receivedEvents, + 'Listener should handle all operations when none specified' + ); + } + + public function testSelfRegister(): void + { + // Create a mock event service + /** @var MockObject&ListenerProvider $provider */ + $provider = $this->createMock(ListenerProvider::class); + $provider->expects($this->once()) + ->method('addListener') + ->with( + DataObjectEvent::class, + $this->isInstanceOf(DataObjectEventListener::class) + ); + + // Create listener and register with mock service + $listener = DataObjectEventListener::create( + function (DataObjectEvent $event) { + $this->receivedEvents[] = $event; + }, + [SimpleDataObject::class] + ); + $listener->selfRegister($provider); + } +} diff --git a/tests/php/Mock/SimpleDataObject.php b/tests/php/Mock/SimpleDataObject.php new file mode 100644 index 0000000..164b267 --- /dev/null +++ b/tests/php/Mock/SimpleDataObject.php @@ -0,0 +1,26 @@ + */ + private static array $db = [ + 'Title' => 'Varchar', + ]; + + /** @var class-string>[] */ + private static array $extensions = [ + EventDispatchExtension::class, + ]; +} diff --git a/tests/php/Mock/VersionedDataObject.php b/tests/php/Mock/VersionedDataObject.php new file mode 100644 index 0000000..b9e46b3 --- /dev/null +++ b/tests/php/Mock/VersionedDataObject.php @@ -0,0 +1,30 @@ + */ + private static array $db = [ + 'Title' => 'Varchar', + ]; + + /** @var class-string>[] */ + private static array $extensions = [ + EventDispatchExtension::class, + Versioned::class, + ]; +} diff --git a/tests/php/Service/EventServiceTest.php b/tests/php/Service/EventServiceTest.php new file mode 100644 index 0000000..6e253fd --- /dev/null +++ b/tests/php/Service/EventServiceTest.php @@ -0,0 +1,117 @@ +get(EventService::class); + } + + public function testEventDispatch(): void + { + // Create test event + $event = new class () { + public bool $handled = false; + }; + + $service = $this->getService(); + + // Add test listener + $service->addListener(get_class($event), function ($event) { + $event->handled = true; + }); + + // Dispatch event + $result = $service->dispatch($event)->await(); + + // Assert listener was called + $this->assertTrue($result->handled, 'Event listener should have been called'); + } + + public function testEventDispatchWithConfiguredListener(): void + { + // Create test event + $event = new class () { + public bool $handled = false; + }; + // Configure listener via config + $eventClass = get_class($event); + EventService::config()->set('listeners', [ + $eventClass => [ + function ($event) { + $event->handled = true; + }, + ], + ]); + + $service = $this->getService(); + + // Dispatch event + $result = $service->dispatch($event)->await(); + + // Assert listener was called + $this->assertTrue($result->handled, 'Configured event listener should have been called'); + } + + public function testEventDispatchWithConfiguredLoader(): void + { + // Create test event + $event = new class () { + public bool $handled = false; + }; + + // Create test loader + $loader = new TestListenerLoader(get_class($event)); + + // Configure loader via config + EventService::config()->set('loaders', [$loader]); + + $service = $this->getService(); + $this->assertTrue($loader->loaded, 'Loader should have been used'); + + // Dispatch event + $result = $service->dispatch($event); + + EventLoop::run(); + + // Assert loader was used and listener was called + $this->assertTrue($loader->eventFired, 'Configured event listener should have been called'); + } + + public function testEventDispatchWithDisabledDispatch(): void + { + // Create test event + $event = new class () { + public bool $handled = false; + }; + + $service = $this->getService(); + + // Add test listener + $service->addListener(get_class($event), function ($event) { + $event->handled = true; + }); + + // Dispatch event + $service->disableDispatch(); + $result = $service->dispatch($event)->await(); + + // Assert listener was called + $this->assertFalse($result->handled, 'Event listener should not have been called when dispatch is disabled'); + + // Re-enabled dispatch + $service->enableDispatch(); + $result = $service->dispatch($event)->await(); + + // Assert listener was called + $this->assertTrue($result->handled, 'Event listener should have been called when dispatch is re-enabled'); + } +} diff --git a/tests/php/TestListenerLoader.php b/tests/php/TestListenerLoader.php new file mode 100644 index 0000000..6310f8f --- /dev/null +++ b/tests/php/TestListenerLoader.php @@ -0,0 +1,37 @@ + $eventName + */ + public function __construct( + private string $eventName + ) { + } + + public function loadListeners(ListenerProvider $provider): void + { + $this->loaded = true; + $provider->addListener($this->eventName, [$this, 'handleEvent']); + } + + public function handleEvent(object $event): void + { + $this->eventFired = true; + } +}